Skip to content

feat(core): SoftDeleteMixin and restore infrastructure#39977

Merged
eschutho merged 22 commits into
apache:masterfrom
mikebridge:sc-106188-soft-delete-infrastructure
May 29, 2026
Merged

feat(core): SoftDeleteMixin and restore infrastructure#39977
eschutho merged 22 commits into
apache:masterfrom
mikebridge:sc-106188-soft-delete-infrastructure

Conversation

@mikebridge

@mikebridge mikebridge commented May 8, 2026

Copy link
Copy Markdown
Contributor

SUMMARY

First of four PRs decomposing the soft-delete work for charts, dashboards, and datasets, SIP-208 — passed 2026-05-08).

This PR ships the infrastructure with zero behaviour change. No model uses the mixin yet, no migration runs, no API surface changes, no permission migrations. Everything in this PR is dormant until the entity-rollout PRs (linked below) opt their concrete entities in.

Entity rollouts (forthcoming, depend on this PR)

Each entity-rollout PR adds SoftDeleteMixin to its model, ships a one-table migration adding the deleted_at column + index, wires the entity-specific concrete subclasses of the abstractions in this PR, and adds entity-level integration tests.

What this PR ships

Runtime mechanism (superset/models/helpers.py, superset/initialization/__init__.py)

  • SoftDeleteMixin — adds a nullable deleted_at column, is_deleted hybrid property, where_not_deleted() filter clause, soft_delete() and restore() methods.
  • _add_soft_delete_filterdo_orm_execute listener using SQLAlchemy's officially-recommended pattern (Mike Bayer / sqlalchemy#7973). On every primary user SELECT, iterates concrete SoftDeleteMixin subclasses and attaches a with_loader_criteria(cls, cls.where_not_deleted(), include_aliases=True) for each one not in the request's bypass set. Per-class scoping means a bypass for Dashboard does not unhide soft-deleted Slice or SqlaTable rows. Subclass iteration is sorted by __qualname__ so SQLAlchemy's compiled-statement cache key is stable across processes. No-op until something inherits the mixin.
  • Two opt-out vehicles, one key (SKIP_VISIBILITY_FILTER_CLASSES) — the listener consults the union of:
    • execution_options[SKIP_VISIBILITY_FILTER_CLASSES] = {Model} on a Query — one-statement narrow scope; used by BaseDAO.find_by_id(skip_visibility_filter=True), security_manager.raise_for_ownership, and find_existing_for_import.
    • session.info[SKIP_VISIBILITY_FILTER_CLASSES] = {Model, ...} on a Session — session-scoped (i.e., request-scoped under Flask-SQLAlchemy) bypass that survives query reconstruction. Used by BaseDeletedStateFilter because FAB's SQLAInterface.get_outer_query_from_inner_query builds the outer fetch query from a fresh session.query(self.obj), dropping the inner query's execution_options. Session-level state survives that reconstruction; per-query options do not.
  • skip_visibility_filter(session, *classes) context manager — programmatic bypass with guaranteed cleanup on exception. Wraps the session.info mechanism.

BaseDAO surface (superset/daos/base.py, superset/daos/database.py)

  • BaseDAO.soft_delete(items) — calls item.soft_delete() on each.
  • BaseDAO.hard_delete(items) — calls db.session.delete(item) on each (the original BaseDAO.delete behaviour, renamed and preserved).
  • BaseDAO.delete(items) — routes to soft for SoftDeleteMixin-inheriting models, hard otherwise. Backwards-compatible: until any model inherits the mixin, delete() continues to call hard_delete() exactly as before.
  • find_by_id, find_by_uuid, find_by_ids, _find_by_column gain a skip_visibility_filter: bool = False keyword-only parameter. Internally translates the boolean to execution_options(SKIP_VISIBILITY_FILTER_CLASSES={cls.model_cls}) — caller-facing API stays ergonomic; per-class scoping happens internally.
  • DatabaseDAO.find_by_id is updated for signature compatibility.

Restore abstractions (superset/commands/restore.py, superset/views/filters.py, superset/commands/importers/v1/utils.py, superset/constants.py)

  • BaseRestoreCommand[T]T bound to SoftDeleteMixin. Subclasses are purely declarative (four ClassVars, no methods): dao, not_found_exc, forbidden_exc, restore_failed_exc. The base run() owns the transaction wrapper, building @transaction(on_error=partial(on_error, reraise=self.restore_failed_exc)) at call time. Subclasses can't accidentally drop or mis-wire the transaction (earlier iterations required subclasses to override run() purely to add the decorator — fragile contract, now eliminated). validate() calls dao.find_by_id(uuid, id_column="uuid", skip_visibility_filter=True) — the entity's RBAC base_filter stays in effect, matching delete's lookup semantics; only the visibility filter is bypassed. Ownership is verified by raise_for_ownership after the lookup.
  • BaseDeletedStateFilter — base class for per-entity rison filters (*_deleted_state with values include / only). Adds its self.model to session.info[SKIP_VISIBILITY_FILTER_CLASSES] so the listener stops filtering this entity for the duration of the request. A separate request-scoped flag g._augment_response_with_deleted_at signals that the response should be augmented with a deleted_at field on each row — two concerns, two channels.
  • SoftDeleteApiMixin (also in views/filters.py) — REST API mixin that augments list responses with deleted_at when the augmentation flag is set. Mount on concrete REST API classes for soft-deletable entities. Chains via super().pre_get_list(data) so other inheritance-chain behaviour still runs. Keeping the augmentation in a mixin rather than on BaseSupersetModelRestApi keeps the base class focused on its own responsibility.
  • find_existing_for_import(model_cls, uuid)pure lookup helper for v1 importer pipelines. Bypasses the visibility filter and returns the row as-is whether it's live or soft-deleted (or None).
  • clear_soft_deleted_for_import(existing)explicit destructive cleanup. Hard-deletes a soft-deleted row via db.session.delete() (so ORM after_delete listeners fire and clean up tagged_object rows + dataset permission-view rows that the database cannot cascade). Entity importers (charts/dashboards/datasets) call this after the overwrite/permission check passes, so the destructive op is gated by the same ownership check as the live-overwrite path.
  • MODEL_API_RW_METHOD_PERMISSION_MAP["restore"] = "write" — so future per-entity /restore endpoints inherit can_write (the same permission as delete). No new permission, no role migration.

