Skip to content

feat(soft-delete): add soft delete for charts, dashboards, and datasets#39286

Closed
mikebridge wants to merge 24 commits into
apache:masterfrom
mikebridge:sc-103157-soft-deletes
Closed

feat(soft-delete): add soft delete for charts, dashboards, and datasets#39286
mikebridge wants to merge 24 commits into
apache:masterfrom
mikebridge:sc-103157-soft-deletes

Conversation

@mikebridge

@mikebridge mikebridge commented Apr 10, 2026

Copy link
Copy Markdown
Contributor

SUMMARY

Adds soft-delete support for Charts, Dashboards, and Datasets as the first step in the Versioning epic (sc-103157). Implements SIP-208 (passed 2026-05-08).

Terminology: The UI presents this as Archive / Unarchive. The backend uses standard soft-delete naming (deleted_at, soft_delete(), /restore) — the terminology boundary is at the UI label layer only.

What changed:

  • DELETE endpoints for charts, dashboards, and datasets now set deleted_at = current_timestamp instead of removing the row
  • Soft-deleted objects are automatically excluded from all ORM queries (including relationship lazy loads) via a global do_orm_execute + with_loader_criteria listener — SQLAlchemy's officially recommended pattern
  • New per-entity rison filter on the list endpoints surfaces soft-deleted rows: q=(filters:!((col:id,opr:chart_deleted_state,value:include))) (or dashboard_deleted_state / dataset_deleted_state). Values: include (live + soft-deleted), only (soft-deleted only), absent (default — live only). When applied, response rows include a deleted_at ISO-8601 timestamp.
  • New POST /api/v1/{chart,dashboard,dataset}/<uuid>/restore endpoints restore soft-deleted objects (UUID path param — new endpoints start on UUID from day one)
  • BaseDAO gains soft_delete(), hard_delete(), and an updated delete() that routes based on whether the model includes SoftDeleteMixin; skip_visibility_filter: bool added to find_by_id() and related methods to mirror the existing skip_base_filter parameter
  • Import pipeline fixed: per-entity import functions bypass the soft-delete filter when looking up by UUID and hard-delete any soft-deleted row before re-importing (prevents unique-constraint violations)

Design decisions:

  • No feature flag — soft delete is always active once deployed
  • No cascade — each entity is soft-deleted independently in V1
  • Permission model mirrors delete exactly:
    • Endpoint-level: restore is mapped to "write" in MODEL_API_RW_METHOD_PERMISSION_MAP, so @protect() enforces can_write on Chart/Dashboard/Dataset — the same gate as delete and bulk_delete. No new permission migration is required.
    • Resource-level: BaseRestoreCommand.validate() calls security_manager.raise_for_ownership() — identical to what each delete command already does. Admins always pass; otherwise the user must be in model.owners.
  • The check is isolated in each restore command for easy future tightening (e.g., a separate can_restore permission) without touching the API surface.
  • datetime.now() for deleted_at to match existing AuditMixinNullable convention (UTC conversion was previously reverted)
  • Compatible with future SQLAlchemy-Continuum adoption for full entity versioning (confirmed compatible with SQLAlchemy 1.4.54)
  • Core bulk DML (session.execute(delete(...))) bypasses the do_orm_execute listener — all deletes must go through BaseDAO.delete()

BEFORE/AFTER SCREENSHOTS OR ANIMATED GIF

N/A — backend-only change, no UI modifications. API behaviour is identical from the caller's perspective (same endpoints, same status codes). The only observable difference is that deleted rows remain in the database with deleted_at set.

TESTING INSTRUCTIONS

  1. Soft delete a chart:
curl -X DELETE http://localhost:8088/api/v1/chart/<pk> -H 'Authorization: Bearer <token>'

Verify: returns 200. Chart no longer appears in GET /api/v1/chart/. Row still exists in DB with deleted_at set.

  1. Verify 404 on double-delete:
curl -X DELETE http://localhost:8088/api/v1/chart/<pk> -H 'Authorization: Bearer <token>'

Verify: returns 404 (soft-deleted chart is invisible).

  1. List including deleted (per-entity rison filter):
curl "http://localhost:8088/api/v1/chart/?q=(filters:!((col:id,opr:chart_deleted_state,value:include)))" -H 'Authorization: Bearer <token>'

Verify: soft-deleted chart appears in results with deleted_at set.

  1. Restore the chart:
curl -X POST http://localhost:8088/api/v1/chart/<uuid>/restore -H 'Authorization: Bearer <token>'

Verify: returns 200. Chart reappears in GET /api/v1/chart/.

  1. Repeat for dashboards and datasets using /api/v1/dashboard/ and /api/v1/dataset/ (rison opr becomes dashboard_deleted_state / dataset_deleted_state).

  2. Run tests:

pytest tests/unit_tests/models/test_soft_delete_mixin.py tests/unit_tests/daos/test_base_dao_soft_delete.py tests/unit_tests/commands/{chart,dashboard,dataset}/restore_test.py tests/integration_tests/charts/soft_delete_tests.py tests/integration_tests/dashboards/soft_delete_tests.py tests/integration_tests/datasets/soft_delete_tests.py -v

ADDITIONAL INFORMATION

Migration details:

  • Adds nullable deleted_at (DateTime) column + index to slices, dashboards, tables
  • Downgrade drops the columns and indexes (fully reversible)
  • No data migration needed — existing rows get NULL (active)

Runtime expectations:

  • Column add (nullable, no default): metadata-only on Postgres ≥ 11 and MySQL InnoDB; instant on SQLite. Tested upgrade and downgrade against all three dialects.

  • Index creation: O(rows) on each of slices, dashboards, tables. The migration runs inside the alembic transaction (no CREATE INDEX CONCURRENTLY), so writes to these tables are blocked for the duration:

    • Small / mid-size deployments (≤100k rows in any of the three tables): expect under 5 seconds total wall time.
    • Large deployments (~1M rows in a single table): expect 5–30 seconds of write blocking on Postgres / MySQL InnoDB. Run during a low-traffic window.
    • Very large deployments (≥10M rows): consider running superset db upgrade ahead of the application restart and during off-hours.
  • Has associated issue: sc-103157 / SIP #39464

  • Required feature flags:

  • Changes UI

  • Includes DB Migration (follow approval process in SIP-59)

    • Migration is atomic, supports rollback & is backwards-compatible
    • Confirm DB migration upgrade and downgrade tested
    • Runtime estimates and downtime expectations provided
  • Introduces new feature or API

  • Removes existing feature or API

@github-actions github-actions Bot added risk:db-migration PRs that require a DB migration api Related to the REST API labels Apr 10, 2026
@codecov

codecov Bot commented Apr 10, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 83.52941% with 42 lines in your changes missing coverage. Please review.
✅ Project coverage is 63.91%. Comparing base (ad5e317) to head (d2f2955).
⚠️ Report is 9 commits behind head on master.

Files with missing lines Patch % Lines
superset/dashboards/api.py 58.82% 7 Missing ⚠️
superset/datasets/api.py 61.11% 7 Missing ⚠️
superset/models/helpers.py 78.57% 6 Missing ⚠️
superset/charts/api.py 70.58% 5 Missing ⚠️
superset/commands/importers/v1/utils.py 50.00% 3 Missing and 1 partial ⚠️
superset/daos/base.py 80.95% 2 Missing and 2 partials ⚠️
superset/commands/report/execute.py 66.66% 1 Missing and 1 partial ⚠️
superset/commands/restore.py 92.30% 2 Missing ⚠️
superset/views/base_api.py 88.88% 1 Missing and 1 partial ⚠️
superset/views/filters.py 88.23% 1 Missing and 1 partial ⚠️
... and 1 more
Additional details and impacted files
@@            Coverage Diff             @@
##           master   #39286      +/-   ##
==========================================
+ Coverage   63.88%   63.91%   +0.03%     
==========================================
  Files        2583     2588       +5     
  Lines      136618   136872     +254     
  Branches    31502    31519      +17     
==========================================
+ Hits        87272    87482     +210     
- Misses      47830    47867      +37     
- Partials     1516     1523       +7     
Flag Coverage Δ
hive 39.46% <57.25%> (+0.08%) ⬆️
mysql 59.13% <83.52%> (+0.09%) ⬆️
postgres 59.21% <83.52%> (+0.09%) ⬆️
presto 41.15% <57.25%> (+0.07%) ⬆️
python 60.64% <83.52%> (+0.08%) ⬆️
sqlite 58.85% <83.52%> (+0.09%) ⬆️
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.

@mikebridge mikebridge force-pushed the sc-103157-soft-deletes branch 3 times, most recently from b7c334f to bd77020 Compare April 13, 2026 22:25
)
user = get_user()

# If the matching row was soft-deleted, hard-delete it so the import

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

This is essentially the old behaviour---should it change when importing a record that has been soft-deleted?

Comment thread superset/commands/chart/delete.py Outdated
Comment thread superset/commands/chart/restore.py Outdated
if self._model is None or self._model.deleted_at is None:
raise ChartNotFoundError()

# Permission check — isolated for easy future RBAC changes (FR-007)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

We should determine if we want to do these now

user = get_user()

# If the matching row was soft-deleted, hard-delete it so the import
# can proceed without a unique-constraint violation on ``uuid``.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

ditto importing

@bito-code-review

Copy link
Copy Markdown
Contributor

The current implementation hard-deletes soft-deleted records during import to avoid UUID unique-constraint violations, enabling successful re-import. This behavior should not change, as it's required for imports to proceed without errors when the same UUID exists in a soft-deleted state.

)
assert datasets == []
# Assert permissions get cleaned
# With soft delete, FAB permissions are preserved for potential restore

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Is it an issue if permissions are changed (e.g. a user leaves), and then an object is restored?

@netlify

netlify Bot commented Apr 15, 2026

Copy link
Copy Markdown

Deploy Preview for superset-docs-preview ready!

Name Link
🔨 Latest commit 839ab30
🔍 Latest deploy log https://app.netlify.com/projects/superset-docs-preview/deploys/69fcfff49c760700099e6d68
😎 Deploy Preview https://deploy-preview-39286--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.

@mikebridge

Copy link
Copy Markdown
Contributor Author

The current implementation hard-deletes soft-deleted records during import to avoid UUID unique-constraint violations, enabling successful re-import. This behavior should not change, as it's required for imports to proceed without errors when the same UUID exists in a soft-deleted state.

We should note this in the spec

@mikebridge mikebridge force-pushed the sc-103157-soft-deletes branch 3 times, most recently from 63d2ec6 to e221949 Compare April 20, 2026 22:00
@rusackas rusackas added review:checkpoint Last PR reviewed during the daily review standup review:draft labels Apr 21, 2026
@mikebridge mikebridge force-pushed the sc-103157-soft-deletes branch from e221949 to 3fc84d6 Compare April 23, 2026 21:12
@mikebridge mikebridge force-pushed the sc-103157-soft-deletes branch from 3fc84d6 to 86a7292 Compare May 4, 2026 17:42
@sadpandajoe sadpandajoe removed the review:checkpoint Last PR reviewed during the daily review standup label May 5, 2026
@mikebridge mikebridge force-pushed the sc-103157-soft-deletes branch 2 times, most recently from e8ad3e8 to ddbbce7 Compare May 5, 2026 23:01
Mike Bridge and others added 4 commits May 7, 2026 15:03
…lter

Phase 1 of sc-103157 (T001-T003, T005-T008). Adds soft-delete
infrastructure to Superset:

- SoftDeleteMixin in superset/models/helpers.py with deleted_at
  column, is_deleted hybrid property, soft_delete()/restore() helpers
- SKIP_VISIBILITY_FILTER constant and _add_soft_delete_filter listener
  using SQLAlchemy's recommended do_orm_execute + with_loader_criteria
  pattern (per Mike Bayer)
- Listener registered in superset/initialization/__init__.py during
  app init to ensure it fires in all contexts
- Mixin applied to Slice, Dashboard, and SqlaTable models
- BaseDAO: separate soft_delete()/hard_delete()/delete() methods with
  routing via issubclass(cls.model_cls, SoftDeleteMixin)

Migration (T004) to follow in next commit.

feat(soft-delete): add Alembic migration for deleted_at column

Phase 1 T004 of sc-103157. Adds nullable deleted_at (DateTime)
column and index (ix_{table}_deleted_at) to slices, dashboards,
and tables. Reversible — downgrade drops columns and indexes.

Hand-written (not auto-generated) because the local venv cannot
run the Superset CLI without the full Docker app context. The
migration follows the pattern in data-model.md and uses standard
op.add_column() / op.create_index() for multi-dialect compatibility.

test(soft-delete): add foundational tests for mixin and DAO routing

Phase 2 of sc-103157 (T009-T012). Adds 8 unit tests:

Mixin tests (test_soft_delete_mixin.py):
- soft_delete() sets deleted_at to non-null datetime
- restore() clears deleted_at back to None
- not_deleted() filter clause excludes soft-deleted rows
- skip_visibility_filter execution option makes soft-deleted rows visible

DAO routing tests (test_base_dao_soft_delete.py):
- delete() routes to soft_delete() for ChartDAO (SoftDeleteMixin model)
- delete() routes to hard_delete() for DatabaseDAO (non-mixin model)
- hard_delete() calls db.session.delete() on each item
- soft_delete() calls item.soft_delete() on each item

Also fixes:
- Move setup_soft_delete_listener() after init_app_in_ctx() to avoid
  import cascade hitting uninitialised singletons (event_logger,
  security_manager)
- Guard issubclass() in BaseDAO.delete() against model_cls=None

fix(soft-delete): correct migration down_revision to chain after ce6bd21901ab

The migration was pointing at a1b2c3d4e5f6 which is also the parent
of ce6bd21901ab (migrate deckgl/mapbox), creating a branch with two
heads. Fix by chaining after ce6bd21901ab instead.
Phase 4 of sc-103157 (T019-T024). Adds the ability to restore
soft-deleted charts, dashboards, and datasets:

Commands (3 new files):
- RestoreChartCommand in superset/commands/chart/restore.py
- RestoreDashboardCommand in superset/commands/dashboard/restore.py
- RestoreDatasetCommand in superset/commands/dataset/restore.py

Each uses @transaction(), loads the soft-deleted object with
SKIP_VISIBILITY_FILTER, validates it's actually soft-deleted,
checks permissions (isolated per FR-007 for future RBAC changes),
and clears deleted_at.

API endpoints (3 modified files):
- POST /api/v1/chart/<pk>/restore
- POST /api/v1/dashboard/<pk>/restore
- POST /api/v1/dataset/<pk>/restore

Each returns 200/403/404/422 following the existing delete endpoint
pattern. OpenAPI docstrings included.

Exceptions (3 modified files):
- ChartRestoreFailedError
- DashboardRestoreFailedError
- DatasetRestoreFailedError
Phase 3 (T013-T018) and Phase 4 tests (T025-T028) of sc-103157.

charts/soft_delete_tests.py:
- Soft delete preserves row with deleted_at set
- Soft-deleted chart excluded from GET and list
- Double-delete returns 404 (FR-008)
- Restore makes chart visible again
- Restore nonexistent/active chart returns 404

dashboards/soft_delete_tests.py:
- Soft delete preserves row
- Excluded from list
- Restore reconnects to chart associations (junction rows preserved)

datasets/soft_delete_tests.py:
- Soft delete preserves row
- Excluded from list
- No cascade to dependent charts (FR-009, T018)
- Restore makes dataset visible again

docs(soft-delete): add UPDATING.md entry for soft-delete behaviour change

Phase 5 T029 of sc-103157. Documents the behavioural change for
external integrations: DELETE endpoints now soft-delete, new restore
endpoints available, no cascade, database-direct queries may see
soft-deleted rows.

fix(soft-delete): add 'restore' to include_route_methods whitelist

Flask-AppBuilder uses include_route_methods to control which API
methods are registered as routes. Without listing 'restore' in this
set, the endpoint exists in the code but Flask never registers the
URL rule — resulting in a 404.

fix(soft-delete): add 'restore' to MODEL_API_RW_METHOD_PERMISSION_MAP

Flask-AppBuilder uses this map to determine the required permission
level for each API method. Without it, @Protect() denies access
with a 403. Mapped to 'write' (same as delete) — permission model
is isolated per FR-007 for future RBAC changes.
…ssingChart handles UI)

When a chart is soft-deleted, its rows in the dashboard_slices junction
table are intentionally left in place — the SIP's no-cascade principle.

Behaviour:
  - Dashboards referencing the soft-deleted chart render the existing
    MissingChart placeholder (same UI as a hard-deleted chart's
    dangling position_json reference).
  - Restoring the chart automatically reattaches it to all the
    dashboards it was previously a member of, with no data loss.

Earlier work in this commit experimented with explicit junction
cleanup; that was removed once it became clear the frontend handled
both cases identically and the no-cleanup path is what the SIP
actually mandates. The original commit title incorrectly described
the abandoned approach.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Mike Bridge and others added 2 commits May 7, 2026 15:05
fix(soft-delete): satisfy targeted pre-commit checks
Remove the commented-out dashboard_slices cleanup code from
DeleteChartCommand — the frontend's MissingChart component handles
orphaned junction rows gracefully.

Also remove the unused delete() method from SoftDeleteMixin and
update tests to match.  The delete routing decision stays in
BaseDAO.delete() per the Data Mapper pattern.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@mikebridge mikebridge force-pushed the sc-103157-soft-deletes branch from ddbbce7 to 839ab30 Compare May 7, 2026 21:11
Mike Bridge and others added 6 commits May 7, 2026 15:54
…d override

DatabaseDAO overrides BaseDAO.find_by_id but was missing the new
skip_visibility_filter parameter, causing a mypy override-incompatibility
error introduced when the parameter was added to the base class.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Aggregated soft-deleted-items listing that returns chart, dashboard,
and dataset rows where deleted_at IS NOT NULL in a single paginated
response. Powers the frontend "Archive" view. See spec sc-103157 US4
(FR-014 – FR-017).

Package layout: superset/deleted/{__init__.py, api.py, dao.py,
schemas.py}. Exposed as "Deleted" class_permission_name and
/api/v1/deleted/ URL path; the internal SoftDeleteMixin /
skip_visibility_filter plumbing retains its explicit soft_* naming.

Query parameters travel in a rison-encoded q=(...) envelope, matching
every other Superset list endpoint. Supported fields: types (rison
list subset of chart/dashboard/dataset), search (ilike on name),
deleted_from / deleted_to (ISO-8601 bounds on deleted_at),
order_column (deleted_at | deleted_by | type | name), order_direction
(asc | desc), page, page_size. Validation via @rison(get_deleted_
schema); malformed input yields 400.

Response envelope: { count, result } — matches the standard Superset
shape. Each row carries type, id, uuid, name (normalised from
slice_name / dashboard_title / table_name), deleted_at, and
deleted_by (nested user object joined from
AuditMixinNullable.changed_by_fk per the Attribution design
constraint — no new deleted_by_fk column is added).

DAO strategy: three per-entity ORM queries under
skip_visibility_filter=True plus each entity's normal base security
filter, merged in Python then sorted and paged via a pure-function
helper. Chosen over a SQL UNION ALL to sidestep column-shape padding
and base-filter composition issues; see research R-007 for rationale.
Renamed from list() to list_items() to avoid shadowing the list
built-in in class-scoped type annotations.

Authorisation: row-level access mirrors the corresponding active list
endpoint — the base filter (DatasourceFilter and friends) still gates
which rows a caller can see; only the soft-delete visibility filter
is bypassed. An authenticated user with zero accessible rows receives
200 with count: 0, never 403.

Registered in superset/initialization/__init__.py via
appbuilder.add_api(DeletedRestApi).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Master removed the word-cloud sort_by_series migration in apache#39575 (replaced
its effect with code defaults), so the previous a3b4c5d6e7f8 merge — which
referenced fd0c8583b46d — became invalid. After dropping it, two heads
remained: cb39f18af67f (sc-103157 soft delete) and 33d7e0e21daa (semantic
layers and views), both descending from ce6bd21901ab. Empty merge migration
unifies them so ``superset db upgrade head`` has an unambiguous target.

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

Drops GET /api/v1/deleted/ (aggregated UNION-then-sort) in favour of a
per-entity rison filter on the existing list endpoints. Aligns with how
every other Superset list filter is expressed (rison q=(filters:!(...))
triples) and avoids the cost of querying three heterogeneous tables and
sorting their union.

Filter: opr is ``chart_deleted_state`` / ``dashboard_deleted_state`` /
``dataset_deleted_state``. Values:
  - ``include`` — live + soft-deleted rows
  - ``only``    — only soft-deleted rows
  - absent / other — default behaviour (live rows only)

When applied, list responses augment each row with ``deleted_at``.

Removed:
  - superset/deleted/{api,dao,schemas}.py
  - tests/{unit,integration}_tests/deleted/*
  - allow_include_deleted_list flag, _should_include_deleted_in_list,
    _maybe_include_deleted_in_list (and the wrapper on get_list_headless)

Tests cover both ``include`` and ``only`` semantics for chart, dashboard,
and dataset endpoints. Frontend grep returned zero callers of the dropped
endpoint, so no client-side changes needed.

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

Replace raw op.add_column / op.create_index calls with the project's
idempotent helpers from superset.migrations.shared.utils. Matches the
pattern used by recent migrations (c233f5365c9e themes,
4b2a8c9d3e1f create_tasks) and aligns with CLAUDE.md guidance.

Behaviour is unchanged. The helpers add a skip-if-already-exists guard
plus structured logging, so the migration is re-runnable if interrupted.

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

Adds the skip_visibility_filter kwarg to BaseDAO.find_by_ids so it
matches the bypass surface already exposed by find_by_id,
find_by_id_or_uuid, and _find_by_column. Without this, callers that
need to bulk-load soft-deleted entities (admin tooling, future
permanent-purge jobs, audit queries) had to reach past the DAO and
write raw session.query(...).execution_options(...) calls.

Audit confirms zero existing callers pass 2+ positional args to
find_by_ids — every multi-arg call uses kwargs — so inserting the new
kwarg between skip_base_filter and id_column breaks no caller.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@mikebridge mikebridge force-pushed the sc-103157-soft-deletes branch from 8473dd3 to 80f8ac3 Compare May 7, 2026 21:54
mikebridge-gfm and others added 4 commits May 7, 2026 16:30
Adds a defensive null-guard at the top of
``BaseReportState._get_url`` (``superset/commands/report/execute.py``).
When both ``self._report_schedule.chart`` and
``self._report_schedule.dashboard`` are ``None``, the method now raises
``ReportScheduleUnexpectedError`` with a message identifying the report
and the missing target, rather than crashing on the downstream
``dashboard.id`` access at the (formerly) ``else`` branch of the
``dashboard.uuid if dashboard and dashboard.uuid else dashboard.id``
ternary.

This is a pre-existing latent bug — ``_report_schedule.dashboard`` was
already nullable in principle, but reaching the ``None`` state was
practically unreachable in master because every code path that orphaned
a report was blocked by ``DeleteChartCommand`` /
``DeleteDashboardCommand`` validation.

The soft-delete branch makes the bug newly reachable through admin
tooling / direct SQLAlchemy paths that bypass the validation: the
visibility filter applied to relationship loads returns ``None`` for
soft-deleted targets even though the FK still references the row. The
existing API protections still block the most common trigger path
(``DELETE /api/v1/chart/<id>`` returns 422 when reports reference the
chart), so production scheduled reports are unaffected.

Adds a unit test in ``tests/unit_tests/commands/report/execute_test.py``
that constructs a ``ReportSchedule`` mock with both relationships
returning ``None`` and asserts the new error is raised with the right
identifying details (report id, name, chart_id, dashboard_id).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Surfaced by `pre-commit run --all-files`:

  - tests/unit_tests/commands/{chart,dashboard,dataset}/restore_test.py:
    Restore*Command takes a UUID string, not an int. The DB is mocked
    in these unit tests so the actual value doesn't matter; the type
    just needs to be `str`.
  - tests/integration_tests/datasets/soft_delete_tests.py:
    ruff-format collapses a list comprehension that fit within the
    88-char line-length budget.

No behaviour changes.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ks, embedded, reattach)

Three integration tests pinning down behaviour described in
specs/sc-103157-soft-deletes/pr-readiness.md:

  test_delete_chart_blocked_when_active_report_references_it
    Pins down the existing API protection: DeleteChartCommand.validate()
    raises ChartDeleteFailedReportsExistError (HTTP 422) before any
    soft-delete routing happens. This is what makes the
    "report-execution against soft-deleted target" crash class
    (commands/report/execute.py:_get_url, addressed in commit
    e8156de's null-guard) unreachable through the API.

  test_embedded_dashboard_with_soft_deleted_parent
    Pins down that the embedded view never dereferences the
    embedded.dashboard relationship — it only reads the FK column and
    allow_domain_list — so soft-deleting the parent dashboard leaves
    the iframe URL returning 200 (rather than 500 from a None deref).
    The frontend's subsequent /api/v1/dashboard/<id> fetch returns 404
    cleanly via the visibility filter; the user sees the standard
    "dashboard not found" UI.

  test_restore_chart_reattaches_to_dashboards
    Positive test for the corrected commit
    "feat(soft-delete): preserve dashboard_slices on chart soft-delete
    (MissingChart handles UI)". Confirms the SIP's no-cascade contract
    end-to-end: junction rows are preserved on chart soft-delete, the
    relationship-load filter hides the chart from dashboard.slices
    while soft-deleted, and on restore the chart automatically
    reappears in every dashboard it was a member of — no manual
    re-attachment needed.

All three tests are scoped to the existing TestChart/TestDashboard
soft-delete suites and use the established _hard_delete_* cleanup
pattern.

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

Three near-identical clones across the soft-delete branch consolidated
into shared base classes / helpers. Net: -103 lines of code, the
Restore* / *DeletedStateFilter / import_* surfaces are now the right
size for what they express.

  1. BaseRestoreCommand (superset/commands/base.py)
     ─ Restore{Chart,Dashboard,Dataset}Command shrink from ~65 lines
       each to ~10 lines (just dao + exception bindings + decorator
       application). Behaviour identical: same DAO calls, same
       exception types, same @transaction wrapping. validate() now
       returns the model rather than mutating self._model + assert,
       and the not-found case differentiates "no row" from "row exists
       but isn't soft-deleted" in the error message.

  2. BaseDeletedStateFilter (superset/views/filters.py)
     ─ Chart/Dashboard/DatasetDeletedStateFilter shrink from ~30 lines
       each to ~5 lines (arg_name + model). The g.skip_visibility_filter
       mutation moved to a named _opt_out_of_visibility_filter() helper
       so the side effect is visible at the call site rather than
       buried in a 5-line comment repeated three times.

  3. find_existing_for_import (superset/commands/importers/v1/utils.py)
     ─ The "look up by uuid + hard-delete soft-deleted match" block
       in each of import_{chart,dashboard,dataset} collapses from ~14
       lines to a single helper call. Same SQL behaviour, same
       SKIP_VISIBILITY_FILTER bypass.

The restore unit tests were also rewritten — they were patching
superset.commands.{chart,dashboard,dataset}.restore.db, which never
existed in those modules, so the tests had been failing on master.
Tests now mock the DAO method directly (the level the unit test
actually owns) and pass cleanly.

Verified against the live container:
  - 12/12 restore unit tests pass
  - test_get_url_raises_unexpected_error_when_target_is_missing passes
  - Live API returns expected counts under include / only / default
    chart_deleted_state filter values

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@mikebridge mikebridge force-pushed the sc-103157-soft-deletes branch from 9d9e71b to b41d7f8 Compare May 8, 2026 15:40
mikebridge-gfm and others added 5 commits May 8, 2026 09:47
Three lint failures CI surfaced on b41d7f8:

  - tests/integration_tests/charts/soft_delete_tests.py:112 — line
    too long (89 > 88) on a docstring. Added `# noqa: E501`.
  - tests/unit_tests/commands/dashboard/restore_test.py:62 — same,
    line 90 > 88 on a sed-generated docstring. Added `# noqa: E501`.
  - superset/dashboards/filters.py: unsorted-imports (I001).
    superset/datasets/filters.py: unused-import (F401).
    Both ruff-auto-fixable; applied locally with
    `ruff check --fix` + `ruff format`.

`superset/commands/base.py` also got a one-line ruff-format adjustment
(`# type: ignore[override]` placement) — not a behaviour change.

The same `pre-commit run --all-files` that CI runs is now clean
locally on the touched files.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two small "fewest elements" tidyings flagged by /tidy-first-review:

  1. BaseRestoreCommand.failed_exc — Dead Code.
     Declared as a ClassVar on the base, set on each subclass, but
     never *read* anywhere. The @transaction decorator on each
     subclass's run() carries the concrete RestoreFailed exception
     directly (`reraise=ChartRestoreFailedError`), not via
     `cls.failed_exc`. Removing the field and updating the docstring
     to express the contract in prose. Subclasses lose three lines.

  2. BaseDeletedStateFilter._opt_out_of_visibility_filter — speculative
     parameters. The static helper took (g, key) as arguments, but both
     always passed the same values (flask.g and SKIP_VISIBILITY_FILTER).
     Inlined: imports moved to module level (no circular-import risk
     in views/filters.py), arguments dropped. Call site reads
     `self._opt_out_of_visibility_filter()` — three characters less
     and no implicit "what if g were something else" speculation.

Verified clean: ruff check + format pass on all touched files; 12
restore unit tests pass; live API returns expected counts (104 with
include, 0 with only) after node restart.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Move the soft-delete restore base class out of commands/base.py
(which is generic command infrastructure) into a sibling
commands/restore.py module. No behaviour change.

Also tighten the TypeVar bound from unbounded T to T bound=SoftDeleteMixin,
which makes the contract — that the base class only restores SoftDeleteMixin
models — visible to mypy and fixes a pre-existing attr-defined error on
model.restore().

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Pre-commit's auto-walrus hook in CI rewrote the
`existing = find_existing_for_import(...); if existing:`
pattern to `if existing := find_existing_for_import(...):`
across the three v1 importer utils. No behaviour change.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two short documentation additions in models/helpers.py:

* SoftDeleteMixin.soft_delete: note that datetime.now() mirrors
  AuditMixinNullable.changed_on by design (PR apache#33693 reverted UTC
  on the audit columns).

* _add_soft_delete_filter listener: distinguish the per-query
  execution_options bypass (narrow, for non-user-facing paths) from
  the per-request flask.g flag (broad, reserved for user-facing list
  filters that ask to surface soft-deleted rows). Reduces the risk of
  a future code path setting g.skip_visibility_filter and bleeding the
  bypass into incidental queries.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@mikebridge mikebridge changed the title feat(soft-delete): add soft delete for charts, dashboards, and datasets feat(soft-delete): add soft delete for charts, dashboards, and datasets May 8, 2026
mikebridge-gfm and others added 3 commits May 8, 2026 11:18
Three CI integration jobs (sqlite, mysql, postgres-previous) were
failing on test_embedded_dashboard_with_soft_deleted_parent at the
'soft-deleted dashboard fetch returns 404' assertion, getting 401
'Missing Authorization Header' instead.

The embedded iframe handler invoked under mock.patch.dict was
clearing the session cookie in CI (interaction between EMBEDDED_SUPERSET
feature-flag swap and Flask test-client cookie state), so the
follow-up authenticated API call saw no auth.

Reordered the test to check the API 404 before hitting the embedded
URL. Both assertions still cover the regression they were added for;
they just no longer interfere with each other.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Per the constitution: f"" prefix must not appear on string literals
with no interpolation, including individual fragments in implicit
string concatenation. Drop f-prefix on the trailing fragment of the
null-target error message in BaseReportState._get_url.

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

Three more cases where an f-string fragment in an implicit
concatenation has no interpolation, found by an AST scanner. Per the
constitution: f"" must not appear on string literals with no
interpolation, including individual fragments in implicit string
concatenation. Ruff F541 doesn't currently flag this case in implicit
concat, so they slipped through.

* superset/commands/restore.py:76 — "nothing to restore"
* tests/integration_tests/dashboards/soft_delete_tests.py:170 — leading
  fragment of the 404 assertion message
* tests/integration_tests/dashboards/soft_delete_tests.py:184 — leading
  fragment of the embedded-load assertion message

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

Copy link
Copy Markdown
Contributor Author

Closing as superseded by a decomposition into smaller PRs.

Per reviewer feedback that this work should land in smaller, more reviewable pieces, the soft-delete change is being split into independent PRs under epic Versioning (sc-103155):

PR Story Description Status
#39973 sc-106186 fix(reports): null-guard execution against missing target Open, ready for review
#39977 sc-106188 feat(core): SoftDeleteMixin and restore infrastructure Open, ready for review
(forthcoming) sc-106189 feat(charts): soft-delete and restore for charts Prepped, opens after #39977 merges
(forthcoming) sc-106190 feat(dashboards): soft-delete and restore for dashboards Prepped, opens after #39977 merges
(forthcoming) sc-106191 feat(datasets): soft-delete and restore for datasets Prepped, opens after #39977 merges

The original SIP-208 (#39464) is approved and covers all five PRs. Any review feedback you intended to leave here, please leave on the relevant PR above.

The original branch (sc-103157-soft-deletes) and this PR are kept alive as the source-of-truth diff that the carving was done against; once the four split PRs merge, the branch will be deleted.

@mikebridge

Copy link
Copy Markdown
Contributor Author

Superseded by the decomposition documented in the comment above. Closing now to clear the review queue; reopen if needed.

@mikebridge mikebridge closed this May 8, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

api Related to the REST API review:draft risk:db-migration PRs that require a DB migration size/XXL

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants