Skip to content

feat(core): SoftDeleteMixin and restore infrastructure#39977

Open
mikebridge wants to merge 21 commits into
apache:masterfrom
mikebridge:sc-106188-soft-delete-infrastructure
Open

feat(core): SoftDeleteMixin and restore infrastructure#39977
mikebridge wants to merge 21 commits into
apache:masterfrom
mikebridge:sc-106188-soft-delete-infrastructure

Conversation

@mikebridge
Copy link
Copy Markdown
Contributor

@mikebridge mikebridge commented May 8, 2026

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
Copy link
Copy Markdown

netlify Bot commented May 8, 2026

Deploy Preview for superset-docs-preview ready!

Name Link
🔨 Latest commit 06a64f0
🔍 Latest deploy log https://app.netlify.com/projects/superset-docs-preview/deploys/6a0dfe757f11a30008cacedc
😎 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
Copy link
Copy Markdown

codecov Bot commented May 8, 2026

Codecov Report

❌ Patch coverage is 44.10256% with 109 lines in your changes missing coverage. Please review.
✅ Project coverage is 64.03%. Comparing base (5393fdf) to head (c5832d0).
⚠️ Report is 77 commits behind head on master.

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.14%   64.03%   -0.12%     
==========================================
  Files        2591     2593       +2     
  Lines      138247   138852     +605     
  Branches    32067    32154      +87     
==========================================
+ Hits        88684    88914     +230     
- Misses      48033    48399     +366     
- Partials     1530     1539       +9     
Flag Coverage Δ
hive 39.32% <38.46%> (-0.13%) ⬇️
mysql 58.80% <44.10%> (-0.33%) ⬇️
postgres 58.89% <44.10%> (-0.33%) ⬇️
presto 40.99% <38.46%> (-0.15%) ⬇️
python 60.44% <44.10%> (-0.21%) ⬇️
sqlite 58.53% <44.10%> (-0.33%) ⬇️
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.

Copy link
Copy Markdown
Contributor

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

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
Copy link
Copy Markdown
Contributor

bito-code-review Bot commented May 11, 2026

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

Copy link
Copy Markdown
Contributor

@richardfogaca richardfogaca left a comment

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
Copy link
Copy Markdown
Contributor Author

mikebridge commented May 12, 2026

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.

Copy link
Copy Markdown
Contributor

@richardfogaca richardfogaca left a comment

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
Copy link
Copy Markdown
Contributor

bito-code-review Bot commented May 13, 2026

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
Copy link
Copy Markdown
Contributor Author

mikebridge commented May 13, 2026

@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
Copy link
Copy Markdown
Contributor

@richardfogaca richardfogaca left a comment

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
Copy link
Copy Markdown
Contributor Author

mikebridge commented May 20, 2026

@aminghadersohi thanks for the careful pass. Most items addressed in 06a64f02c1; two deferred with reasoning below.

(from Claude)

HIGH

H1 — uncached subclass walk → cached registry via __init_subclass__. Fixed.

You're right that retrofitting under live traffic is harder than getting it right now. SoftDeleteMixin now maintains a sorted _registered_subclasses: ClassVar[list[type[SoftDeleteMixin]]] updated by __init_subclass__ at class-definition time; _all_soft_delete_subclasses() is now a return SoftDeleteMixin._registered_subclasses — no walk, no sorted() allocation per call. Sort happens once per registration (rare event at app init), not per query.

I chose the __init_subclass__ registry over functools.lru_cache because the cache would freeze at first call — tests that define synthetic subclasses (we have ~6 of them) would never be picked up by the listener, breaking the test suite. The registry hook handles class-definition events naturally and works for both production and test contexts.

H2 — non-restorable / GDPR hook. Deferred.

The concern is valid but I'd push back on landing the interface now. Two reasons:

  1. No concrete use case yet: SIP-208 V1 doesn't include compliance flows. Designing a deletion_policy or locked_at interface without a real GDPR/audit/admin-purge workflow risks getting the API wrong — and once entity migrations land, the migration to a richer interface becomes the expensive change you're trying to avoid.
  2. The natural retrofit isn't catastrophic: if compliance becomes V2 scope, the cheap path is a separate LockedDeletion table (UUID-keyed, points at the soft-deleted row) checked in BaseRestoreCommand.validate(). That's a one-migration add — no column on the mixin, no change to the existing entity migrations. The expense Amin is anticipating ("five entity rollouts already landed") doesn't actually accrue, because the lock lives outside the entity table.

If GDPR work gets scheduled, I'll do the design then with the actual constraints in hand. Tracking as a follow-up rather than a speculative interface here.

MEDIUM

M3 — SoftDeleteMixin inline import in BaseDAO.delete. Fixed. Moved to top-level (no cycle); SKIP_VISIBILITY_FILTER_CLASSES from the same module was already top-level so adding SoftDeleteMixin to the existing import line was the right cleanup.

M4 — raise_for_ownership pylint-disable. Documented (kept inline). Tried moving to top-level — it does cause a real circular: security.managermodels.helpersmodels.core → lazy superset.feature_flag_manager. Kept the inline import but added a comment naming the specific cycle so a future maintainer doesn't try to "fix" it and re-trip the issue:

# Inline import: ``superset.models.helpers`` transitively imports
# ``superset.models.core``, which depends on lazily-initialised
# ``superset.feature_flag_manager``. A top-level import here would
# create a circular dependency (security ↔ models.core ↔ superset).

M5 — BaseDeletedStateFilter.model: Any → typed. Fixed.

# Subclasses bind ``model`` to a concrete ``SoftDeleteMixin``
# subclass. Typed as ``type[SoftDeleteMixin]`` so a subclass that
# accidentally binds to a non-soft-deletable entity fails mypy
# rather than crashing at runtime on ``.deleted_at``.
model: ClassVar[type[SoftDeleteMixin]]

M6 — listener placement. Documented. Investigated and confirmed the placement is intentional — the do_orm_execute hook attaches to the Session class (process-wide), not a Session instance, so it has no Flask-app-context dependency. setup_db() runs earlier in init_app so the Session import is already initialised. Added a six-line comment in init_app explaining the placement so the rationale doesn't have to be re-derived from review history.

M7 — no tests for BaseDeletedStateFilter / SoftDeleteApiMixin. Fixed. New tests/unit_tests/views/test_soft_delete_filter.py with 10 tests:

  • Filter: value-absent / include / only / case-insensitivity / unknown-value — including assertions that the session bypass set and the g augmentation flag are both correctly populated.
  • Mixin: no-op-without-flag / inject-with-flag / consume-flag-on-call (the read-and-clear semantics you specifically called out as subtle enough to warrant a dedicated test) / _serialize_deleted_at handling of None and datetime.

M8 — deleted_at schema mutation undocumented. Acknowledged. This is intentional — deleted_state=include augments rows with deleted_at, and that's the point of the filter. I'll add a "ADDITIONAL INFORMATION" callout to each entity rollout PR body (#40128 / #40129 / #40130) noting the response schema additions, and call out the OpenAPI response-schema update as a per-entity-PR responsibility so it's explicit on the checklist. Not changing the infrastructure PR for this — the augmentation logic itself is correct.

Nits

  • Explicit orig_resource is None guard in raise_for_ownership. Fixed. The fallthrough was correct in effect (hasattr(None, "owners") → False → owners=[] → MISSING_OWNERSHIP_ERROR) but the error reads "you don't own this" when the actual cause is "this row was hard-deleted between the caller's load and the re-query." Now raises the precise cause:

    if orig_resource is None:
        raise SupersetSecurityException(...
            message=_("Resource was removed before ownership could be verified"),
        ...)
  • Unit tests for find_existing_for_import / clear_soft_deleted_for_import. Fixed. New tests/unit_tests/commands/importers/v1/test_find_existing_for_import.py (5 tests) — pure-lookup behaviour for live + soft-deleted + missing rows, clear hard-deletes via the ORM (fires after_delete listeners), and the composed find-then-clear contract that's the documented caller sequence.

  • Feature-flag kill switch (ENABLE_SOFT_DELETE): deliberately not adding. SIP-208 explicitly discusses this: a global flag risks confusing partial states (some entities filtered, others not, depending on which adopters have the mixin at the time the flag flips). The rollback path is "revert the entity rollout PR" rather than a runtime kill — the infrastructure is a no-op without an adopter, so a problem in production means a specific entity rollout PR misbehaves, and reverting that single PR is the right escape hatch.

  • Query.get()session.get(Model, pk) (SQLAlchemy 2.0 idiom): skipping. The pattern is pre-existing in raise_for_ownership and unrelated to soft-delete; doing it here would mix scopes. Worth a separate cleanup PR for the codebase once the SQLAlchemy 1.4 → 2.x migration is on the roadmap (the Mapped[] adoption thread already touches this area).

  • register_soft_delete_listener() public function: skipping. The inline from superset.models.helpers import _add_soft_delete_filter in setup_soft_delete_listener is one line in one place; the indirection doesn't pay for itself.

Architecture notes

  • Cascade decision per entity: agreed, documenting as a per-entity checklist item. Updating the three entity rollout PR bodies (feat(dashboards): soft-delete and restore (sc-106190) #40128 / feat(charts): soft-delete and restore (sc-106189) #40129 / feat(datasets): soft-delete and restore (sc-106191) #40130) to call out the cascade-neutral choice for each (charts: dashboards keep their reference, render a placeholder; dashboards: child slices stay live; datasets: dependent charts render an error at chart-load).

  • owners M2M constraint: noted. Logging as a documented constraint on future soft-deletable additions — if User or Role were ever to become soft-deletable, raise_for_ownership's .owners lookup would silently return empty for legitimate restores. Not a current risk (neither model is on the SIP-208 roadmap), but worth pinning in the mixin docstring as a future-proofing note. Will add in a follow-up tidy.

Re: the praise

Thanks. The dual-bypass mechanism in particular went through ~5 design iterations — per-request g flag → moved into raise_for_ownership → per-query execution_options → discovered FAB's outer/inner reconstruction strips them → session-scoped session.info → per-class scoping. The "design doc woven into the code" effect is partly because each iteration's reasoning had to land in docstrings to keep the next reviewer (and future-me) from re-deriving the same trade-offs.


Infrastructure tip: 06a64f02c1. Entity branches rebased: sc-106189a3ac63be0b, sc-1061908bff0145b5, sc-1061910a56e67842. CI re-running across all four PRs.

40 unit tests pass (was 26; added 14 covering filter, mixin, importer helpers). Let me know if anything else surfaces.

@mikebridge
Copy link
Copy Markdown
Contributor Author

@aminghadersohi A couple points to add:

  • for H2, I don't see current patterns for GDPR in the codebase; I think it's a good idea to address but I think it should be part of a broader concern
  • As for the ENABLE_SOFT_DELETE flag/kill-switch, it was in my original design but the general in-person consensus was that we would try to avoid having two code paths

mikebridge pushed a commit to mikebridge/superset that referenced this pull request May 20, 2026
…/M3/M5)

Three small follow-ups surfaced by aminghadersohi's review of the
SoftDeleteMixin PR (apache#39977) that apply equally here:

- H1: cache _child_to_parent_registry() with functools.cache. Called
  twice per save flush; mapping depends only on import-time model
  classes, so unbounded cache is the right shape (no invalidation).
- M5: tighten _CHILD_BASELINE_HANDLERS type from dict[str, Any] to
  dict[str, Callable[[Session, Any, int], None]] via a named alias.
  Mypy now catches a future broken handler signature.
- M3/M4: explain the inline-import pattern once in the module
  docstrings of baseline.py and changes.py. Both modules use
  pylint disable=import-outside-toplevel uniformly because they
  load during init_versioning() before mappers are configured;
  the per-callsite "why" comments would just repeat the same
  reason. Module-level explanation + a hint to comment unusual
  cases is the cleaner shape.

M6 (listener placement) doesn't apply — init_versioning() already
runs inside init_app_in_ctx(). M8 (loose OpenAPI schema in
*/api.py docstrings) is real but its own change.
Copy link
Copy Markdown
Contributor

@aminghadersohi aminghadersohi left a comment

Choose a reason for hiding this comment

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

Codex second opinion obtained (no blockers found). Two items worth tracking in entity rollout PRs:

**TOCTOU in ** (): the check-then-act window between loading the row and committing means a concurrent hard-delete or ownership change produces a stale authorization decision. The wrapper limits blast radius, but a on the re-query (or an atomic conditional update scoped to + ) would close the window properly.

** session bypass not cleaned up** (): unlike (which removes classes on exit), writes to and relies on request teardown to clear it. Within-request code after the list call sees widened visibility for that model class. Worth scoping this the same way the context manager does.

Both are follow-up items, not blockers. Infrastructure is solid — approving.

Copy link
Copy Markdown
Contributor

@aminghadersohi aminghadersohi left a comment

Choose a reason for hiding this comment

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

Codex second opinion obtained (no blockers found). Two items worth tracking in entity rollout PRs:

TOCTOU in BaseRestoreCommand.validate() (restore.py:82-97): the check-then-act window between validate() loading the row and model.restore() committing means a concurrent hard-delete or ownership change produces a stale authorization decision. The @transaction wrapper limits blast radius, but a SELECT...FOR UPDATE on the re-query (or an atomic conditional update scoped to uuid + deleted_at IS NOT NULL) would close the window properly.

BaseDeletedStateFilter session bypass not cleaned up (filters.py:184-191): unlike skip_visibility_filter (which removes classes on exit), _add_session_bypass writes to session.info and relies on request teardown to clear it. Within-request code after the list call sees widened visibility for that model class. Worth scoping this the same way the context manager does.

Both are follow-up items, not blockers. Infrastructure is solid.

…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>
mikebridge pushed a commit to mikebridge/superset that referenced this pull request May 20, 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>
mikebridge pushed a commit to mikebridge/superset that referenced this pull request May 20, 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 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
Copy link
Copy Markdown
Contributor Author

@aminghadersohi thanks for the post-approval flags — both are worth addressing. F-002 fixed in 8b0495aacb; F-001 deferred to a focused follow-up PR.

F-002 — session bypass cleanup. Fixed.

You're right that relying on session teardown left a within-request widened-visibility window. The fix uses a variation on your option B/C: the filter records classes it added in g._deleted_state_added_classes (request-scoped, auto-cleans), and SoftDeleteApiMixin.pre_get_list removes those entries from session.info[SKIP_VISIBILITY_FILTER_CLASSES] in a try/finally immediately after augmenting the response.

# BaseDeletedStateFilter._add_session_bypass — track for release
bypass = query.session.info.setdefault(SKIP_VISIBILITY_FILTER_CLASSES, set())
bypass.add(self.model)
added = getattr(g, DELETED_STATE_ADDED_CLASSES, set()) | {self.model}
setattr(g, DELETED_STATE_ADDED_CLASSES, added)

# SoftDeleteApiMixin.pre_get_list — release after augmentation
try:
    self._inject_deleted_at(data)
finally:
    self._release_session_bypass()

@staticmethod
def _release_session_bypass() -> None:
    added = getattr(g, DELETED_STATE_ADDED_CLASSES, set())
    if not added:
        return
    bypass = db.session.info.get(SKIP_VISIBILITY_FILTER_CLASSES, set())
    bypass -= added
    setattr(g, DELETED_STATE_ADDED_CLASSES, set())

Key property: the release is scoped to entries the filter added. A programmatic caller using the skip_visibility_filter context manager or a DAO skip_visibility_filter=True call earlier in the same request is unaffected — those bypasses manage their own lifecycles via try/finally or DAO call scope. Pinned by test_mixin_release_does_not_touch_unrelated_bypass_entries.

Sequence within a list response:

  1. BaseDeletedStateFilter.apply adds self.model to both session.info[SKIP_VISIBILITY_FILTER_CLASSES] and g._deleted_state_added_classes.
  2. FAB issues count + inner + outer + relationship loads — all see the bypass.
  3. pre_get_list runs after the list query: consumes the augmentation flag, injects deleted_at, then releases the filter's bypass.
  4. Any code that runs later in the same request (audit hooks, after_request handlers, dependent serialisation) sees the normal filtered view.

Four new tests:

  • test_filter_records_added_classes_on_g — pins the tracker
  • test_mixin_releases_bypass_after_inject — pins the release fires after pre_get_list returns
  • test_mixin_release_does_not_touch_unrelated_bypass_entries — pins that programmatic bypasses are preserved
  • test_mixin_release_is_noop_when_filter_was_not_invoked — pins the no-op path for requests that didn't use deleted_state

44 unit tests pass on the infrastructure branch (was 40).

F-001 — TOCTOU on restore. Deferred.

You're right that the validate()model.restore() → commit window has a stale-authorization race. The fix isn't a one-liner, though, and the design choice between the two options has real trade-offs:

  • SELECT ... FOR UPDATE on the re-query needs either a new for_update kwarg on BaseDAO.find_by_id (widening a load-bearing DAO method) or bypassing the DAO for restore (losing the abstraction). Plus dialect-specific behaviour: SQLite silently no-ops, PostgreSQL/MySQL differ on timeouts and nowait / skip_locked policy.
  • Atomic conditional updateUPDATE … WHERE uuid = ? AND deleted_at IS NOT NULL — closes the window precisely but bypasses ORM before_update / after_update event listeners, including AuditMixinNullable's changed_on / changed_by_fk stamping. Losing audit attribution on restore is a real cost; we'd have to re-add stamping manually.

Proper test coverage means an actual concurrent-session test rig (two sessions, observe lock contention or rowcount=0), not just a unit-level mock — a different kind of work than what's been in this PR.

So F-001 lands separately, where the locking-vs-atomic-update design can get focused review and the concurrency tests can be built deliberately. Tracked in the spec at sc-103157-soft-deletes/spec.md under "Follow-up Items from Review."


Infrastructure tip is now 8b0495aacb. Entity branches rebased: sc-1061899c6d1584a2, sc-1061905e26ebc084, sc-106191ac38056b41. CI re-running across all four.

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>
mikebridge pushed a commit to mikebridge/superset that referenced this pull request May 20, 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>
mikebridge pushed a commit to mikebridge/superset that referenced this pull request May 20, 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 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 20, 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>
mikebridge pushed a commit to mikebridge/superset that referenced this pull request May 20, 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 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 20, 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>
mikebridge pushed a commit to mikebridge/superset that referenced this pull request May 20, 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 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>
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>
mikebridge pushed a commit to mikebridge/superset that referenced this pull request May 20, 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>
mikebridge pushed a commit to mikebridge/superset that referenced this pull request May 20, 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 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>
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>
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
Copy link
Copy Markdown
Contributor

bito-code-review Bot commented May 22, 2026

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

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.

3 participants