Design decisions documented in code

  • No feature flag — soft-delete is always-on once a model inherits the mixin. Per SIP-208 discussion and the sc-103157 thread, a global flag risks confusing partial states.
  • No cascade in V1 — each entity is soft-deleted independently; cascade is out of scope and tracked separately.
  • Permission model mirrors delete exactly — endpoint-level can_write, resource-level raise_for_ownership. No new permission plumbing.
  • datetime.now() (naive) for deleted_at to match AuditMixinNullable.changed_on precedent (PR #33693 reverted UTC on those columns; if/when audit columns move to UTC-aware, soft-delete should follow).
  • Compatible with future SQLAlchemy-Continuum adoption for full entity versioning (confirmed compatible with SQLAlchemy 1.4.54).
  • Per-class bypass scoping — not a global "skip soft-delete for this request" boolean. SIP-208's roadmap covers more entities (Database, Annotation, SavedQuery, Report, Alert, Tag); a global boolean's leak surface would grow with every new soft-deletable entity. Per-class scoping caps the leak at exactly what the caller asked for.

BEFORE/AFTER SCREENSHOTS OR ANIMATED GIF

N/A — backend-only infrastructure with no runtime behaviour change.

TESTING INSTRUCTIONS

pytest tests/unit_tests/models/test_soft_delete_mixin.py \
       tests/unit_tests/daos/test_base_dao_soft_delete.py \
       tests/unit_tests/commands/test_base_restore_command.py -v

Expected: 26 passed.

The tests use synthetic in-memory models (_SoftDeletable, _SoftDeletableTwo, _SoftDeletableParent, _SoftDeletableChild) that inherit SoftDeleteMixin directly, exercising the listener, mixin methods, BaseDAO routing, BaseRestoreCommand.validate, the transactional wrapper, and the context manager — all without any real Superset entity. Real entities acquire the mixin in the entity-rollout PRs above.

Key tests worth pointing at:

  • test_session_bypass_survives_query_reconstruction reproduces the exact FAB outer/inner shape (inner_query.with_entities(pk).subquery() joined to a fresh session.query(Model)) and proves the session-level bypass survives that reconstruction. This is the test that would have caught the original bug.
  • test_per_query_bypass_for_one_class_does_not_unhide_other and test_session_bypass_does_not_leak_across_classes pin per-class scoping using a second synthetic soft-deletable model.
  • test_relationship_load_filters_child_independently_of_parent_bypass verifies that with_loader_criteria(..., propagate_to_loaders=True) carries the per-class criteria through to lazy/selectin loads.
  • test_get_without_bypass_filters_out_soft_deleted_row and test_per_query_bypass_via_get_finds_soft_deleted_row pin Query.get() behaviour (using session.expunge_all() to force a SQL round trip through the listener, not just an identity-map hit) — the path raise_for_ownership depends on.
  • test_run_translates_sqlalchemy_errors_via_restore_failed_exc pins the base class's transactional wrapper: a SQLAlchemyError raised inside the unit of work is translated to the subclass's restore_failed_exc.
  • test_listener_does_not_affect_non_soft_deletable_queries pins that the per-class iteration doesn't break queries against classes that don't inherit SoftDeleteMixin.

Optional sanity check that nothing broke in master behaviour (since BaseDAO.delete is now routed):

pytest tests/unit_tests/daos/ tests/unit_tests/models/ -q

Expected: all green.

ADDITIONAL INFORMATION

  • Has associated issue: sc-106188, SIP-208 #39464
  • Required feature flags:
  • Changes UI
  • Includes DB Migration — (no, migrations land in the entity-rollout PRs)
  • Introduces new feature or API — (adds BaseDAO methods, BaseRestoreCommand, BaseDeletedStateFilter, SoftDeleteApiMixin, find_existing_for_import, clear_soft_deleted_for_import, skip_visibility_filter context manager, and restore → "write" mapping; no user-facing API surface yet)
  • Removes existing feature or API

Reviewer notes

A reviewer may reasonably ask: "why ship abstractions with no callers in master?":

  1. The callers exist — the three entity-rollout PRs (sc-106189 / sc-106190 / sc-106191) are queued behind this one and each consumes the abstractions. The original mono-PR (feat(soft-delete): add soft delete for charts, dashboards, and datasets #39286) demonstrated all three callers in a single diff; the decomposition just spreads them across reviewable PRs while keeping the abstraction as a single, focused unit.
  2. The unit tests prove the contract. Synthetic _SoftDeletable models exercise the mixin, listener, BaseDAO routing, and BaseRestoreCommand. The tests would fail if the abstractions were broken.
  3. The branch has been reviewed extensively/sqlalchemy-review (cache-stability + hot-path allocation tidyings), /clean-code-review (Extract Method on the listener gate, filter opt-in, and pre_get_list policy/mechanism split), /tidy-first-review, /ultrareview (clean), plus multiple iterations of richardfogaca review (find/clear split, base_filter honored on restore, transaction-wrapper moved into base class, FAB outer/inner bug surfaced and fixed via session-scoped bypass).

@netlify

netlify Bot commented May 8, 2026

Copy link
Copy Markdown

Deploy Preview for superset-docs-preview ready!

Name Link
🔨 Latest commit 08a3cb6
🔍 Latest deploy log https://app.netlify.com/projects/superset-docs-preview/deploys/6a148ada0dc1e700089c92d9
😎 Deploy Preview https://deploy-preview-39977--superset-docs-preview.netlify.app
📱 Preview on mobile
Toggle QR Code...

QR Code

Use your smartphone camera to open QR code link.
🤖 Make changes Run an agent on this branch

To edit notification comments on pull requests, go to your Netlify project configuration.

@codecov

codecov Bot commented May 8, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 44.10256% with 109 lines in your changes missing coverage. Please review.
✅ Project coverage is 64.05%. Comparing base (e68251f) to head (125f202).

Files with missing lines Patch % Lines
superset/views/filters.py 39.39% 40 Missing ⚠️
superset/commands/restore.py 0.00% 32 Missing ⚠️
superset/models/helpers.py 63.15% 20 Missing and 1 partial ⚠️
superset/daos/base.py 50.00% 6 Missing and 4 partials ⚠️
superset/commands/importers/v1/utils.py 50.00% 3 Missing ⚠️
superset/daos/database.py 33.33% 1 Missing and 1 partial ⚠️
superset/initialization/__init__.py 85.71% 0 Missing and 1 partial ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##           master   #39977      +/-   ##
==========================================
- Coverage   64.07%   64.05%   -0.03%     
==========================================
  Files        2592     2593       +1     
  Lines      139548   139733     +185     
  Branches    32421    32442      +21     
==========================================
+ Hits        89422    89499      +77     
- Misses      48589    48690     +101     
- Partials     1537     1544       +7     
Flag Coverage Δ
hive 39.09% <38.46%> (-0.01%) ⬇️
mysql 58.47% <44.10%> (-0.05%) ⬇️
postgres 58.55% <44.10%> (-0.05%) ⬇️
presto 40.76% <38.46%> (-0.01%) ⬇️
python 60.09% <44.10%> (-0.06%) ⬇️
sqlite 58.19% <44.10%> (-0.05%) ⬇️
unit 100.00% <ø> (ø)

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

Comment thread superset/commands/importers/v1/utils.py Outdated
@bito-code-review

Copy link
Copy Markdown
Contributor

The explanation accurately describes the code in superset/commands/importers/v1/utils.py, where the per-query execution_options(**{SKIP_VISIBILITY_FILTER: True}) bypasses the visibility filter only for the UUID lookup in find_existing_for_import, allowing access to soft-deleted rows while keeping other entity queries filtered.

@mikebridge mikebridge marked this pull request as ready for review May 8, 2026 19:41
@dosubot dosubot Bot added the change:backend Requires changing the backend label May 8, 2026
@mikebridge

Copy link
Copy Markdown
Contributor Author

@mistercrunch @michael-s-molina @betodealmeida @eschutho @sadpandajoe @kgabryje: I lack the ability to add you as reviewers, so I'm tagging you instead.

@bito-code-review bito-code-review Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review Agent Run #096e88

Actionable Suggestions - 1
  • superset/models/helpers.py - 1
Additional Suggestions - 1
  • superset/models/helpers.py - 1
    • Missing Return Type Hint · Line 707-707
      The repository rule [7819] requires explicit return type hints for all functions and methods, including when they return nothing. The new _add_soft_delete_filter function lacks a return type hint, violating this policy.
      Code suggestion
       @@ -707,1 +707,1 @@
      -def _add_soft_delete_filter(execute_state):  # type: ignore
      +def _add_soft_delete_filter(execute_state) -> None:  # type: ignore
Filtered by Review Rules

Bito filtered these suggestions based on rules created automatically for your feedback. Manage rules.

  • superset/daos/database.py - 1
    • Missing skip_visibility_filter implementation · Line 72-72
Review Details
  • Files reviewed - 11 · Commit Range: d54e7cc..d54e7cc
    • superset/commands/importers/v1/utils.py
    • superset/commands/restore.py
    • superset/constants.py
    • superset/daos/base.py
    • superset/daos/database.py
    • superset/initialization/__init__.py
    • superset/models/helpers.py
    • superset/views/base_api.py
    • superset/views/filters.py
    • tests/unit_tests/daos/test_base_dao_soft_delete.py
    • tests/unit_tests/models/test_soft_delete_mixin.py
  • Files skipped - 0
  • Tools
    • Whispers (Secret Scanner) - ✔︎ Successful
    • Detect-secrets (Secret Scanner) - ✔︎ Successful
    • MyPy (Static Code Analysis) - ✔︎ Successful
    • Astral Ruff (Static Code Analysis) - ✔︎ Successful

Bito Usage Guide

Commands

Type the following command in the pull request comment and save the comment.

  • /review - Manually triggers a full AI review.

  • /pause - Pauses automatic reviews on this pull request.

  • /resume - Resumes automatic reviews.

  • /resolve - Marks all Bito-posted review comments as resolved.

  • /abort - Cancels all in-progress reviews.

Refer to the documentation for additional commands.

Configuration

This repository uses Superset You can customize the agent settings here or contact your Bito workspace admin at evan@preset.io.

Documentation & Help

AI Code Review powered by Bito Logo

Comment thread superset/models/helpers.py
Comment thread superset/commands/restore.py
Comment thread superset/models/helpers.py Outdated
@bito-code-review

bito-code-review Bot commented May 11, 2026

Copy link
Copy Markdown
Contributor

Code Review Agent Run #915a41

Actionable Suggestions - 0
Review Details
  • Files reviewed - 1 · Commit Range: d54e7cc..9c83215
    • superset/models/helpers.py
  • Files skipped - 0
  • Tools
    • Whispers (Secret Scanner) - ✔︎ Successful
    • Detect-secrets (Secret Scanner) - ✔︎ Successful
    • MyPy (Static Code Analysis) - ✔︎ Successful
    • Astral Ruff (Static Code Analysis) - ✔︎ Successful

Bito Usage Guide

Commands

Type the following command in the pull request comment and save the comment.

  • /review - Manually triggers a full AI review.

  • /pause - Pauses automatic reviews on this pull request.

  • /resume - Resumes automatic reviews.

  • /resolve - Marks all Bito-posted review comments as resolved.

  • /abort - Cancels all in-progress reviews.

Refer to the documentation for additional commands.

Configuration

This repository uses Superset You can customize the agent settings here or contact your Bito workspace admin at evan@preset.io.

Documentation & Help

AI Code Review powered by Bito Logo

@richardfogaca richardfogaca left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Posting on Richard's behalf — this is his PR reviewer agent. Forward any pushback to him and he'll loop me back in.

Left a few notes below — the main things worth checking before merge are around the visibility opt-out boundaries and the restore lookup path. All line numbers verified against HEAD 9c83215.

Functional — worth checking before merge

  • superset/views/filters.py:164

    BaseDeletedStateFilter opts out by setting g.skip_visibility_filter, and the global listener reads that flag for every ORM SELECT later in the same request. Once more than one entity adopts SoftDeleteMixin, a list request that asks for deleted dashboards/charts/datasets could also make incidental relationship loads, serializer lookups, or helper queries expose soft-deleted rows for other models in the rest of that request.

    WDYT — could this use the query-scoped execution option for the primary list query, and keep any response-annotation state separate from the visibility bypass?

  • superset/commands/restore.py:79

    BaseRestoreCommand.validate() correctly finds the soft-deleted row with skip_visibility_filter=True, but raise_for_ownership() re-loads the same model through self.session.query(resource.__class__).get(resource.id) without that opt-out. The global listener will hide the soft-deleted row there, so non-admin owners can be denied restore even when they own the row; admins bypass this path, so it may be easy to miss without owner-level restore coverage.

    WDYT — would it be worth making the ownership check soft-delete-aware for this command, or adding a restore-specific ownership helper/test so owners can restore their own deleted resources?

  • superset/daos/database.py:82

    DatabaseDAO.find_by_id() now accepts skip_visibility_filter, but this override builds the query without applying the execution option. Any restore/admin path that routes through the override will still be filtered even though it passed skip_visibility_filter=True.

    Small suggestion: could we mirror BaseDAO.find_by_id() here and apply query.execution_options(**{SKIP_VISIBILITY_FILTER: True}) when the flag is set?

Compatibility / follow-up

  • superset/daos/base.py:299

    Adding skip_visibility_filter before the existing id_column and query_options parameters changes the meaning of positional third/fourth arguments to a public DAO helper. I did not find an in-tree positional caller in the changed checkout, but Superset extensions and downstream code may still call this positionally.

    Totally optional if the team treats this helper as keyword-only in practice, but could we append the new flag after existing parameters or make it keyword-only to preserve the current positional ABI?

Praise

  • superset/models/helpers.py:739

    The core listener uses SQLAlchemy's do_orm_execute plus with_loader_criteria pattern and already has a narrow per-query escape hatch, which seems like the right primitive for restore/import/admin flows once the broader request-level bypass is tightened.

mikebridge pushed a commit to mikebridge/superset that referenced this pull request May 12, 2026
Two real bugs in the infrastructure surface, both flagged by
codeant-ai-for-open-source on PR apache#39977. Both are fixed in this commit
with documentation and a new unit test.

1. raise_for_ownership re-query was filtered by the listener

   BaseRestoreCommand.validate calls security_manager.raise_for_ownership,
   which re-queries the row internally to fetch its current owners. That
   re-query goes through the global do_orm_execute listener; for a
   soft-deleted row the listener filters it out, raise_for_ownership
   sees no row, falls back to an empty owners list, and raises
   MISSING_OWNERSHIP_ERROR for any non-admin user. The only callers who
   could restore were admins.

   Fix: set g.skip_visibility_filter = True for the scope of the security
   check, restoring the previous value afterward. This is the documented
   per-request opt-out path for cases like this where the bypass must
   propagate to a function we don't directly own.

2. Listener missed the documented loader-path guards

   SQLAlchemy's recommended soft-delete pattern at
   github.com/sqlalchemy/sqlalchemy/issues/7973 guards the criteria
   application with not execute_state.is_column_load and not
   execute_state.is_relationship_load. Without these, every lazy/eager
   relationship load re-applies the criteria, stacking redundant
   deleted_at IS NULL clauses on the loader's query. Adds both guards.

Tests

* New tests/unit_tests/commands/test_restore.py exercises the
  BaseRestoreCommand contract directly:
  - flag is set to True during the security check
  - flag is restored to its previous value afterward
  - flag is restored even when the security check raises
  The previous entity-level tests mocked raise_for_ownership directly
  and never exercised the real re-query path, so they masked the bug.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
mikebridge pushed a commit to mikebridge/superset that referenced this pull request May 12, 2026
Three changes addressing items flagged in
apache#39977 (review)

1. DatabaseDAO.find_by_id ignored the new flag

   The override accepted skip_visibility_filter for signature
   compatibility but never applied it to the query. Any caller of the
   override that passed skip_visibility_filter=True still got filtered
   results. Now applies execution_options when the flag is True,
   matching BaseDAO.find_by_id.

2. skip_visibility_filter moved to keyword-only

   The original signature inserted skip_visibility_filter at position 3
   in BaseDAO.find_by_id / find_by_ids / find_by_id_or_uuid /
   _find_by_column, shifting id_column and query_options. Downstream
   positional callers (extensions, library consumers) would silently
   bind the new parameter to a different intent. Moved to the end and
   made keyword-only across all four methods plus the DatabaseDAO
   override; existing in-tree callers all use keyword form already.

3. BaseDeletedStateFilter scope tightened

   Previously set g.skip_visibility_filter = True (broad, per-request),
   which is fine in isolation but would leak soft-deleted rows of
   unrelated entities once multiple models adopt SoftDeleteMixin: a
   list request for soft-deleted dashboards would also bypass the
   filter for incidental Slice / SqlaTable relationship loads.

   Two changes decouple the concerns:
   - The visibility-filter bypass is now applied per-query on the
     primary list query via .execution_options(skip_visibility_filter=
     True). Affects only the list query for this entity.
   - Response augmentation (adding deleted_at to result rows) is
     signalled via a separate request-scoped flag,
     g._augment_response_with_deleted_at. Distinct from the bypass.

   pre_get_list now checks the augmentation flag instead of the
   bypass flag.

Listener docstring updated to clarify the narrower per-request use
case: the broad flag is now reserved for cases where a function we
don't directly control performs an internal re-query that must see
the soft-deleted row (e.g., security_manager.raise_for_ownership).
The previously-claimed use case (list-endpoint rison filters) has
moved to the per-query option.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@mikebridge

Copy link
Copy Markdown
Contributor Author

@richardfogaca thanks for the careful review — all four addressed in c499e318fe (or in earlier 44e1d1710b for one of them). Per-item:

views/filters.py:164 — broad bypass scope — agreed, fixed. BaseDeletedStateFilter now applies the bypass per-query via .execution_options(skip_visibility_filter=True) on the primary list query only, and signals the response-augmentation step via a separate request-scoped flag (g._augment_response_with_deleted_at). The two concerns are decoupled. Soft-deleted rows of unrelated entities no longer leak through incidental relationship loads / serializer queries.

commands/restore.py:79 — ownership re-query filtered out — already fixed in 44e1d1710b (independently flagged by codeant-ai-for-open-source on Sunday). BaseRestoreCommand.validate() now sets g.skip_visibility_filter = True for the scope of raise_for_ownership only, restoring the previous value in a finally block. New unit tests in tests/unit_tests/commands/test_restore.py cover the contract (flag set during check, restored after, restored even on the exception path).

daos/database.py:82 — flag accepted but ignored — agreed, fixed. The override now applies execution_options(skip_visibility_filter=True) to its query when the flag is passed, matching BaseDAO.find_by_id.

daos/base.py:299 — positional ABI — agreed, fixed. skip_visibility_filter moved to the end and made keyword-only across find_by_id, find_by_ids, find_by_id_or_uuid, _find_by_column, and the DatabaseDAO.find_by_id override. Existing in-tree callers all already use keyword form, so no churn there.

Listener docstring updated to reflect the narrower documented use case for the per-request flag: it's now reserved for cases like wrapping raise_for_ownership where we don't directly control the internal queries. The list-endpoint use case has moved to the per-query option.

The "Praise" item also stays accurate — the do_orm_execute + with_loader_criteria pattern is unchanged, just with the documented loader-path guards added (also in 44e1d1710b, per the matching codeant comment).

@mikebridge

mikebridge commented May 12, 2026

Copy link
Copy Markdown
Contributor Author

After some discussion with AI about the maintainability of the official side-effecting pattern, I'd like to create a follow-up PR after this one to wrap the per-request bypass in a context manager.

The issue here is that it's easy for a dev to forget to reset the flag in try/catch:

  • Forgetting the try/finally and leaving the flag set for the rest of the request
  • Cleaning up with setattr(g, SKIP_VISIBILITY_FILTER, False) instead of restoring the captured previous value, which breaks composition when a caller is already inside a bypass

Both cases can be eliminated by a small context manager next to the SKIP_VISIBILITY_FILTER constant (via claude):

@contextmanager                                   
def skip_visibility_filter() -> Iterator[None]:
      """Opt the current request out of the soft-delete listener for the
      duration of the block. Restores the previous value on exit,                                                                                                                                     
      including the exception path. Use only when the bypass must                                                                                                                                     
      propagate through a function whose internal queries are not under                                                                                                                               
      your control; for queries you build directly, prefer the per-query                                                                                                                              
      ``execution_options(skip_visibility_filter=True)``.
      """                                                                                                                                                                                             
      previous = getattr(g, SKIP_VISIBILITY_FILTER, False)
      setattr(g, SKIP_VISIBILITY_FILTER, True)                                                                                                                                                        
      try:                                                                                                                                                                                            
          yield                                
      finally:                                                                                                                                                                                        
          setattr(g, SKIP_VISIBILITY_FILTER, previous)

Call site in BaseRestoreCommand.validate becomes:

with skip_visibility_filter():                    
      try:                                     
          security_manager.raise_for_ownership(model)
      except SupersetSecurityException as ex:                                                                                                                                                         
          raise self.forbidden_exc() from ex

I also think there should be a primer document somewhere on how the two bypasses work and why they are there, because it is not obvious.

@richardfogaca richardfogaca left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Posting on Richard's behalf — this is his PR reviewer agent. Forward any pushback to him and he'll loop me back in.

Left a few notes below — the import hard-delete cleanup path and the deleted-state visibility contract look like the main things to tighten before the entity rollout PRs depend on this infrastructure. All line numbers verified against HEAD c499e318.

Functional — worth addressing before entity rollout

  • superset/commands/importers/v1/utils.py:430

    The replacement path for a soft-deleted import match uses a Core table DELETE, which bypasses the ORM after_delete listeners that currently own cleanup for the rollout entities. That matters because tags are cleaned up by ObjectUpdater.after_delete (superset/tags/core.py:38, :42, :46; superset/tags/models.py:278-289), and the physical tagged_object.object_id migration is just an integer (superset/migrations/versions/2018-07-26_11-10_c82ee8a39623_add_implicit_tags.py:84-89), so DB cascades will not remove those rows. Datasets also route permission cleanup through SqlaTable.after_delete (superset/connectors/sqla/models.py:2115-2123).

    WDYT — could this use an existing hard-delete path that still fires the relevant cleanup, or explicitly run the tag/permission cleanup before the direct delete?

  • superset/views/filters.py:162

    BaseDeletedStateFilter opts the whole ORM statement out of the soft-delete listener with a boolean skip_visibility_filter; the only branch does the same at superset/views/filters.py:165. The listener then skips criteria for every SoftDeleteMixin subclass in that statement (superset/models/helpers.py:733-759), not just self.model, so a future list query that joins multiple soft-deletable entities could surface unrelated deleted rows while only asking for deleted state on one entity.

    Would it be worth making the bypass model-scoped, or adding an explicit non-deleted predicate back for other soft-deletable models participating in the query?

  • superset/views/filters.py:160

    The deleted-state filter turns on include / only visibility without an authorization hook. Once a concrete list endpoint wires this in, a caller with normal list/read access can discover soft-deleted rows, while the future restore API is mapped to write permission.

    WDYT — should the base filter expose a hook/contract so concrete subclasses can require restore/write/admin capability before showing deleted rows?

Other suggestion

  • superset/views/base_api.py:380

    _get_deleted_at_map lives on the generic base API but assumes every future soft-deletable API uses id as its primary key. That is true for the planned chart/dashboard/dataset rollout, but it makes the shared helper easier to misuse later.

    Small suggestion: could this use self.datamodel.get_pk() / get_pk_name() and preserve the existing key type instead?

Praise

  • superset/commands/restore.py:87

    The restore command scopes the temporary request-level visibility bypass with try/finally and restores the previous flag value. That is a good guardrail for a subtle global-listener interaction.

mikebridge pushed a commit to mikebridge/superset that referenced this pull request May 12, 2026
Two fixes from
apache#39977 (review)

1. find_existing_for_import bypassed ORM cleanup listeners

   The helper used a raw Core ``Model.__table__.delete().where(...)`` to
   hard-delete the soft-deleted row before re-import. The original
   rationale was perf (avoid ORM cascade-load), but the raw delete
   skips ORM ``after_delete`` event listeners — which is where two
   important cleanup paths live:

   * ObjectUpdater.after_delete (superset/tags/core.py) cleans up
     tagged_object rows. tagged_object.object_id is a plain integer,
     not a foreign key, so the database cannot cascade them. Bypassing
     the listener leaves orphaned tag rows pointing at deleted entities.
   * SqlaTable.after_delete (superset/connectors/sqla/models.py) cleans
     up dataset permission-view rows. Bypassing the listener leaves
     orphaned permission entries.

   Switched to db.session.delete(existing) so both listeners fire. The
   perf concern (cascade-loading large relationship graphs) is much
   less acute for an import operation than for a steady-state user-
   triggered delete, and correctness wins.

   Updated the function docstring to reflect the change and explain why
   the listener-firing path is required. Updated the bypass primer
   ((superset-spec)/sc-103157-soft-deletes/bypass-primer.md) so the
   "common mistakes" section no longer points at the raw-DELETE pattern
   as a recommended escape hatch.

2. _get_deleted_at_map hardcoded the id PK column

   The helper queried ``self.datamodel.obj.id`` directly, which works
   for chart/dashboard/dataset (all int PKs) but would silently break
   for a future soft-deletable entity with a different PK. Now resolves
   the column via ``self.datamodel.get_pk_name()``.

   Signature relaxed from ``list[int]`` to ``list[Any]`` to match the
   broader PK contract.

Items 2 (per-query bypass scope) and 3 (auth on visibility) from the
review are deferred per the review-thread replies — item 2 is the YAGNI
tradeoff with an explicit code comment, item 3 belongs in SIP-208
discussion rather than this PR.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@mikebridge

Copy link
Copy Markdown
Contributor Author

@richardfogaca thanks for the second pass — addressed in 543dff20c9 for items 1 and 4; replying inline for items 2 and 3.

Item 1 (importers cleanup) — fixed. You're right that the raw Core DELETE was trading correctness for perf. find_existing_for_import now uses db.session.delete() so ObjectUpdater.after_delete and SqlaTable.after_delete fire and clean up tagged_object rows + dataset permission rows. The original rationale was avoiding cascade-load on large relationship graphs, but an import operation isn't a steady-state hot path; the trade tilts toward correctness. Updated the function docstring and the bypass-primer doc to reflect the change.

Item 4 (hardcoded id PK) — fixed. _get_deleted_at_map now resolves the PK via self.datamodel.get_pk_name() and the signature is list[Any] to match the broader PK contract. Same behaviour for the chart/dashboard/dataset rollout (all int PKs); future soft-deletable entities with a different PK will work without changes here.

Item 2 (per-query bypass scope) — accepted YAGNI, comment added in follow-up. You're the third reviewer to raise this from a slightly different angle (codeant flagged the listener guards, I raised it during initial design, you raised it for the request-scoped flag, and now for the per-query option). The mechanism in all cases is the same: with_loader_criteria(SoftDeleteMixin, ...) applies polymorphically to every subclass, so any bypass affects all subclasses in the same query.

In the current code path, the bypass is set by BaseDeletedStateFilter on the primary list query, which targets one concrete entity. The list queries for chart/dashboard/dataset don't currently join across other soft-deletable entities in the same statement, so the over-broad bypass is theoretical rather than actual.

Per-entity scoping is a real future-proofing option (e.g., execution_options(skip_visibility_filter_for=[Slice])) but it materially expands the listener and the bypass API surface. I'd prefer to defer it until a use case actually surfaces. Happy to add a code comment in BaseDeletedStateFilter.apply flagging the contract:

# The execution-option bypass is per-statement but not per-class — it
# skips the soft-delete listener for every SoftDeleteMixin subclass in
# the statement, not just self.model. Today the list endpoints don't
# join across soft-deletable entities, so this is moot in practice.
# If a future list query starts joining (e.g., a dashboard list that
# eagerly loads charts in the same statement), revisit per-entity
# scoping rather than continuing to use the broad bypass here.

WDYT — comment in code as a forward-looking warning, or do you think the per-entity scoping is worth doing now?

Item 3 (visibility auth gap) — SIP-208 question rather than code review. Legitimate concern. The proposal in SIP-208 maps restore to write but leaves visibility on the existing list-read permission. Whether seeing soft-deleted rows should require something stronger is a policy choice the SIP didn't address — I think it belongs back on the SIP-208 issue thread or dev@ rather than getting decided in this PR. If you'd like to take the discussion there, happy to be the lightning rod for the dev@ post.

Praise on the try/finally — noted, thank you. That ordering took a couple of iterations to land on.

@richardfogaca

Copy link
Copy Markdown
Contributor

Posting on Richard's behalf — this is his PR reviewer agent. Forward any pushback to him and he'll loop me back in.

One follow-up from the second pass. Most of the earlier review notes look addressed, but I think there is still one functional gap around relationship loads. Line numbers verified against HEAD 543dff20c9.

  • superset/models/helpers.py:751

    Would it be worth re-checking the is_relationship_load guard with the mixin-based with_loader_criteria target? I tested a small SQLAlchemy 1.4.54 repro with Parent -> Child where both classes inherit the soft-delete mixin: direct Child queries were filtered, but parent.children lazy loads returned soft-deleted children when this guard skipped relationship loads. Removing the relationship-load guard made the lazy load filter correctly.

    This seems to weaken the main invariant that soft-deleted rows are hidden from relationship/serializer queries by default, and it also means the deleted-state list filter can still expose unrelated soft-deleted related rows once multiple rollout entities use the mixin.

    WDYT about either applying the criteria on relationship loads too, or adding a regression test that proves lazy/eager relationship loads stay filtered?

@bito-code-review

bito-code-review Bot commented May 13, 2026

Copy link
Copy Markdown
Contributor

Code Review Agent Run #fd97cd

Actionable Suggestions - 0
Review Details
  • Files reviewed - 8 · Commit Range: 9c83215..543dff2
    • superset/commands/restore.py
    • superset/models/helpers.py
    • tests/unit_tests/commands/test_restore.py
    • superset/daos/base.py
    • superset/daos/database.py
    • superset/views/base_api.py
    • superset/views/filters.py
    • superset/commands/importers/v1/utils.py
  • Files skipped - 0
  • Tools
    • Whispers (Secret Scanner) - ✔︎ Successful
    • Detect-secrets (Secret Scanner) - ✔︎ Successful
    • MyPy (Static Code Analysis) - ✔︎ Successful
    • Astral Ruff (Static Code Analysis) - ✔︎ Successful

Bito Usage Guide

Commands

Type the following command in the pull request comment and save the comment.

  • /review - Manually triggers a full AI review.

  • /pause - Pauses automatic reviews on this pull request.

  • /resume - Resumes automatic reviews.

  • /resolve - Marks all Bito-posted review comments as resolved.

  • /abort - Cancels all in-progress reviews.

Refer to the documentation for additional commands.

Configuration

This repository uses Superset You can customize the agent settings here or contact your Bito workspace admin at evan@preset.io.

Documentation & Help

AI Code Review powered by Bito Logo

@mikebridge

mikebridge commented May 13, 2026

Copy link
Copy Markdown
Contributor Author

@richardfogaca I think there's a better solution to the raise_for_ownership issue, which is to always supply the "skip" filter there---there's no logical reason to be omitting soft-deleted items in "check owners". That eliminates a lot of the complexity of this PR

mikebridge pushed a commit to mikebridge/superset that referenced this pull request May 13, 2026
Two real bugs in the infrastructure surface, both flagged by
codeant-ai-for-open-source on PR apache#39977. Both are fixed in this commit
with documentation and a new unit test.

1. raise_for_ownership re-query was filtered by the listener

   BaseRestoreCommand.validate calls security_manager.raise_for_ownership,
   which re-queries the row internally to fetch its current owners. That
   re-query goes through the global do_orm_execute listener; for a
   soft-deleted row the listener filters it out, raise_for_ownership
   sees no row, falls back to an empty owners list, and raises
   MISSING_OWNERSHIP_ERROR for any non-admin user. The only callers who
   could restore were admins.

   Fix: set g.skip_visibility_filter = True for the scope of the security
   check, restoring the previous value afterward. This is the documented
   per-request opt-out path for cases like this where the bypass must
   propagate to a function we don't directly own.

2. Listener missed the documented loader-path guards

   SQLAlchemy's recommended soft-delete pattern at
   github.com/sqlalchemy/sqlalchemy/issues/7973 guards the criteria
   application with not execute_state.is_column_load and not
   execute_state.is_relationship_load. Without these, every lazy/eager
   relationship load re-applies the criteria, stacking redundant
   deleted_at IS NULL clauses on the loader's query. Adds both guards.

Tests

* New tests/unit_tests/commands/test_restore.py exercises the
  BaseRestoreCommand contract directly:
  - flag is set to True during the security check
  - flag is restored to its previous value afterward
  - flag is restored even when the security check raises
  The previous entity-level tests mocked raise_for_ownership directly
  and never exercised the real re-query path, so they masked the bug.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
mikebridge pushed a commit to mikebridge/superset that referenced this pull request May 13, 2026
Three changes addressing items flagged in
apache#39977 (review)

1. DatabaseDAO.find_by_id ignored the new flag

   The override accepted skip_visibility_filter for signature
   compatibility but never applied it to the query. Any caller of the
   override that passed skip_visibility_filter=True still got filtered
   results. Now applies execution_options when the flag is True,
   matching BaseDAO.find_by_id.

2. skip_visibility_filter moved to keyword-only

   The original signature inserted skip_visibility_filter at position 3
   in BaseDAO.find_by_id / find_by_ids / find_by_id_or_uuid /
   _find_by_column, shifting id_column and query_options. Downstream
   positional callers (extensions, library consumers) would silently
   bind the new parameter to a different intent. Moved to the end and
   made keyword-only across all four methods plus the DatabaseDAO
   override; existing in-tree callers all use keyword form already.

3. BaseDeletedStateFilter scope tightened

   Previously set g.skip_visibility_filter = True (broad, per-request),
   which is fine in isolation but would leak soft-deleted rows of
   unrelated entities once multiple models adopt SoftDeleteMixin: a
   list request for soft-deleted dashboards would also bypass the
   filter for incidental Slice / SqlaTable relationship loads.

   Two changes decouple the concerns:
   - The visibility-filter bypass is now applied per-query on the
     primary list query via .execution_options(skip_visibility_filter=
     True). Affects only the list query for this entity.
   - Response augmentation (adding deleted_at to result rows) is
     signalled via a separate request-scoped flag,
     g._augment_response_with_deleted_at. Distinct from the bypass.

   pre_get_list now checks the augmentation flag instead of the
   bypass flag.

Listener docstring updated to clarify the narrower per-request use
case: the broad flag is now reserved for cases where a function we
don't directly control performs an internal re-query that must see
the soft-deleted row (e.g., security_manager.raise_for_ownership).
The previously-claimed use case (list-endpoint rison filters) has
moved to the per-query option.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
mikebridge pushed a commit to mikebridge/superset that referenced this pull request May 13, 2026
Two fixes from
apache#39977 (review)

1. find_existing_for_import bypassed ORM cleanup listeners

   The helper used a raw Core ``Model.__table__.delete().where(...)`` to
   hard-delete the soft-deleted row before re-import. The original
   rationale was perf (avoid ORM cascade-load), but the raw delete
   skips ORM ``after_delete`` event listeners — which is where two
   important cleanup paths live:

   * ObjectUpdater.after_delete (superset/tags/core.py) cleans up
     tagged_object rows. tagged_object.object_id is a plain integer,
     not a foreign key, so the database cannot cascade them. Bypassing
     the listener leaves orphaned tag rows pointing at deleted entities.
   * SqlaTable.after_delete (superset/connectors/sqla/models.py) cleans
     up dataset permission-view rows. Bypassing the listener leaves
     orphaned permission entries.

   Switched to db.session.delete(existing) so both listeners fire. The
   perf concern (cascade-loading large relationship graphs) is much
   less acute for an import operation than for a steady-state user-
   triggered delete, and correctness wins.

   Updated the function docstring to reflect the change and explain why
   the listener-firing path is required. Updated the bypass primer
   ((superset-spec)/sc-103157-soft-deletes/bypass-primer.md) so the
   "common mistakes" section no longer points at the raw-DELETE pattern
   as a recommended escape hatch.

2. _get_deleted_at_map hardcoded the id PK column

   The helper queried ``self.datamodel.obj.id`` directly, which works
   for chart/dashboard/dataset (all int PKs) but would silently break
   for a future soft-deletable entity with a different PK. Now resolves
   the column via ``self.datamodel.get_pk_name()``.

   Signature relaxed from ``list[int]`` to ``list[Any]`` to match the
   broader PK contract.

Items 2 (per-query bypass scope) and 3 (auth on visibility) from the
review are deferred per the review-thread replies — item 2 is the YAGNI
tradeoff with an explicit code comment, item 3 belongs in SIP-208
discussion rather than this PR.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@mikebridge mikebridge force-pushed the sc-106188-soft-delete-infrastructure branch from 1fb3aa4 to c48e23b Compare May 13, 2026 16:14
Comment thread superset/daos/base.py

@richardfogaca richardfogaca left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Posting on Richard's behalf - this is his PR reviewer agent. Forward any pushback to him and he'll loop me back in.

Left a few notes below. The main things I would look at before the entity rollout PRs are the import helper's hard-delete timing and whether restore should bypass DAO base filters; the rest is smaller contract/docs cleanup. All line numbers verified against HEAD c6d741a.

Functional - worth tightening before merge

  • superset/commands/importers/v1/utils.py:431-433

    find_existing_for_import() hard-deletes and flushes the soft-deleted match during what otherwise reads like a lookup helper. Once the entity import paths start using this, a caller that invokes the helper before completing overwrite / validation decisions would have already permanently removed the old row, including after_delete cleanup side effects.

    WDYT - would it be worth splitting this into a side-effect-free lookup plus an explicit hard_delete_existing_for_import() call that the entity import command can place after its overwrite/permission checks pass?

  • superset/commands/restore.py:65-69

    Restore lookup bypasses the DAO base_filter as well as the new soft-delete visibility filter. Existing delete paths load through find_by_ids() without skip_base_filter=True, then run ownership checks, so restore would have a broader initial lookup surface than delete/update even though it only needs to see soft-deleted rows.

    Could we keep skip_visibility_filter=True but leave the normal base filter in place, unless a concrete rollout PR has an entity-specific reason to bypass it?

Other suggestions

  • superset/commands/restore.py:60-62

    The base run() mutates the model without a transaction and relies on every concrete restore command remembering to override run() only to add the transaction decorator. That contract is documented, but it is easy for a rollout PR to miss and would silently skip the standard commit/error-wrapping behavior.

    Small suggestion: could the reusable base flow own the transactional wrapper somehow, or could the subclass requirement be made harder to accidentally skip? Happy to keep as-is if the concrete rollout PRs already pin this pattern with tests.

  • superset/models/helpers.py:672-674

    Tiny doc mismatch: this now says BaseDAO.delete() is the permanent hard-delete path, but this PR changes BaseDAO.delete() to soft-delete models that inherit SoftDeleteMixin.

    Nit: could this point readers at BaseDAO.hard_delete() for permanent deletion instead?

Praise

  • The Query.get() tests using expunge_all() are a nice guard against an identity-map false positive. That makes the listener/bypass behavior much more convincing, especially for the ownership re-query path that restore depends on.

mikebridge pushed a commit to mikebridge/superset that referenced this pull request May 20, 2026
Wire datasets to the soft-delete infrastructure (sc-106188). Adds
the SoftDeleteMixin to SqlaTable, ships a new RestoreDatasetCommand
and POST /api/v1/dataset/<uuid>/restore endpoint, adds the
dataset_deleted_state rison filter, updates the v1 importer to bypass
the visibility filter on UUID lookup, and ships a one-table migration
adding deleted_at to tables.

DeleteDatasetCommand needs no source change in this PR - the
BaseDAO.delete() routing introduced in sc-106188 detects the
SoftDeleteMixin on SqlaTable automatically and routes to soft_delete.

Migration

* New migration 3a8e6f2c1b95 (down_revision 33d7e0e21daa) adding
  nullable deleted_at column + ix_tables_deleted_at index. Reversible.

Model + DAO

* SqlaTable inherits SoftDeleteMixin -> deleted_at column on the ORM,
  the global do_orm_execute listener begins filtering SqlaTable
  queries (and SqlaTable relationships).
* DeleteDatasetCommand routes through BaseDAO.delete() unchanged.

API

* RestoreDatasetCommand subclasses BaseRestoreCommand[SqlaTable]
  (4 lines).
* POST /api/v1/dataset/<uuid>/restore endpoint with the standard
  decorator stack. Permissions mirror delete exactly.
* DatasetDeletedStateFilter subclasses BaseDeletedStateFilter
  (2 lines), wired into search filters.
* DatasetRestoreFailedError exception type.
* Import pipeline uses find_existing_for_import.

Cascade behaviour (V1)

Per SIP-208, soft-delete does not cascade in V1. Charts that
reference a soft-deleted dataset render an error at chart-load time.
That's the documented behaviour - a future ticket may add a
"degraded chart" state. The integration tests cover the
chart-on-soft-deleted-dataset case.

Tests

* tests/unit_tests/commands/dataset/restore_test.py - 4 unit tests.
* tests/integration_tests/datasets/soft_delete_tests.py - integration
  coverage for delete -> soft, restore -> active, list-with-filter,
  importer-handles-soft-deleted, chart-on-soft-deleted-dataset.

Depends on sc-106188 (apache#39977). Do NOT merge until sc-106188 is in
master. Migration shares down_revision (33d7e0e21daa) with sc-106189
and sc-106190; whichever lands second/third needs a rebase or merge
migration.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
mikebridge pushed a commit to mikebridge/superset that referenced this pull request May 21, 2026
Wire dashboards to the soft-delete infrastructure (sc-106188). Adds
the SoftDeleteMixin to Dashboard, ships a new RestoreDashboardCommand
and POST /api/v1/dashboard/<uuid>/restore endpoint, adds the
dashboard_deleted_state rison filter, updates the v1 importer to
bypass the visibility filter on UUID lookup, and ships a one-table
migration adding deleted_at to dashboards.

Note that DeleteDashboardCommand needs no source change in this PR -
the BaseDAO.delete() routing introduced in sc-106188 detects the
SoftDeleteMixin on Dashboard automatically and routes to soft_delete.

Migration

* New migration 9e1f3b8c4d2a (down_revision 33d7e0e21daa) adding
  nullable deleted_at column + ix_dashboards_deleted_at index.
  Reversible.

Model + DAO

* Dashboard inherits SoftDeleteMixin -> deleted_at column on the ORM,
  the global do_orm_execute listener begins filtering Dashboard
  queries (and Dashboard relationships - lazy-loaded charts on a
  soft-deleted dashboard are also hidden).
* DeleteDashboardCommand routes through BaseDAO.delete() unchanged.

API

* RestoreDashboardCommand subclasses BaseRestoreCommand[Dashboard]
  (4 lines).
* POST /api/v1/dashboard/<uuid>/restore endpoint with the standard
  decorator stack. Permissions mirror delete exactly: endpoint-level
  can_write, resource-level raise_for_ownership.
* DashboardDeletedStateFilter subclasses BaseDeletedStateFilter
  (2 lines), wired into search filters.
* DashboardRestoreFailedError exception type.
* Import pipeline uses find_existing_for_import.

Embedded-dashboard regression

* tests/integration_tests/dashboards/soft_delete_tests.py covers the
  case where a parent dashboard is soft-deleted: the embedded iframe
  URL still loads 200 (it doesn't dereference the parent dashboard
  during render), and the dashboard API returns 404 cleanly. The
  assertions are ordered so the API check runs before the embedded
  GET (the embedded handler clears the test-client session in CI;
  reordering keeps both assertions in scope).

Tests

* tests/unit_tests/commands/dashboard/restore_test.py - 4 unit tests.
* tests/integration_tests/dashboards/soft_delete_tests.py - integration
  coverage for delete -> soft, restore -> active + reattach to charts,
  list-with-filter, importer-handles-soft-deleted, embedded-with-soft-
  deleted-parent.

Depends on sc-106188 (apache#39977). Do NOT merge until sc-106188 is in
master. Migration shares down_revision (33d7e0e21daa) with sc-106189
and sc-106191; whichever lands second/third needs a rebase or merge
migration.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
mikebridge pushed a commit to mikebridge/superset that referenced this pull request May 21, 2026
Wire datasets to the soft-delete infrastructure (sc-106188). Adds
the SoftDeleteMixin to SqlaTable, ships a new RestoreDatasetCommand
and POST /api/v1/dataset/<uuid>/restore endpoint, adds the
dataset_deleted_state rison filter, updates the v1 importer to bypass
the visibility filter on UUID lookup, and ships a one-table migration
adding deleted_at to tables.

DeleteDatasetCommand needs no source change in this PR - the
BaseDAO.delete() routing introduced in sc-106188 detects the
SoftDeleteMixin on SqlaTable automatically and routes to soft_delete.

Migration

* New migration 3a8e6f2c1b95 (down_revision 33d7e0e21daa) adding
  nullable deleted_at column + ix_tables_deleted_at index. Reversible.

Model + DAO

* SqlaTable inherits SoftDeleteMixin -> deleted_at column on the ORM,
  the global do_orm_execute listener begins filtering SqlaTable
  queries (and SqlaTable relationships).
* DeleteDatasetCommand routes through BaseDAO.delete() unchanged.

API

* RestoreDatasetCommand subclasses BaseRestoreCommand[SqlaTable]
  (4 lines).
* POST /api/v1/dataset/<uuid>/restore endpoint with the standard
  decorator stack. Permissions mirror delete exactly.
* DatasetDeletedStateFilter subclasses BaseDeletedStateFilter
  (2 lines), wired into search filters.
* DatasetRestoreFailedError exception type.
* Import pipeline uses find_existing_for_import.

Cascade behaviour (V1)

Per SIP-208, soft-delete does not cascade in V1. Charts that
reference a soft-deleted dataset render an error at chart-load time.
That's the documented behaviour - a future ticket may add a
"degraded chart" state. The integration tests cover the
chart-on-soft-deleted-dataset case.

Tests

* tests/unit_tests/commands/dataset/restore_test.py - 4 unit tests.
* tests/integration_tests/datasets/soft_delete_tests.py - integration
  coverage for delete -> soft, restore -> active, list-with-filter,
  importer-handles-soft-deleted, chart-on-soft-deleted-dataset.

Depends on sc-106188 (apache#39977). Do NOT merge until sc-106188 is in
master. Migration shares down_revision (33d7e0e21daa) with sc-106189
and sc-106190; whichever lands second/third needs a rebase or merge
migration.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
mikebridge pushed a commit to mikebridge/superset that referenced this pull request May 21, 2026
Wire charts to the soft-delete infrastructure (sc-106188). Adds the
SoftDeleteMixin to Slice, routes DeleteChartCommand through BaseDAO
to soft-delete, ships a new RestoreChartCommand and POST
/api/v1/chart/<uuid>/restore endpoint, adds the chart_deleted_state
rison filter, updates the v1 importer to bypass the visibility filter
on UUID lookup, and ships a one-table migration adding deleted_at
to slices.

Migration

* New migration 7c4a8d09ca37 (down_revision 33d7e0e21daa) adding
  nullable deleted_at column + ix_slices_deleted_at index. Uses
  superset.migrations.shared.utils helpers so it's idempotent and
  re-runnable. Reversible.

Model + DAO

* Slice inherits SoftDeleteMixin -> deleted_at column appears on the
  ORM, the global do_orm_execute listener begins filtering Slice
  queries.
* DeleteChartCommand routes through BaseDAO.delete() which now
  detects SoftDeleteMixin and calls soft_delete().

API

* RestoreChartCommand subclasses BaseRestoreCommand[Slice] (4 lines).
* POST /api/v1/chart/<uuid>/restore endpoint with @Protect / @safe /
  @statsd_metrics decorators. Permissions mirror delete exactly:
  endpoint-level can_write, resource-level raise_for_ownership.
* ChartDeletedStateFilter subclasses BaseDeletedStateFilter (2 lines)
  and is wired into the search filters.
* ChartRestoreFailedError exception type.
* Import pipeline uses find_existing_for_import to bypass the visibility
  filter and hard-delete soft-deleted rows before re-import.

Tests

* tests/unit_tests/commands/chart/restore_test.py - 4 unit tests.
* tests/integration_tests/charts/soft_delete_tests.py - integration
  coverage for delete -> soft, restore -> active, list-with-filter,
  importer-handles-soft-deleted.

Depends on sc-106188 (apache#39977). Do NOT merge until sc-106188 is in
master.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@bito-code-review

bito-code-review Bot commented May 22, 2026

Copy link
Copy Markdown
Contributor

Code Review Agent Run #def5a4

Actionable Suggestions - 0
Filtered by Review Rules

Bito filtered these suggestions based on rules created automatically for your feedback. Manage rules.

  • superset/security/manager.py - 1
Review Details
  • Files reviewed - 8 · Commit Range: 03a16d5..c5832d0
    • superset/daos/base.py
    • superset/initialization/__init__.py
    • superset/models/helpers.py
    • superset/security/manager.py
    • superset/views/filters.py
    • tests/unit_tests/commands/importers/v1/test_find_existing_for_import.py
    • tests/unit_tests/views/test_soft_delete_filter.py
    • tests/unit_tests/models/test_soft_delete_mixin.py
  • Files skipped - 0
  • Tools
    • Whispers (Secret Scanner) - ✔︎ Successful
    • Detect-secrets (Secret Scanner) - ✔︎ Successful
    • MyPy (Static Code Analysis) - ✔︎ Successful
    • Astral Ruff (Static Code Analysis) - ✔︎ Successful

Bito Usage Guide

Commands

Type the following command in the pull request comment and save the comment.

  • /review - Manually triggers a full AI review.

  • /pause - Pauses automatic reviews on this pull request.

  • /resume - Resumes automatic reviews.

  • /resolve - Marks all Bito-posted review comments as resolved.

  • /abort - Cancels all in-progress reviews.

Refer to the documentation for additional commands.

Configuration

This repository uses Superset You can customize the agent settings here or contact your Bito workspace admin at evan@preset.io.

Documentation & Help

AI Code Review powered by Bito Logo

@bito-code-review

bito-code-review Bot commented May 25, 2026

Copy link
Copy Markdown
Contributor

Code Review Agent Run #575e5b

Actionable Suggestions - 0
Additional Suggestions - 2
  • tests/unit_tests/commands/importers/v1/test_find_existing_for_import.py - 1
    • Unused fixture parameter · Line 71-71
      The `mocker` parameter on line 71 is never referenced in the test body. Either remove it for clarity or implement the intended mock if future mocking was planned.
      Code suggestion
      --- a/tests/unit_tests/commands/importers/v1/test_find_existing_for_import.py
      +++ b/tests/unit_tests/commands/importers/v1/test_find_existing_for_import.py
       @@ -68,7 +68,7 @@ def test_find_returns_live_row_as_is(
        @pytest.mark.usefixtures("_synthetic_table")
       def test_find_returns_live_row_as_is(
      -    app_context: None, session: Session, mocker: object
      +    app_context: None, session: Session
        ) -> None:
            """A live (not soft-deleted) row is returned as the existing row
            so the caller can treat it as an overwrite target."""
  • superset/views/filters.py - 1
    • Type inconsistency in filter override · Line 183-183
      The `apply` method parameter `value` is typed as `Any` while the overridden `BaseFilter.apply` and all other filter methods in this file (lines 47, 75, 101, 122) use `Optional[Any]`. This inconsistency prevents consistent static analysis and may cause type-checking tools to report mismatched overrides.
Filtered by Review Rules

Bito filtered these suggestions based on rules created automatically for your feedback. Manage rules.

  • superset/models/helpers.py - 1
  • superset/commands/restore.py - 1
    • Base class override contract violated · Line 76-76
Review Details
  • Files reviewed - 14 · Commit Range: 8a812c1..08a3cb6
    • superset/commands/importers/v1/utils.py
    • superset/commands/restore.py
    • superset/constants.py
    • superset/daos/base.py
    • superset/daos/database.py
    • superset/initialization/__init__.py
    • superset/models/helpers.py
    • superset/security/manager.py
    • superset/views/filters.py
    • tests/unit_tests/commands/importers/v1/test_find_existing_for_import.py
    • tests/unit_tests/commands/test_base_restore_command.py
    • tests/unit_tests/daos/test_base_dao_soft_delete.py
    • tests/unit_tests/models/test_soft_delete_mixin.py
    • tests/unit_tests/views/test_soft_delete_filter.py
  • Files skipped - 0
  • Tools
    • Whispers (Secret Scanner) - ✔︎ Successful
    • Detect-secrets (Secret Scanner) - ✔︎ Successful
    • MyPy (Static Code Analysis) - ✔︎ Successful
    • Astral Ruff (Static Code Analysis) - ✔︎ Successful

Bito Usage Guide

Commands

Type the following command in the pull request comment and save the comment.

  • /review - Manually triggers a full AI review.

  • /pause - Pauses automatic reviews on this pull request.

  • /resume - Resumes automatic reviews.

  • /resolve - Marks all Bito-posted review comments as resolved.

  • /abort - Cancels all in-progress reviews.

Refer to the documentation for additional commands.

Configuration

This repository uses Superset You can customize the agent settings here or contact your Bito workspace admin at evan@preset.io.

Documentation & Help

AI Code Review powered by Bito Logo

@betodealmeida betodealmeida left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is awesome!

Comment thread superset/commands/importers/v1/utils.py Outdated
Comment on lines +407 to +409
"""Look up an existing row by UUID for an import operation,
bypassing the soft-delete visibility filter so soft-deleted matches
are returned too.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit, by convention docstrings should have a 1-line short description. Because it's a convention a lot of tooling will rely on it, and in this case IDEs and other tools would show only "Look up an existing row by UUID for an import operation," as the documentation for the function.

Mike Bridge and others added 22 commits May 28, 2026 14:07
Pure-code infrastructure for soft-delete (sc-103157 / SIP-208). Ships
the runtime mechanism and the entity-rollout abstractions in one focused
commit. No model uses the mixin yet, no migration runs, no API surface
changes — everything is dormant until the entity-rollout PRs (sc-106189
/ sc-106190 / sc-106191) opt their concrete entities in.

Runtime mechanism

* ``SoftDeleteMixin`` in ``superset/models/helpers.py`` — defines the
  ``deleted_at`` column, ``is_deleted`` hybrid property,
  ``not_deleted()`` filter clause, and ``soft_delete()`` / ``restore()``
  methods.
* ``_add_soft_delete_filter`` ``do_orm_execute`` listener — uses
  SQLAlchemy's officially-recommended pattern (``do_orm_execute`` +
  ``with_loader_criteria``, per Mike Bayer / sqlalchemy#7973). No-op
  until something inherits the mixin.
* Listener registration in ``setup_soft_delete_listener`` at app init.
* Two opt-out paths: per-query
  ``execution_options(skip_visibility_filter=True)`` for narrow
  bypasses (admin tooling, import re-lookups), and per-request
  ``flask.g.skip_visibility_filter = True`` for user-facing list
  filters that explicitly ask to surface soft-deleted rows.

BaseDAO surface

* ``BaseDAO.soft_delete()``, ``BaseDAO.hard_delete()``, and a
  ``BaseDAO.delete()`` that routes between them based on whether
  ``cls.model_cls`` includes ``SoftDeleteMixin``.
* ``find_by_id``, ``find_by_uuid``, ``find_by_ids``, and
  ``_find_by_column`` gain a ``skip_visibility_filter: bool = False``
  parameter, mirroring the existing ``skip_base_filter`` parameter.
* ``DatabaseDAO.find_by_id`` is updated for signature compatibility.

Restore abstractions

* ``BaseRestoreCommand[T]`` in ``superset/commands/restore.py``, with
  ``T`` bound to ``SoftDeleteMixin``. Subclasses provide ``dao``,
  ``not_found_exc``, ``forbidden_exc``. Validates ownership via
  ``security_manager.raise_for_ownership`` (matches existing delete
  commands).
* ``BaseDeletedStateFilter`` in ``superset/views/filters.py`` — base
  class for per-entity rison filters with ``include`` / ``only`` /
  default values.
* ``find_existing_for_import`` in
  ``superset/commands/importers/v1/utils.py`` — helper for v1
  importer pipelines that takes ``skip_visibility_filter`` and
  hard-deletes any soft-deleted match before returning so the
  re-import doesn't hit a unique-constraint violation on uuid.
* ``restore`` -> ``"write"`` entry in
  ``MODEL_API_RW_METHOD_PERMISSION_MAP`` so future
  per-entity restore endpoints inherit ``can_write`` and no
  permission migration is needed.
* ``BaseSupersetModelRestApi.pre_get_list`` augments list responses
  with ``deleted_at`` when the request has opted into seeing
  soft-deleted rows.

Tests

* ``tests/unit_tests/models/test_soft_delete_mixin.py`` — exercises
  the mixin and listener via a synthetic ``_SoftDeletable`` model.
* ``tests/unit_tests/daos/test_base_dao_soft_delete.py`` — exercises
  routing (soft vs hard) via synthetic DAOs.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Replace the type: ignore on _add_soft_delete_filter with the proper
ORMExecuteState annotation from sqlalchemy.orm.session. SQLAlchemy 1.4
exposes this type for do_orm_execute listeners; the type: ignore was
masking the missing import, not a real typing gap. mypy passes with
the explicit annotation.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two real bugs in the infrastructure surface, both flagged by
codeant-ai-for-open-source on PR apache#39977. Both are fixed in this commit
with documentation and a new unit test.

1. raise_for_ownership re-query was filtered by the listener

   BaseRestoreCommand.validate calls security_manager.raise_for_ownership,
   which re-queries the row internally to fetch its current owners. That
   re-query goes through the global do_orm_execute listener; for a
   soft-deleted row the listener filters it out, raise_for_ownership
   sees no row, falls back to an empty owners list, and raises
   MISSING_OWNERSHIP_ERROR for any non-admin user. The only callers who
   could restore were admins.

   Fix: set g.skip_visibility_filter = True for the scope of the security
   check, restoring the previous value afterward. This is the documented
   per-request opt-out path for cases like this where the bypass must
   propagate to a function we don't directly own.

2. Listener missed the documented loader-path guards

   SQLAlchemy's recommended soft-delete pattern at
   github.com/sqlalchemy/sqlalchemy/issues/7973 guards the criteria
   application with not execute_state.is_column_load and not
   execute_state.is_relationship_load. Without these, every lazy/eager
   relationship load re-applies the criteria, stacking redundant
   deleted_at IS NULL clauses on the loader's query. Adds both guards.

Tests

* New tests/unit_tests/commands/test_restore.py exercises the
  BaseRestoreCommand contract directly:
  - flag is set to True during the security check
  - flag is restored to its previous value afterward
  - flag is restored even when the security check raises
  The previous entity-level tests mocked raise_for_ownership directly
  and never exercised the real re-query path, so they masked the bug.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Three changes addressing items flagged in
apache#39977 (review)

1. DatabaseDAO.find_by_id ignored the new flag

   The override accepted skip_visibility_filter for signature
   compatibility but never applied it to the query. Any caller of the
   override that passed skip_visibility_filter=True still got filtered
   results. Now applies execution_options when the flag is True,
   matching BaseDAO.find_by_id.

2. skip_visibility_filter moved to keyword-only

   The original signature inserted skip_visibility_filter at position 3
   in BaseDAO.find_by_id / find_by_ids / find_by_id_or_uuid /
   _find_by_column, shifting id_column and query_options. Downstream
   positional callers (extensions, library consumers) would silently
   bind the new parameter to a different intent. Moved to the end and
   made keyword-only across all four methods plus the DatabaseDAO
   override; existing in-tree callers all use keyword form already.

3. BaseDeletedStateFilter scope tightened

   Previously set g.skip_visibility_filter = True (broad, per-request),
   which is fine in isolation but would leak soft-deleted rows of
   unrelated entities once multiple models adopt SoftDeleteMixin: a
   list request for soft-deleted dashboards would also bypass the
   filter for incidental Slice / SqlaTable relationship loads.

   Two changes decouple the concerns:
   - The visibility-filter bypass is now applied per-query on the
     primary list query via .execution_options(skip_visibility_filter=
     True). Affects only the list query for this entity.
   - Response augmentation (adding deleted_at to result rows) is
     signalled via a separate request-scoped flag,
     g._augment_response_with_deleted_at. Distinct from the bypass.

   pre_get_list now checks the augmentation flag instead of the
   bypass flag.

Listener docstring updated to clarify the narrower per-request use
case: the broad flag is now reserved for cases where a function we
don't directly control performs an internal re-query that must see
the soft-deleted row (e.g., security_manager.raise_for_ownership).
The previously-claimed use case (list-endpoint rison filters) has
moved to the per-query option.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two fixes from
apache#39977 (review)

1. find_existing_for_import bypassed ORM cleanup listeners

   The helper used a raw Core ``Model.__table__.delete().where(...)`` to
   hard-delete the soft-deleted row before re-import. The original
   rationale was perf (avoid ORM cascade-load), but the raw delete
   skips ORM ``after_delete`` event listeners — which is where two
   important cleanup paths live:

   * ObjectUpdater.after_delete (superset/tags/core.py) cleans up
     tagged_object rows. tagged_object.object_id is a plain integer,
     not a foreign key, so the database cannot cascade them. Bypassing
     the listener leaves orphaned tag rows pointing at deleted entities.
   * SqlaTable.after_delete (superset/connectors/sqla/models.py) cleans
     up dataset permission-view rows. Bypassing the listener leaves
     orphaned permission entries.

   Switched to db.session.delete(existing) so both listeners fire. The
   perf concern (cascade-loading large relationship graphs) is much
   less acute for an import operation than for a steady-state user-
   triggered delete, and correctness wins.

   Updated the function docstring to reflect the change and explain why
   the listener-firing path is required. Updated the bypass primer
   ((superset-spec)/sc-103157-soft-deletes/bypass-primer.md) so the
   "common mistakes" section no longer points at the raw-DELETE pattern
   as a recommended escape hatch.

2. _get_deleted_at_map hardcoded the id PK column

   The helper queried ``self.datamodel.obj.id`` directly, which works
   for chart/dashboard/dataset (all int PKs) but would silently break
   for a future soft-deletable entity with a different PK. Now resolves
   the column via ``self.datamodel.get_pk_name()``.

   Signature relaxed from ``list[int]`` to ``list[Any]`` to match the
   broader PK contract.

Items 2 (per-query bypass scope) and 3 (auth on visibility) from the
review are deferred per the review-thread replies — item 2 is the YAGNI
tradeoff with an explicit code comment, item 3 belongs in SIP-208
discussion rather than this PR.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Previously, BaseRestoreCommand.validate wrapped its raise_for_ownership
call with g.skip_visibility_filter=True so the function's internal
re-query of the resource could see soft-deleted rows. That worked but
left the soft-delete coupling visible at every restore-command site,
required dedicated tests for the bypass contract, and added a follow-up
ticket (sc-106314) to consolidate the boilerplate.

Move the bypass inside raise_for_ownership itself instead. The function
becomes soft-delete-aware: its internal re-query always sees the row
the caller passed, including when that row is soft-deleted. The bypass
is scoped narrowly to the re-query (try/finally restores the previous
value) so it doesn't leak past the function.

Net effect for callers:

- BaseRestoreCommand.validate goes back to a plain
  raise_for_ownership(model) call.
- Every other caller is unaffected — their resources weren't
  soft-deleted, the re-query wasn't being filtered, the bypass is a
  no-op for them.

The change in security/manager.py is 12 lines (try/finally wrapper +
import) and surgical. It preserves the defense-in-depth value of the
re-query (defense against form-data tampering of owners, see PR apache#20499
and the original 2016 commit) while making the function correct for
the soft-delete case.

Removed

- The 15-line try/finally bypass wrapper in BaseRestoreCommand.validate
- The 'from flask import g' and SKIP_VISIBILITY_FILTER imports in
  commands/restore.py
- tests/unit_tests/commands/test_restore.py (the test file specifically
  exercised the bypass contract that no longer lives in
  BaseRestoreCommand)

Updated

- Listener docstring in models/helpers.py to point at the new canonical
  caller of the per-request flag (raise_for_ownership itself).
- Bypass primer to reflect that the per-request bypass is now mostly
  an internal mechanism; new external callers should be rare and
  justified.

Follow-up tickets to be updated separately

- sc-106314 (context-manager refactor) becomes superseded; only one
  caller of the bypass remains, and that one is internal.
- sc-106319 (remove the defensive re-query entirely) drops from
  'fixes the soft-delete coupling' to 'pure perf optimization, optional.'

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ry.get()

The internal re-query in security_manager.raise_for_ownership uses
Query.get(), which has identity-map short-circuit behavior. There was
a real question whether the per-query
execution_options(skip_visibility_filter=True) bypass propagates
correctly through Query.get() in SQLAlchemy 1.4, vs. only working with
the per-request g.skip_visibility_filter flag.

Both tests use session.expire_all() to force the identity-map miss path
so .get() actually issues SQL. The listener then fires and the bypass
is honored in both modes:

* test_per_request_bypass_via_get_finds_soft_deleted_row: verifies the
  current design (per-request g flag) works with .get(). This is what
  raise_for_ownership currently does internally.

* test_per_query_bypass_via_get_finds_soft_deleted_row: verifies the
  per-query execution_options approach also works with .get(). Removes
  the empirical uncertainty about whether execution_options propagates
  through .get(); confirms a future simplification of
  raise_for_ownership (swap g flag for execution_options on the get()
  call) is behaviorally safe.

Both pass. Both bypass surfaces are validated and equivalent for the
raise_for_ownership use case.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…uest to per-query

Replaces the try/finally g.skip_visibility_filter dance inside
raise_for_ownership with a per-query execution_options attached
directly to the internal re-query's .get() call. The listener also
drops its g-flag read since no caller uses it anymore.

The two approaches are behaviorally equivalent for raise_for_ownership
(empirically verified by the previous commit's test_per_query_bypass_
via_get_finds_soft_deleted_row test), but per-query has strict
advantages:

* Scope: exactly the one statement, never broader. The previous
  per-request approach was correctly scoped via try/finally but
  required reasoning about Flask request lifecycle to be confident in
  the scope.
* Listener simplification: drops the getattr(g, ...) read and the
  try/except RuntimeError that handled non-Flask contexts. The
  listener is now a pure function of execute_state.
* No more g coupling in the listener. The listener no longer reaches
  into Flask request state for any reason.
* Public bypass API is one path (per-query) instead of two. Smaller
  surface area for external consumers; less documentation needed.
* test_get_without_bypass_filters_out_soft_deleted_row pins the
  baseline that .get() does fire the listener when forced through
  SQL (via session.expunge_all()), proving the per-query bypass is
  exercised through the real path rather than being short-circuited
  by the identity-map cache.

The previous test_per_request_bypass_via_get_finds_soft_deleted_row
was actually a tautology — it was passing via the identity-map cache,
not via the listener bypass. Replaced with the baseline test plus the
already-empirically-validated per-query test.

Bypass primer updated to remove the per-request bypass section.
Decision tree collapses to a single path. Listener docstring updated
to describe the single opt-out path.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two small follow-ups from the SQLAlchemy-lens review:

1. pre_get_list now resets g._augment_response_with_deleted_at after
   reading it. Today the flag is request-scoped and dies at request
   end, but a batch endpoint dispatching multiple list views in one
   request could leak the flag between them. Read-and-clear keeps the
   semantics explicit.

2. raise_for_ownership docstring expanded to flag the bypass
   propagation through subsequent lazy loads. The execution_options
   on the re-query also affects lazy loads from orig_resource (per
   with_loader_criteria semantics). Today only owners (User, not
   SoftDeleteMixin) is read, so the propagation is harmless. Document
   it so a future contributor doesn't accidentally extend the function
   to access soft-deletable relationships and silently get the
   bypass cascading.

Co-Authored-By: Mike Bridge <michael.bridge@ext.preset.io>
Three structural follow-ups from the clean-code review:

1. SRP — extract SoftDeleteApiMixin from BaseSupersetModelRestApi

   The augmentation methods (_serialize_deleted_at, _get_deleted_at_map,
   and the augmenting pre_get_list) lived on BaseSupersetModelRestApi,
   coupling the FAB REST API base class to soft-delete semantics.
   Moved them into a new SoftDeleteApiMixin in views/filters.py
   (alongside BaseDeletedStateFilter, the filter that signals the
   augmentation). Concrete REST API classes for soft-deletable entities
   opt in by inheritance:

       class ChartRestApi(SoftDeleteApiMixin, BaseSupersetModelRestApi):
           ...

   BaseSupersetModelRestApi loses its soft-delete knowledge entirely.
   The mixin chains via super().pre_get_list(data) so other behaviour
   in the inheritance chain still runs.

2. Naming — rename not_deleted() to where_not_deleted()

   The method returns a SQL ColumnElement, not a Python bool. The name
   "not_deleted" reads like a predicate, which is misleading. Renamed
   to where_not_deleted() to make the SQL-WHERE-clause intent explicit
   at call sites: session.query(Slice).filter(Slice.where_not_deleted()).

   Updated the only in-tree caller (the test exercising the predicate).

3. Encapsulate Conditional — extract _should_apply_soft_delete_filter

   The listener's four-condition guard (is_select + not column_load +
   not relationship_load + not skip_visibility_filter) is now a named
   helper. The listener body is a one-liner; the helper's docstring
   carries the full rationale (citing the SQLAlchemy issue with the
   guards).

   Also DRYed the criterion lambda: now uses cls.where_not_deleted()
   instead of duplicating cls.deleted_at.is_(None). The predicate is
   defined once on the mixin and reused by the listener.

Deferred from the review: the skip_visibility_filter selector argument
on BaseDAO methods. The same shape (skip_base_filter) is established
convention in the codebase; splitting both selectors into separate
named methods is a larger refactor outside this PR's scope.

Co-Authored-By: Mike Bridge <michael.bridge@ext.preset.io>
Tests

* New tests/unit_tests/commands/test_base_restore_command.py pins the
  BaseRestoreCommand.validate contract directly, without going through
  a concrete entity command. Five tests cover the four exits and the
  DAO-load shape:
  - raises not_found_exc when the DAO returns None
  - raises not_found_exc with a distinguishable message when the row
    is live (deleted_at is None)
  - returns the model when soft-deleted and owned (admin path)
  - raises forbidden_exc when raise_for_ownership rejects
  - confirms the DAO is called with id_column="uuid",
    skip_base_filter=True, skip_visibility_filter=True

  These would have caught the bug codeant flagged on the first review
  round (when the per-request bypass wasn't yet in place) and gives
  refactors fast local feedback rather than waiting for entity-branch
  integration CI.

Small tidyings (from the tidy-first review):

* find_existing_for_import: modernized typing (Type[Any] -> type[Any],
  Optional[Any] -> Any | None) for consistency with the rest of the
  diff.
* find_existing_for_import: moved SKIP_VISIBILITY_FILTER import to
  module level. No circular-import concern (verified — models.helpers
  doesn't import from commands.importers).
* _should_apply_soft_delete_filter: extracted two explaining
  variables (caller_opted_out, is_primary_user_select). The reader
  meets the two sub-conditions as named concepts before seeing them
  combined.

Co-Authored-By: Mike Bridge <michael.bridge@ext.preset.io>
The per-query ``execution_options(skip_visibility_filter=True)``
bypass is bound to a specific Query instance — it does not survive
code paths that build a derived query from a fresh
``session.query(Model)``. Flask-AppBuilder's
``SQLAInterface.get_outer_query_from_inner_query`` does exactly that
when ``list_columns`` contains many-to-many fields (charts,
dashboards, datasets all hit this): inner query carries the bypass,
becomes a ``.subquery()``, outer query is built fresh from
``session.query(self.obj)`` and joined to the subquery, listener
fires on the outer query without the bypass option, attaches its
``with_loader_criteria(where_not_deleted)``, and the two predicates
collide to zero rows. Live testing on PR apache#39977 reproduced this on
``GET /api/v1/dashboard/?...&dashboard_deleted_state=only`` —
``count`` is 1, ``result`` is empty.

Other framework or future code paths could lose the per-query option
the same way (Query.from_self(), custom DAO rebuilds, ad-hoc
subquery composition). Patching FAB's specific method would fix the
known site but leave the rest of the surface unguarded.

The fix moves the bypass off the Query and onto the **session**, via
``session.info[SKIP_VISIBILITY_FILTER_CLASSES]``. The listener reads
this directly from ``execute_state.session.info`` regardless of how
the statement was constructed, so the bypass survives FAB's
reconstruction (and any future framework that strips per-query
options).

The bypass is keyed on a **set of classes** rather than a global
boolean. Each ``BaseDeletedStateFilter`` subclass adds only its own
``model`` to the set, so a "deleted dashboards" list response does
not unhide soft-deleted Slice or SqlaTable rows even when those
entities become soft-deletable (sc-106189 / sc-106191).

Listener changes (superset/models/helpers.py):

* ``SKIP_VISIBILITY_FILTER`` (boolean key) replaced by
  ``SKIP_VISIBILITY_FILTER_CLASSES`` (set-of-classes key).
* ``_collect_bypass_classes`` unions per-query
  (``execution_options``) and per-session (``session.info``) sources.
* Listener iterates concrete ``SoftDeleteMixin`` subclasses via
  ``_all_soft_delete_subclasses()`` (transitive) and attaches a
  ``with_loader_criteria(cls, cls.where_not_deleted(), ...)`` only
  for those NOT in the bypass set. Concrete SQL expressions rather
  than callables: passing a callable would trigger SQLAlchemy's
  ``DeferredLambdaElement`` parsing, which does not support Python
  control flow like ``if cls in bypass_classes``.
* New ``skip_visibility_filter(session, *classes)`` context manager
  for narrow-scope programmatic opt-in with guaranteed cleanup.

Filter (superset/views/filters.py):

* ``BaseDeletedStateFilter.apply`` adds ``self.model`` to
  ``session.info[SKIP_VISIBILITY_FILTER_CLASSES]`` via the new
  ``_add_session_bypass`` helper. Stale docstring about per-request
  ``g.skip_visibility_filter`` removed; scope-decision block
  rewritten to describe the actual current mechanism and the FAB
  inner/outer reason for choosing session-scoped over per-query.
* ``SoftDeleteApiMixin._get_deleted_at_map`` uses the new key with a
  one-class set for the entity's own model.

Programmatic callers (DAOs, security manager, importer):

* ``BaseDAO.find_by_id`` / ``find_by_uuid`` / ``find_by_ids`` /
  ``_find_by_column`` and ``DatabaseDAO.find_by_id`` translate the
  boolean ``skip_visibility_filter=True`` kwarg into
  ``execution_options(SKIP_VISIBILITY_FILTER_CLASSES={cls.model_cls})``.
  Public API stays boolean for ergonomics.
* ``security_manager.raise_for_ownership`` uses
  ``{resource.__class__}`` on the internal re-query.
* ``find_existing_for_import`` uses ``{model_cls}`` on the UUID
  lookup.

Tests (tests/unit_tests/models/test_soft_delete_mixin.py):

Rewritten around the per-class semantics. Adds a second synthetic
soft-deletable model and a parent/child pair so isolation and
relationship-load propagation tests are real, not theoretical:

* test_per_query_class_bypass_returns_soft_deleted_rows — basic
  per-query bypass via execution_options.
* test_per_query_bypass_for_one_class_does_not_unhide_other — pins
  per-class scoping at the listener level.
* test_session_bypass_survives_query_reconstruction — reproduces
  the FAB outer/inner shape (inner query as subquery, outer query
  fresh from session.query() joined to the subquery) and proves
  session.info bypass survives it. This is the test that would have
  caught the original bug.
* test_session_bypass_does_not_leak_across_classes — same isolation
  guarantee, session-level vehicle.
* test_context_manager_adds_and_removes_bypass and
  test_context_manager_nested_preserves_outer_scope — context
  manager lifecycle.
* test_relationship_load_filters_child_independently_of_parent_bypass —
  verifies ``with_loader_criteria``'s ``propagate_to_loaders=True``
  carries the per-class criteria to lazy/selectin loads, so a
  bypass for one entity does not unhide its (un-bypassed) children.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Follow-ups from the SQLAlchemy-lens review of the per-entity bypass
refactor (commit 69af6c7):

* ``_all_soft_delete_subclasses()`` now returns a list sorted by
  ``__qualname__`` instead of a set. Set iteration is hash-order, and
  type hashes derive from ``id()`` which differs per process — so the
  same logical query could have its ``with_loader_criteria`` options
  attached in different orders across app processes, fragmenting
  SQLAlchemy's compiled-statement cache. Negligible at 1 soft-delete
  class today; gets real once SIP-208 expands to ~10 entities and the
  listener fires on every primary SELECT.

* ``_collect_bypass_classes`` returns a shared empty ``frozenset``
  sentinel (``_NO_BYPASS``) on the common path. Previously every ORM
  SELECT allocated 4 empty sets (two for ``.get(..., set())`` defaults
  and two for the ``set(per_query) | set(per_session)`` union) even
  when no bypass was in effect. The new fast path is a single dict
  lookup with a shared sentinel; allocation only happens when there's
  an actual bypass.

* Listener docstring gains a Performance note acknowledging the
  linear-in-N cost (one ``with_loader_criteria`` option per
  non-bypassed subclass on every primary SELECT) so the next person
  profiling a hot endpoint isn't surprised.

* ``_all_soft_delete_subclasses`` docstring notes the eager-import
  assumption: Superset imports models eagerly via ``superset.models``;
  a future lazy-import refactor would silently break the listener for
  un-imported classes.

* ``skip_visibility_filter`` docstring notes that calling with zero
  classes is a deliberate no-op.

* New ``test_listener_does_not_affect_non_soft_deletable_queries``
  pins the listener's no-op behavior for queries against classes that
  don't inherit ``SoftDeleteMixin`` — protects against a class of
  regression where future listener changes could silently break
  unrelated queries.

24 unit tests pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Four items from the 2026-05-14 review:

1. ``find_existing_for_import`` split into pure lookup + explicit
   ``clear_soft_deleted_for_import``. Previously the helper hard-deleted
   any soft-deleted match as a side effect of "find," which meant the
   destructive action fired before the importer had finished overwrite
   / permission validation. Today's flow is safe because import is
   gated by ``can_write`` at the top, but it's a forward-looking
   footgun: a future change that adds an overwrite-path permission
   check wouldn't realize the soft-delete path silently bypasses it.
   Splitting moves the destructive call to the call site where it's
   explicit and audit-able.

2. ``BaseRestoreCommand.validate`` no longer passes
   ``skip_base_filter=True`` to the DAO. Existing delete paths load
   through ``find_by_ids`` *with* the per-entity ``base_filter``, then
   run ownership; restore now matches. The base filters (DashboardFilter
   etc.) are RBAC — they don't filter on ``deleted_at``, so leaving
   them on is correct, and the visibility-filter bypass remains the
   only escape hatch needed.

3. Transaction wrapping moved into the base ``run()``. The previous
   contract required every concrete restore command to override
   ``run()`` ONLY to add ``@transaction(on_error=...)``, which was
   fragile — every new entity rollout had to remember. Subclasses
   now declare a ``restore_failed_exc: ClassVar`` and the base
   ``run()`` builds the transactional wrapper at call time, using
   that ClassVar as the re-raise type. Entity restore commands
   become 4 ClassVars with no methods.

4. ``SoftDeleteMixin`` docstring corrected: ``BaseDAO.delete()`` now
   routes through the mixin to ``soft_delete()``;
   ``BaseDAO.hard_delete()`` is the permanent path.

Tests:

* ``test_validate_calls_dao_with_visibility_bypass_only`` replaces
  ``test_validate_calls_dao_with_skip_visibility_filter`` and asserts
  the DAO call no longer includes ``skip_base_filter=True``.
* ``test_run_calls_model_restore_on_success`` covers the happy path
  through the new base ``run()``.
* ``test_run_translates_sqlalchemy_errors_via_restore_failed_exc``
  pins the transaction wrapping: a ``SQLAlchemyError`` raised during
  the unit of work is translated to the subclass's
  ``restore_failed_exc``. Prevents a future refactor from silently
  dropping the wrapper or pointing it at the wrong exception type.

26 unit tests pass across the soft-delete suite.

Entity-rollout PRs (sc-106189, sc-106190, sc-106191) need a follow-up
to drop their ``run()`` overrides and the ``@transaction`` import,
add ``restore_failed_exc = X``, and switch their v1 importer call
sites to use the split ``find_existing_for_import`` +
``clear_soft_deleted_for_import``. That's mechanical and lands in
each entity branch separately.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Three small extractions to lower the abstraction-level mixing in the
listener and the filter without changing behavior. All three motivated
by Bob Martin's stepdown rule / G28 (encapsulate conditional) / G34
(functions descend only one level of abstraction).

* ``_is_primary_user_select(execute_state)`` extracted from the
  listener's compound early-return gate. The three-part OR was readable
  but required the reader to translate "not select OR column-load OR
  relationship-load" into the intent ("primary user SELECT"). Naming
  the predicate also gives the docstring a place to explain the
  ``propagate_to_loaders`` semantics that justify excluding the latter
  two.

* ``_opt_into_deleted_state(query)`` extracted from
  ``BaseDeletedStateFilter.apply`` so the two-step opt-in shared by the
  ``include`` and ``only`` branches stops being duplicated inline. The
  selector argument is forced by FAB's ``BaseFilter.apply(query, value)``
  contract — we can't push polymorphism up — but the duplication inside
  the branches was fixable.

* ``_consume_augmentation_flag`` + ``_inject_deleted_at`` extracted from
  ``SoftDeleteApiMixin.pre_get_list`` so the method reads as policy at
  one level of abstraction (super → consume flag → inject) instead of
  mixing policy with the underlying mechanism (getattr/setattr/zip).

No behavior change. 26 unit tests pass unmodified — the contract is
unchanged; only the internal structure shifted.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two Python-3.10 mypy errors flagged by ``pre-commit (previous)`` on
CI (Python 3.11 / current was clean):

* ``_all_soft_delete_subclasses() -> list[type]`` was too broad — mypy
  3.10 couldn't see that the loop variable in
  ``_add_soft_delete_filter`` was a ``SoftDeleteMixin`` subclass, so
  ``cls.where_not_deleted()`` was flagged as ``"type" has no
  attribute "where_not_deleted"``. Narrowed to
  ``list[type[SoftDeleteMixin]]`` so the classmethod resolves on the
  inferred type.

* The ``# type: ignore[misc, valid-type]`` on ``_SoftDeletableChild``
  was on the ``class`` declaration line, but the inheritance list
  (where ``_TestBase`` is referenced) had wrapped to the next line,
  putting the actual errors out of the ignore's scope. Collapsed the
  declaration so the ignore applies to the same line as the
  ``_TestBase`` reference, matching the pattern used by the sibling
  synthetic models in the file.

26 unit tests pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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>
…tion

Addresses F-002 from @aminghadersohi's review on PR apache#39977:
``BaseDeletedStateFilter._add_session_bypass`` writes the filter's
model class to ``session.info[SKIP_VISIBILITY_FILTER_CLASSES]`` so the
listener stops filtering for FAB's count + inner + outer queries —
but never removed it. The bypass persisted for the rest of the
request, so any code that ran after the list query (audit hooks,
``after_request`` handlers, dependent serialisation work) saw widened
visibility for that model class.

Fix: track filter-added classes in ``g._deleted_state_added_classes``
(request-scoped) and release them in
``SoftDeleteApiMixin.pre_get_list`` after augmentation completes. The
release is scoped to entries *this filter* added — programmatic
bypasses installed by the ``skip_visibility_filter`` context manager
or DAO ``skip_visibility_filter=True`` calls (which manage their own
lifecycle) are untouched.

Sequence within a list response:

1. ``BaseDeletedStateFilter.apply`` adds ``self.model`` to
   ``session.info[SKIP_VISIBILITY_FILTER_CLASSES]`` AND records the
   class in ``g._deleted_state_added_classes``.
2. FAB issues count + inner + outer + relationship loads — all see
   the bypass.
3. ``SoftDeleteApiMixin.pre_get_list`` runs after the list query:
   reads-and-clears the augmentation flag, injects ``deleted_at``,
   then releases the session bypass via a ``try/finally`` so the
   release fires even if augmentation raises.
4. Any code that runs later in the same request sees the normal
   filtered view.

Tests added in ``tests/unit_tests/views/test_soft_delete_filter.py``:

* test_filter_records_added_classes_on_g — pins the tracker
* test_mixin_releases_bypass_after_inject — pins that the release
  removes the filter's entries from session.info
* test_mixin_release_does_not_touch_unrelated_bypass_entries —
  guards against the release clobbering a bypass installed by a
  programmatic caller for a different class
* test_mixin_release_is_noop_when_filter_was_not_invoked — release
  is a no-op when no augmentation flag was set (normal request path)

44 unit tests pass (was 40 + 4 new).

F-001 (TOCTOU in BaseRestoreCommand.validate) intentionally deferred
to its own follow-up PR: the design choice between SELECT ... FOR
UPDATE and an atomic conditional update has real trade-offs (ORM
event listeners vs locking semantics) and deserves focused review
and proper concurrent test coverage.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Six mypy errors flagged by the pre-commit (current) CI check after
the F-002 session-bypass-cleanup commit:

* superset/views/filters.py — ``added`` and ``bypass`` variables in
  ``_release_session_bypass`` need explicit type annotations
  (``set[type[SoftDeleteMixin]]``) because mypy can't infer the
  element type from ``getattr(g, ..., set())`` or
  ``session.info.get(..., set())``. Same pattern in
  ``_add_session_bypass`` where ``added`` shadows the inferred type.

* tests/unit_tests/commands/importers/v1/test_find_existing_for_import.py —
  the ``# type: ignore`` on ``_ImportableSoftDeletable``'s multi-line
  ``class`` declaration was on the ``class`` line but the
  ``_TestBase`` reference (the line that actually triggers
  ``Invalid base class`` + ``not valid as a type``) was on the
  continuation. Collapsed the declaration to one line so the ignore
  applies to the right line — matches the pattern used by the other
  synthetic models in the same test file.

* tests/unit_tests/views/test_soft_delete_filter.py — same ``added``
  annotation fix; plus dropped an unused ``# type: ignore`` on a
  marker inner class (``_OtherSoftDeletable``) that doesn't inherit
  from ``_TestBase`` and therefore doesn't need the suppression.

44 unit tests still pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The listener's loader_criteria was passing a concrete SQL expression
(``cls.where_not_deleted()``) which rendered as the raw table name
(``slices.deleted_at``) regardless of how the table was aliased in
the statement. When the same soft-deletable model appeared under an
alias in a JOIN — e.g., FAB's outer/inner reconstruction or
``report_schedule.chart`` aliasing ``slices AS chart`` — the JOIN's
ON clause produced ``Unknown column 'slices.deleted_at' in 'on
clause'`` and broke the entire query.

Surfaced as CI failures on the entity-rollout PRs (apache#40128 / apache#40129 /
apache#40130) — specifically pre-existing report-schedule tests that join
to ``slices AS chart`` started failing once ``Slice`` inherited
``SoftDeleteMixin``. The infrastructure PR itself doesn't exercise
this because no entity has the mixin yet.

Fix: switch the criteria to a lambda (the form Bayer's canonical
pattern uses). SQLAlchemy invokes the lambda per occurrence and
adapts the column reference to the alias at each site. The lambda's
body is trivial (``c.deleted_at.is_(None)``) so the
``DeferredLambdaElement`` parser handles it without issue — we
originally moved AWAY from a lambda because of an
``if cls in bypass_classes`` conditional that DeferredLambdaElement
mis-parsed as a SQL ``IN`` operator, but the conditional now lives
outside the lambda (in the per-class iteration), so the lambda body
itself is clean.

Test added:
test_listener_adapts_criteria_to_aliased_table_in_joins —
reproduces the aliased-join shape using the synthetic
``_SoftDeletableChild`` model. Without the fix, this query raises
``OperationalError: no such column: ..._soft_deletable_child.deleted_at``
because the criteria renders as the raw table name. With the fix,
the query runs cleanly.

45 unit tests pass (was 44 + 1 regression test).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Bayer's canonical pattern excludes ``is_relationship_load`` events on
the assumption that ``with_loader_criteria(propagate_to_loaders=True)``
(the default) carries criteria from the parent statement to the
relationship loaders.

That works for the simple case where the parent statement references
the target class. It does NOT reliably work when the parent statement
references a *different* class and the criteria targets the
relationship's target only. The Superset case that surfaces this:

* A ``Dashboard`` SELECT fires. The listener attaches
  ``with_loader_criteria(Slice, lambda, propagate_to_loaders=True)``.
  ``Slice`` never appears in the Dashboard statement.
* ``dashboard.slices`` lazy-loads via the many-to-many through
  ``dashboard_slices``. The criteria targeting ``Slice`` does not
  reach this lazy load.
* The relationship load returns soft-deleted charts the listener
  was supposed to exclude.

Surfaced as the failing
``test_restore_chart_reattaches_to_dashboards`` integration test on
the charts-rollout PR (apache#40129): the soft-deleted chart was still in
``dashboard.slices``.

Fix: drop the ``is_relationship_load`` exclusion. The listener now
fires on relationship loads too and re-attaches the criteria
directly. The resulting WHERE clause has
``deleted_at IS NULL`` once (when propagation didn't work) or twice
(when it did) — both are idempotent and correct. Bayer's concern
was about "redundant clauses," not correctness, and a tiny SQL
redundancy is the right trade for closing a real visibility leak.

``is_column_load`` exclusion is kept — those events fire for
attribute refreshes on already-loaded objects, not entity loads,
and attaching loader criteria there would be both pointless and
potentially harmful.

The predicate is renamed
``_is_primary_user_select`` → ``_should_attach_soft_delete_criteria``
to reflect the broader scope; docstring updated to explain the
deliberate inclusion of relationship loads and the
redundancy-vs-correctness trade-off.

899 unit tests pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Addresses betodealmeida's review nit on PR apache#39977:
apache#39977 (comment)

Per PEP 257, a docstring's first line should be a self-contained
one-line summary so IDE tooltips and auto-doc tooling show useful
text. Both helpers in superset/commands/importers/v1/utils.py wrapped
their first sentence across two lines, causing tooltips to display a
trailing comma fragment.

Rewrote the leading line of:
- find_existing_for_import: "Look up an existing row by UUID for an
  import, including soft-deleted matches."
- clear_soft_deleted_for_import: "Hard-delete a soft-deleted row to
  free its UUID for re-import."

The rest of each docstring is unchanged.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@bito-code-review

bito-code-review Bot commented May 28, 2026

Copy link
Copy Markdown
Contributor

Code Review Agent Run #98adfd

Actionable Suggestions - 0
Additional Suggestions - 2
  • superset/security/manager.py - 1
    • Time-sensitive comment needs removal · Line 3557-3557
      The docstring at line 3557 states "(none today; `.owners` is a User)" — a time-sensitive comment that creates a maintenance obligation. Per BITO.md rule [10906], documentation should describe only actively implemented features without predictions about future states.
      Code suggestion
      --- a/superset/security/manager.py
      +++ b/superset/security/manager.py
       @@ -3554,7 +3554,6 @@ class SupersetSecurityManager(
       
                The bypass is scoped to ``resource.__class__`` only —
                any soft-deletable relationships read from ``orig_resource``
      -        (none today; ``.owners`` is a User) remain filtered.
       
                :param resource: The dashboard, dataset, chart, etc. resource
  • tests/unit_tests/commands/importers/v1/test_find_existing_for_import.py - 1
    • Dead code: unused mocker param · Line 71-71
      The `mocker` parameter is declared in the function signature but never used within the test body. Remove it to eliminate dead code and prevent confusion about its intended purpose.
      Code suggestion
      --- a/tests/unit_tests/commands/importers/v1/test_find_existing_for_import.py
      +++ b/tests/unit_tests/commands/importers/v1/test_find_existing_for_import.py
       @@ -68,7 +68,7 @@ def test_find_returns_none_when_no_match(app_context: None, session: Session) -
       
        @pytest.mark.usefixtures("_synthetic_table")
       def test_find_returns_live_row_as_is(
      -    app_context: None, session: Session, mocker: object
      +    app_context: None, session: Session
        ) -> None:
            """A live (not soft-deleted) row is returned as the existing row
            so the caller can treat it as an overwrite target."""
Review Details
  • Files reviewed - 14 · Commit Range: ca51364..125f202
    • superset/commands/importers/v1/utils.py
    • superset/commands/restore.py
    • superset/constants.py
    • superset/daos/base.py
    • superset/daos/database.py
    • superset/initialization/__init__.py
    • superset/models/helpers.py
    • superset/security/manager.py
    • superset/views/filters.py
    • tests/unit_tests/commands/importers/v1/test_find_existing_for_import.py
    • tests/unit_tests/commands/test_base_restore_command.py
    • tests/unit_tests/daos/test_base_dao_soft_delete.py
    • tests/unit_tests/models/test_soft_delete_mixin.py
    • tests/unit_tests/views/test_soft_delete_filter.py
  • Files skipped - 0
  • Tools
    • Whispers (Secret Scanner) - ✔︎ Successful
    • Detect-secrets (Secret Scanner) - ✔︎ Successful
    • MyPy (Static Code Analysis) - ✔︎ Successful
    • Astral Ruff (Static Code Analysis) - ✔︎ Successful

Bito Usage Guide

Commands

Type the following command in the pull request comment and save the comment.

  • /review - Manually triggers a full AI review.

  • /pause - Pauses automatic reviews on this pull request.

  • /resume - Resumes automatic reviews.

  • /resolve - Marks all Bito-posted review comments as resolved.

  • /abort - Cancels all in-progress reviews.

Refer to the documentation for additional commands.

Configuration

This repository uses Superset You can customize the agent settings here or contact your Bito workspace admin at evan@preset.io.

Documentation & Help

AI Code Review powered by Bito Logo

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

change:backend Requires changing the backend size/XXL

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants