Skip to content

feat(datasets): soft-delete and restore#40130

Merged
rusackas merged 14 commits into
apache:masterfrom
mikebridge:sc-106191-datasets-soft-delete
Jul 7, 2026
Merged

feat(datasets): soft-delete and restore#40130
rusackas merged 14 commits into
apache:masterfrom
mikebridge:sc-106191-datasets-soft-delete

Conversation

@mikebridge

@mikebridge mikebridge commented May 14, 2026

Copy link
Copy Markdown
Contributor

SUMMARY

Wire datasets (SqlaTable / the tables table) into the soft-delete infrastructure from #39977 — one of three per-entity rollouts (charts / dashboards / datasets) decomposing SIP-208.

DELETE /api/v1/dataset/<id> and the bulk-delete endpoint stop hard-deleting: the row is marked with a deleted_at timestamp and hidden from all list, detail, lookup, and relationship-load queries (e.g. database.tables). A new POST /api/v1/dataset/<uuid>/restore endpoint clears deleted_at. The v1 YAML importer treats re-import of a soft-deleted UUID as an implicit restore-and-update, and a new dataset_deleted_state rison filter lets the list endpoint surface soft-deleted rows.

Merge ordering satisfied — #41166 is merged and this branch is rebased onto it. Soft-delete on this branch is gated by the SOFT_DELETE release toggle (default off): the BaseDAO.delete() flag check and the per-query visibility-listener gate are both in the merge base. On a default deployment this PR ships dark — DELETE continues to hard-delete until the flag is enabled.

Referential behavior (datasets are referenced by charts/dashboards):

  • No cascade in v1. Soft-deleting a dataset does NOT cascade to its charts or dashboards; they stay visible. A chart whose dataset is soft-deleted surfaces a "datasource not found" error at chart-load time (the existing behavior for any unresolved datasource). Restoring the dataset recovers it.
  • Database deletion is blocked by soft-deleted datasets. Superset already refuses to delete a database that still has datasets; that check now counts soft-deleted datasets too (it bypasses the visibility filter), since the soft-deleted tables rows still reference the database. Because v1 ships no hard-delete/purge, a database that has ever had datasets cannot be deleted through the API once those datasets are soft-deleted, until purge work lands. Deliberate trade-off (no orphaned rows / datasets stay restorable).
  • Logical-uniqueness guard. Physical identity is (database_id, catalog, schema, table_name). Restore, create, update, and re-import all refuse to produce two active datasets for one physical table, returning a clean 422 / ImportFailedError instead of an opaque DB IntegrityError (500). On create/update this is enforced by bypassing the visibility filter so a soft-deleted twin still blocks; on restore/import by DatasetDAO.has_active_logical_duplicate / find_soft_deleted_logical_duplicate.

API CHANGES

New endpoint — restore a soft-deleted dataset

Method / path POST /api/v1/dataset/<uuid>/restore
Path param uuid (string, format uuid) — the dataset UUID
Request body none
Permission can_write on Dataset (endpoint) and ownership of the row, or admin (object-level raise_for_ownership)
200 { "message": "OK" }deleted_at cleared, dataset active again
401 not authenticated
403 authenticated but not an owner/admin of the dataset (DatasetForbiddenError)
404 no dataset with that UUID, or the row is not soft-deleted (DatasetNotFoundError)
422 another active dataset already references the same physical table (DatasetLogicalDuplicateError), or the restore UPDATE failed (DatasetRestoreFailedError)

Permission model mirrors DELETE / bulk_delete: endpoint-level can_write (via method_permission_name["restore"] = "write") plus object-level ownership. Existing can_write on Dataset grants cover restore automatically — no role migration required.

Changed — DELETE / bulk delete become soft. DELETE /api/v1/dataset/<pk> and DELETE /api/v1/dataset/ (bulk) set deleted_at instead of removing the row. Same response codes as before. The row is hidden from the default API but reappears if restored, and direct DB queries on tables still see it. FAB ab_view_menu / permission-view rows tied to the dataset are preserved.

New — dataset_deleted_state rison filter (GET list). A search filter on GET /api/v1/dataset/:

  • include — live rows + soft-deleted rows
  • only — only soft-deleted rows
  • absent / any other value — live rows only (default)

When opted in, each result row is augmented with a deleted_at field (null for live, ISO-8601 for soft-deleted). Access scoping: admins see all soft-deleted rows; non-admins see only soft-deleted datasets they own — the same audience that can restore them.

Combined GET /api/v1/datasource/ no longer counts soft-deleted datasets in pagination totals (explicit deleted_at IS NULL predicate on the Core select).

Importer (import_dataset). Re-importing a UUID that matches a soft-deleted dataset is an implicit restore-and-update for an owner/admin with can_write (clears deleted_at, UPDATEs in place, preserving PK / chart back-reference / columns / metrics). Non-owners and callers without can_write get ImportFailedError. Importing a fresh-UUID dataset whose physical table is claimed by a soft-deleted row also raises ImportFailedError, directing the user to restore instead.

BEFORE/AFTER SCREENSHOTS OR ANIMATED GIF

No UI changes — REST endpoints only. A frontend "Restore" affordance is a follow-up.

Endpoint Before After (flag on)
DELETE /api/v1/dataset/<pk> Hard-deletes the row Sets deleted_at; row hidden, restorable
GET /api/v1/dataset/?...&dataset_deleted_state=only Filter doesn't exist Returns soft-deleted datasets (owner-scoped for non-admins), deleted_at populated
GET /api/v1/dataset/?...&dataset_deleted_state=include Filter doesn't exist Returns live + soft-deleted; deleted_at populated on deleted rows
POST /api/v1/dataset/<uuid>/restore 404 (no such endpoint) Clears deleted_at; 422 if a live dataset already claims the same physical table
POST /api/v1/dataset/ colliding with a soft-deleted row 500 IntegrityError Clean 422
Import of fresh-UUID dataset colliding with a soft-deleted row Creates an active twin of a hidden row ImportFailedError directing to restore
DELETE of a database that has soft-deleted datasets Allowed (if no live datasets) Blocked until purge lands (rows still reference the DB)

TESTING INSTRUCTIONS

Automated

pytest tests/unit_tests/commands/dataset/restore_test.py -v
pytest tests/integration_tests/datasets/soft_delete_tests.py -v

QA / manual — enable the SOFT_DELETE feature flag first (from #41166); on a build that has both this PR and #41166:

  1. Soft-delete leaves the list. Create a dataset, note its uuid. DELETE /api/v1/dataset/<id> → 200. GET /api/v1/dataset/ no longer lists it; GET /api/v1/dataset/<id> → 404.
  2. Charts referencing it. Build a chart on that dataset, then soft-delete the dataset. Load the chart → expect a "datasource not found" error at chart-load time (no cascade; chart still listed).
  3. Surface soft-deleted rows. GET /api/v1/dataset/?q=(filters:!((col:id,opr:dataset_deleted_state,value:only))) → the deleted dataset with deleted_at populated. Try include → live + deleted. As a non-admin non-owner, confirm the soft-deleted row is NOT listed even with value:only.
  4. Restore via API. POST /api/v1/dataset/<uuid>/restore200 {"message":"OK"}. Confirm the dataset reappears and the chart from step 2 loads again.
  5. Restore negatives. Restore as a non-owner → 403. Restore a non-existent UUID, or a UUID that is currently live (not soft-deleted) → 404.
  6. Logical-duplicate on restore (422). Soft-delete dataset A, create dataset B at the same (database, catalog, schema, table), then restore A → 422 DatasetLogicalDuplicateError. (With default DB constraints the create step is rejected first; restore_test.py::test_restore_dataset_logical_duplicate_raises covers the command path with mocks.)
  7. Create blocked by soft-deleted twin (422). Soft-delete a dataset, then POST /api/v1/dataset/ with the same physical identity → 422 (not a 500).
  8. Importer restore. Export a dataset, soft-delete it, re-import the ZIP as the owner → restored in place (PK, columns, metrics, chart back-ref preserved). Re-import as a non-owner / a caller without can_writeImportFailedError.
  9. Importer create-collision. Import a dataset with a fresh UUID whose physical table matches a soft-deleted dataset → ImportFailedError directing you to restore.
  10. Database delete blocked. With a soft-deleted dataset present, attempt to delete its database via the API → blocked (DatabaseDeleteDatasetsExistFailedError).
  11. Flag off. Disable SOFT_DELETE and confirm DELETE hard-deletes as before. The restore endpoint and the dataset_deleted_state filter remain functional by design (so rows soft-deleted during a flag-on window stay discoverable and restorable after a flag rollback): confirm a previously soft-deleted dataset can still be restored with the flag off. Covered by test_delete_dataset_flag_off_hard_deletes for the hard-delete + restore-404 contract.

ADDITIONAL INFORMATION

  • Has associated issue: SIP-208 #39464
  • Required feature flags: SOFT_DELETE (defined in feat(soft-delete): gate soft delete behind a temporary SOFT_DELETE release toggle #41166 — merged; gating verified on this branch)
  • Changes UI
  • Includes DB Migration (follow approval process in SIP-59)
    • Migration is atomic, supports rollback & is backwards-compatible — adds nullable deleted_at column + ix_tables_deleted_at index to tables; downgrade drops both
    • Confirm DB migration upgrade and downgrade tested
    • Runtime estimates and downtime expectations provided — additive: nullable column with no default + a single-column index on tables. Column add is instant; the index build runs inline (no CONCURRENTLY) and may briefly block writes on tables on very large Postgres deployments (reads unaffected; MySQL InnoDB builds online). Run during a maintenance window on large installs.
  • Introduces new feature or API — soft-delete of datasets, POST /api/v1/dataset/<uuid>/restore, the dataset_deleted_state rison filter, and the deleted_at field on list responses when deleted-state visibility is opted into
  • Removes existing feature or API

Behavior changes worth flagging

  • DELETE/bulk_delete are now soft (when SOFT_DELETE is on). Hidden from the default API after delete; restorable; visible via direct DB queries.
  • No cascade to charts/dashboards (v1). Charts on a soft-deleted dataset error at load time.
  • Database deletion blocked by soft-deleted datasets until purge lands.
  • Restore / create / import refuse logical duplicates with 422 / ImportFailedError instead of opaque 500s.
  • Combined GET /api/v1/datasource/ pagination excludes soft-deleted datasets.
  • Non-admins can only enumerate soft-deleted datasets they own via dataset_deleted_state — the restore audience.

Depends on

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

netlify Bot commented May 14, 2026

Copy link
Copy Markdown

Deploy Preview for superset-docs-preview ready!

Name Link
🔨 Latest commit 5314db9
🔍 Latest deploy log https://app.netlify.com/projects/superset-docs-preview/deploys/6a4bc9cb00300700087e0c79
😎 Deploy Preview https://deploy-preview-40130--superset-docs-preview.netlify.app
📱 Preview on mobile
Toggle QR Code...

QR Code

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

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

@codecov

codecov Bot commented May 14, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 71.58470% with 52 lines in your changes missing coverage. Please review.
✅ Project coverage is 64.73%. Comparing base (a7a0517) to head (8e692f5).
⚠️ Report is 17 commits behind head on master.

Files with missing lines Patch % Lines
superset/commands/dataset/importers/v1/utils.py 32.43% 21 Missing and 4 partials ⚠️
superset/commands/database/sync_permissions.py 40.00% 5 Missing and 1 partial ⚠️
superset/commands/database/uploaders/base.py 40.00% 1 Missing and 2 partials ⚠️
superset/datasets/api.py 88.88% 3 Missing ⚠️
...uperset/mcp_service/dataset/tool/create_dataset.py 0.00% 3 Missing ⚠️
...mcp_service/dataset/tool/create_virtual_dataset.py 0.00% 3 Missing ⚠️
superset/commands/database/delete.py 77.77% 1 Missing and 1 partial ⚠️
superset/commands/dataset/restore.py 85.71% 1 Missing and 1 partial ⚠️
superset/views/base.py 75.00% 1 Missing and 1 partial ⚠️
superset/commands/database/exceptions.py 80.00% 1 Missing ⚠️
... and 2 more
Additional details and impacted files
@@            Coverage Diff             @@
##           master   #40130      +/-   ##
==========================================
- Coverage   64.74%   64.73%   -0.01%     
==========================================
  Files        2687     2688       +1     
  Lines      148743   148942     +199     
  Branches    34329    34356      +27     
==========================================
+ Hits        96301    96419     +118     
- Misses      50678    50748      +70     
- Partials     1764     1775      +11     
Flag Coverage Δ
hive 39.19% <28.41%> (-0.02%) ⬇️
mysql 57.89% <71.58%> (+0.01%) ⬆️
postgres 57.95% <71.03%> (+<0.01%) ⬆️
presto 40.72% <28.41%> (-0.02%) ⬇️
python 59.34% <71.58%> (+<0.01%) ⬆️
sqlite 57.53% <71.58%> (+0.01%) ⬆️
unit 100.00% <ø> (ø)

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

☔ View full report in Codecov by Harness.
📢 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-106191-datasets-soft-delete branch from fefb486 to ec98d74 Compare May 18, 2026 17:03
mikebridge pushed a commit to mikebridge/superset that referenced this pull request May 20, 2026
Eight items from the 2026-05-20 review on the infrastructure PR:

* **H1 — subclass walk cached via ``__init_subclass__``.** The previous
  ``_all_soft_delete_subclasses()`` walked ``SoftDeleteMixin.__subclasses__()``
  on every primary ORM SELECT. With one adopter today the cost is
  negligible, but the listener fires on every query in the app and the
  walk grows linearly with each adopted entity. ``SoftDeleteMixin`` now
  maintains a sorted ``_registered_subclasses`` list updated by
  ``__init_subclass__`` at class-definition time; the listener reads
  the cached list with no walk. Sort happens once per registration
  (rare event) rather than per query.

* **M3 — ``SoftDeleteMixin`` import in ``BaseDAO.delete`` moved to
  top.** ``SKIP_VISIBILITY_FILTER_CLASSES`` from the same module was
  already top-level; ``SoftDeleteMixin`` had no real reason to be
  inline. Cleaner.

* **M4 — pylint-disable in ``raise_for_ownership`` documented.** A
  top-level import here actually does cause a circular
  (security ↔ models.core ↔ superset's lazy
  ``feature_flag_manager``). Kept the inline import but added a
  comment naming the cycle so future maintainers don't try to "fix"
  it and re-trip the issue.

* **M5 — ``BaseDeletedStateFilter.model`` typed as
  ``ClassVar[type[SoftDeleteMixin]]``.** Previously ``Any``, which
  meant a subclass accidentally binding ``model`` to a
  non-soft-deletable entity would crash at runtime on
  ``.deleted_at`` rather than failing mypy.

* **M6 — listener placement documented.** ``setup_soft_delete_listener``
  is called outside the ``with app_context()`` block because the
  ``do_orm_execute`` hook attaches to the ``Session`` class
  (process-wide), not to a Session instance, so it doesn't need
  Flask app context. Added an explaining comment in
  ``init_app_in_ctx``'s caller; ``setup_db()`` already ran earlier
  so the Session import is initialised.

* **M7 — unit tests for ``BaseDeletedStateFilter`` +
  ``SoftDeleteApiMixin``.** New
  ``tests/unit_tests/views/test_soft_delete_filter.py`` (10 tests):
  filter no-op / include / only / case-insensitivity / unknown value;
  mixin no-op-without-flag / inject-with-flag / consume-flag-on-call;
  ``_serialize_deleted_at`` None / datetime handling.

* **Nit — explicit ``orig_resource is None`` guard in
  ``raise_for_ownership``.** Previously fell through to
  ``hasattr(None, "owners")`` returning ``False`` → ``owners = []`` →
  generic ownership error. Now raises the proper "resource removed
  before ownership could be verified" error so the cause is clear in
  the rare race-with-hard-delete case.

* **Nit — unit tests for ``find_existing_for_import`` /
  ``clear_soft_deleted_for_import``.** New
  ``tests/unit_tests/commands/importers/v1/test_find_existing_for_import.py``
  (5 tests): no-match-returns-None, live-row-as-is,
  soft-deleted-row-as-is (pure lookup, no side effect),
  ``clear`` hard-deletes via ORM (listener-firing path), and the
  composed find-then-clear contract that's the documented caller
  sequence.

Deferred with explanation in the PR-comment reply (not in this
commit):

* **H2 — non-restorable / GDPR hook**: V2 concern. Designing the
  interface without a concrete use case risks getting it wrong;
  follow-up ticket if compliance work is ever scoped.

* **M8 — ``deleted_at`` schema mutation**: documented in the entity-
  rollout PR bodies (apache#40128/apache#40129/apache#40130) rather than blocking
  infrastructure.

40 unit tests pass (was 26; added 14 covering filter, mixin, importer
helpers).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@mikebridge
mikebridge force-pushed the sc-106191-datasets-soft-delete branch 5 times, most recently from 5995e2d to 8da8ce4 Compare May 20, 2026 22:36
mikebridge pushed a commit to mikebridge/superset that referenced this pull request May 20, 2026
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
mikebridge force-pushed the sc-106191-datasets-soft-delete branch 2 times, most recently from 1d78910 to 8cb25e1 Compare May 21, 2026 15:44
mikebridge pushed a commit to mikebridge/superset that referenced this pull request May 25, 2026
Eight items from the 2026-05-20 review on the infrastructure PR:

* **H1 — subclass walk cached via ``__init_subclass__``.** The previous
  ``_all_soft_delete_subclasses()`` walked ``SoftDeleteMixin.__subclasses__()``
  on every primary ORM SELECT. With one adopter today the cost is
  negligible, but the listener fires on every query in the app and the
  walk grows linearly with each adopted entity. ``SoftDeleteMixin`` now
  maintains a sorted ``_registered_subclasses`` list updated by
  ``__init_subclass__`` at class-definition time; the listener reads
  the cached list with no walk. Sort happens once per registration
  (rare event) rather than per query.

* **M3 — ``SoftDeleteMixin`` import in ``BaseDAO.delete`` moved to
  top.** ``SKIP_VISIBILITY_FILTER_CLASSES`` from the same module was
  already top-level; ``SoftDeleteMixin`` had no real reason to be
  inline. Cleaner.

* **M4 — pylint-disable in ``raise_for_ownership`` documented.** A
  top-level import here actually does cause a circular
  (security ↔ models.core ↔ superset's lazy
  ``feature_flag_manager``). Kept the inline import but added a
  comment naming the cycle so future maintainers don't try to "fix"
  it and re-trip the issue.

* **M5 — ``BaseDeletedStateFilter.model`` typed as
  ``ClassVar[type[SoftDeleteMixin]]``.** Previously ``Any``, which
  meant a subclass accidentally binding ``model`` to a
  non-soft-deletable entity would crash at runtime on
  ``.deleted_at`` rather than failing mypy.

* **M6 — listener placement documented.** ``setup_soft_delete_listener``
  is called outside the ``with app_context()`` block because the
  ``do_orm_execute`` hook attaches to the ``Session`` class
  (process-wide), not to a Session instance, so it doesn't need
  Flask app context. Added an explaining comment in
  ``init_app_in_ctx``'s caller; ``setup_db()`` already ran earlier
  so the Session import is initialised.

* **M7 — unit tests for ``BaseDeletedStateFilter`` +
  ``SoftDeleteApiMixin``.** New
  ``tests/unit_tests/views/test_soft_delete_filter.py`` (10 tests):
  filter no-op / include / only / case-insensitivity / unknown value;
  mixin no-op-without-flag / inject-with-flag / consume-flag-on-call;
  ``_serialize_deleted_at`` None / datetime handling.

* **Nit — explicit ``orig_resource is None`` guard in
  ``raise_for_ownership``.** Previously fell through to
  ``hasattr(None, "owners")`` returning ``False`` → ``owners = []`` →
  generic ownership error. Now raises the proper "resource removed
  before ownership could be verified" error so the cause is clear in
  the rare race-with-hard-delete case.

* **Nit — unit tests for ``find_existing_for_import`` /
  ``clear_soft_deleted_for_import``.** New
  ``tests/unit_tests/commands/importers/v1/test_find_existing_for_import.py``
  (5 tests): no-match-returns-None, live-row-as-is,
  soft-deleted-row-as-is (pure lookup, no side effect),
  ``clear`` hard-deletes via ORM (listener-firing path), and the
  composed find-then-clear contract that's the documented caller
  sequence.

Deferred with explanation in the PR-comment reply (not in this
commit):

* **H2 — non-restorable / GDPR hook**: V2 concern. Designing the
  interface without a concrete use case risks getting it wrong;
  follow-up ticket if compliance work is ever scoped.

* **M8 — ``deleted_at`` schema mutation**: documented in the entity-
  rollout PR bodies (apache#40128/apache#40129/apache#40130) rather than blocking
  infrastructure.

40 unit tests pass (was 26; added 14 covering filter, mixin, importer
helpers).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
mikebridge pushed a commit to mikebridge/superset that referenced this pull request May 25, 2026
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
mikebridge force-pushed the sc-106191-datasets-soft-delete branch 2 times, most recently from 5cb3b5f to bf87b03 Compare May 25, 2026 19:41
mikebridge pushed a commit to mikebridge/superset that referenced this pull request May 25, 2026
CI test-postgres (current) on PR apache#40130 cascade-failed with:

  psycopg2.errors.UniqueViolation: duplicate key value violates unique
  constraint "_customer_location_uc"
  DETAIL: Key (database_id, schema, table_name)=(1, public, birth_names)
          already exists.

Root cause: dashboard_utils.get_table queries SqlaTable through the
ORM, which the soft-delete listener now filters. When a prior test in
the same session soft-deletes the birth_names row (any test exercising
the new soft-delete DELETE behavior on datasets), the row stays in the
DB but is hidden from the helper. create_table_metadata's "doesn't
exist, INSERT" path then collides with the underlying (database_id,
schema, table_name) unique constraint that survives soft-delete (the
constraint is enforced even though the SQLAlchemy comment claims it's
not physical — Postgres test environments enforce it via a named
constraint that an old migration tried to drop).

Two-part fix in tests/integration_tests/dashboard_utils.py:

1. get_table attaches a per-query SKIP_VISIBILITY_FILTER_CLASSES
   execution option so it sees soft-deleted leftovers. Scoped to the
   single query — no leak to anything else in the session.

2. create_table_metadata restores any soft-deleted row it finds before
   mutating it for the new test's setup. Without this, the test would
   succeed in finding the row but then operate on a row whose
   `deleted_at` is still set, which the listener would hide from any
   subsequent API queries inside the same test.

Mirrors the pattern the dashboards branch (sc-106190) uses for
insert_dashboard's defensive cleanup, scoped narrowly to the test
helper and limited to SqlaTable.

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 28, 2026
CI test-postgres (current) on PR apache#40130 cascade-failed with:

  psycopg2.errors.UniqueViolation: duplicate key value violates unique
  constraint "_customer_location_uc"
  DETAIL: Key (database_id, schema, table_name)=(1, public, birth_names)
          already exists.

Root cause: dashboard_utils.get_table queries SqlaTable through the
ORM, which the soft-delete listener now filters. When a prior test in
the same session soft-deletes the birth_names row (any test exercising
the new soft-delete DELETE behavior on datasets), the row stays in the
DB but is hidden from the helper. create_table_metadata's "doesn't
exist, INSERT" path then collides with the underlying (database_id,
schema, table_name) unique constraint that survives soft-delete (the
constraint is enforced even though the SQLAlchemy comment claims it's
not physical — Postgres test environments enforce it via a named
constraint that an old migration tried to drop).

Two-part fix in tests/integration_tests/dashboard_utils.py:

1. get_table attaches a per-query SKIP_VISIBILITY_FILTER_CLASSES
   execution option so it sees soft-deleted leftovers. Scoped to the
   single query — no leak to anything else in the session.

2. create_table_metadata restores any soft-deleted row it finds before
   mutating it for the new test's setup. Without this, the test would
   succeed in finding the row but then operate on a row whose
   `deleted_at` is still set, which the listener would hide from any
   subsequent API queries inside the same test.

Mirrors the pattern the dashboards branch (sc-106190) uses for
insert_dashboard's defensive cleanup, scoped narrowly to the test
helper and limited to SqlaTable.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@mikebridge
mikebridge force-pushed the sc-106191-datasets-soft-delete branch from 6a9640a to c9c9011 Compare May 28, 2026 19:59
mikebridge pushed a commit to mikebridge/superset that referenced this pull request May 28, 2026
Eight items from the 2026-05-20 review on the infrastructure PR:

* **H1 — subclass walk cached via ``__init_subclass__``.** The previous
  ``_all_soft_delete_subclasses()`` walked ``SoftDeleteMixin.__subclasses__()``
  on every primary ORM SELECT. With one adopter today the cost is
  negligible, but the listener fires on every query in the app and the
  walk grows linearly with each adopted entity. ``SoftDeleteMixin`` now
  maintains a sorted ``_registered_subclasses`` list updated by
  ``__init_subclass__`` at class-definition time; the listener reads
  the cached list with no walk. Sort happens once per registration
  (rare event) rather than per query.

* **M3 — ``SoftDeleteMixin`` import in ``BaseDAO.delete`` moved to
  top.** ``SKIP_VISIBILITY_FILTER_CLASSES`` from the same module was
  already top-level; ``SoftDeleteMixin`` had no real reason to be
  inline. Cleaner.

* **M4 — pylint-disable in ``raise_for_ownership`` documented.** A
  top-level import here actually does cause a circular
  (security ↔ models.core ↔ superset's lazy
  ``feature_flag_manager``). Kept the inline import but added a
  comment naming the cycle so future maintainers don't try to "fix"
  it and re-trip the issue.

* **M5 — ``BaseDeletedStateFilter.model`` typed as
  ``ClassVar[type[SoftDeleteMixin]]``.** Previously ``Any``, which
  meant a subclass accidentally binding ``model`` to a
  non-soft-deletable entity would crash at runtime on
  ``.deleted_at`` rather than failing mypy.

* **M6 — listener placement documented.** ``setup_soft_delete_listener``
  is called outside the ``with app_context()`` block because the
  ``do_orm_execute`` hook attaches to the ``Session`` class
  (process-wide), not to a Session instance, so it doesn't need
  Flask app context. Added an explaining comment in
  ``init_app_in_ctx``'s caller; ``setup_db()`` already ran earlier
  so the Session import is initialised.

* **M7 — unit tests for ``BaseDeletedStateFilter`` +
  ``SoftDeleteApiMixin``.** New
  ``tests/unit_tests/views/test_soft_delete_filter.py`` (10 tests):
  filter no-op / include / only / case-insensitivity / unknown value;
  mixin no-op-without-flag / inject-with-flag / consume-flag-on-call;
  ``_serialize_deleted_at`` None / datetime handling.

* **Nit — explicit ``orig_resource is None`` guard in
  ``raise_for_ownership``.** Previously fell through to
  ``hasattr(None, "owners")`` returning ``False`` → ``owners = []`` →
  generic ownership error. Now raises the proper "resource removed
  before ownership could be verified" error so the cause is clear in
  the rare race-with-hard-delete case.

* **Nit — unit tests for ``find_existing_for_import`` /
  ``clear_soft_deleted_for_import``.** New
  ``tests/unit_tests/commands/importers/v1/test_find_existing_for_import.py``
  (5 tests): no-match-returns-None, live-row-as-is,
  soft-deleted-row-as-is (pure lookup, no side effect),
  ``clear`` hard-deletes via ORM (listener-firing path), and the
  composed find-then-clear contract that's the documented caller
  sequence.

Deferred with explanation in the PR-comment reply (not in this
commit):

* **H2 — non-restorable / GDPR hook**: V2 concern. Designing the
  interface without a concrete use case risks getting it wrong;
  follow-up ticket if compliance work is ever scoped.

* **M8 — ``deleted_at`` schema mutation**: documented in the entity-
  rollout PR bodies (apache#40128/apache#40129/apache#40130) rather than blocking
  infrastructure.

40 unit tests pass (was 26; added 14 covering filter, mixin, importer
helpers).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
mikebridge pushed a commit to mikebridge/superset that referenced this pull request May 28, 2026
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 28, 2026
CI test-postgres (current) on PR apache#40130 cascade-failed with:

  psycopg2.errors.UniqueViolation: duplicate key value violates unique
  constraint "_customer_location_uc"
  DETAIL: Key (database_id, schema, table_name)=(1, public, birth_names)
          already exists.

Root cause: dashboard_utils.get_table queries SqlaTable through the
ORM, which the soft-delete listener now filters. When a prior test in
the same session soft-deletes the birth_names row (any test exercising
the new soft-delete DELETE behavior on datasets), the row stays in the
DB but is hidden from the helper. create_table_metadata's "doesn't
exist, INSERT" path then collides with the underlying (database_id,
schema, table_name) unique constraint that survives soft-delete (the
constraint is enforced even though the SQLAlchemy comment claims it's
not physical — Postgres test environments enforce it via a named
constraint that an old migration tried to drop).

Two-part fix in tests/integration_tests/dashboard_utils.py:

1. get_table attaches a per-query SKIP_VISIBILITY_FILTER_CLASSES
   execution option so it sees soft-deleted leftovers. Scoped to the
   single query — no leak to anything else in the session.

2. create_table_metadata restores any soft-deleted row it finds before
   mutating it for the new test's setup. Without this, the test would
   succeed in finding the row but then operate on a row whose
   `deleted_at` is still set, which the listener would hide from any
   subsequent API queries inside the same test.

Mirrors the pattern the dashboards branch (sc-106190) uses for
insert_dashboard's defensive cleanup, scoped narrowly to the test
helper and limited to SqlaTable.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@mikebridge
mikebridge force-pushed the sc-106191-datasets-soft-delete branch from c9c9011 to 83445b3 Compare May 28, 2026 20:08
mikebridge pushed a commit to mikebridge/superset that referenced this pull request May 28, 2026
Three SQLAlchemy-review follow-ups in import_dataset, mirroring the
charts (16b5ae9) and dashboards (adf511b) passes:

1. **Explicit flush on restore-and-update path + rewritten comment.**
   The implicit-restore flow relied on autoflush to push the deleted_at
   = NULL update before SqlaTable.import_from_dict's internal query-by-
   uuid lookup ran. Without it, the listener would have filtered the
   row out, import_from_dict would have taken the "new object" branch,
   and the insert would have hit the uuid unique constraint at flush
   time. Adding db.session.flush() after clearing deleted_at makes the
   contract independent of autoflush. The inline comment is rewritten
   to describe the actual mechanism (the previous wording claimed
   config["id"] "routes import_from_dict through UPDATE", but
   ImportExportMixin strips non-export_fields keys — so config["id"]
   is defensive and the real bind happens via uuid uniqueness inside
   import_from_dict).

2. **Force sync on the implicit-restore path.** Previously
   `sync = ["columns", "metrics"] if overwrite else []`, so re-importing
   a soft-deleted dataset hit `sync = []` and ImportExportMixin would
   upsert children by UUID without removing rows present in the live
   DB but absent from the upload. Net effect: a stale-then-restored
   dataset carried orphan columns from before the soft-delete — a
   surprising merge of two states. Now ``sync = ["columns", "metrics"]
   if (overwrite or is_soft_deleted_match) else []`` so re-import of a
   soft-deleted UUID is treated as a clean replacement, matching what
   an explicit overwrite would do anyway.

   New test test_import_soft_deleted_dataset_restore_removes_orphan_children
   pins this contract.

3. **MultipleResultsFound fallback bypass.** The post-exception query
   `db.session.query(SqlaTable).filter_by(uuid=...).one()` didn't
   bypass the soft-delete listener, so a soft-deleted duplicate would
   be hidden and `.one()` would raise NoResultFound, masking the
   original MultipleResultsFound. Added SKIP_VISIBILITY_FILTER_CLASSES
   execution option to the fallback so it can locate either state.

All three items raised by the SQLAlchemy lens review on PR apache#40130.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@mikebridge
mikebridge force-pushed the sc-106191-datasets-soft-delete branch from acc8884 to 72049a9 Compare June 1, 2026 18:50
Comment thread superset/daos/dataset.py
Comment thread superset/datasets/api.py
Comment thread superset/datasets/api.py
Comment thread superset/daos/dataset.py
Comment thread tests/integration_tests/datasets/soft_delete_tests.py
@mikebridge

Copy link
Copy Markdown
Contributor Author

@aminghadersohi — closing out the two remaining items from your 2026-06-29 review body (the two inline findings from that pass were fixed in b9f6282; these two had no thread to resolve, so noting them here):

  • MEDIUM — get_or_create_dataset opaque 422 on a soft-deleted collision: fixed in 48e350b986, essentially per your sketch — a pre-check with DatasetDAO.find_soft_deleted_logical_duplicate (the same helper the YAML importer uses) returns a targeted 422 naming the hidden twin's uuid and the POST /api/v1/dataset/<uuid>/restore resolution path, instead of the bare "already exists."
  • NIT — duplicated "uuid" in list_columns: dropped in the same commit.

That closes every finding from all four of your passes on this PR.

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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Code Review Agent Run #1dca5c

Actionable Suggestions - 3
  • superset/translations/fi/LC_MESSAGES/messages.po - 1
  • tests/unit_tests/commands/dataset/restore_test.py - 1
    • Missing assertion on DAO call arguments · Line 57-57
  • superset/commands/database/delete.py - 1
Additional Suggestions - 2
  • tests/unit_tests/databases/commands/delete_test.py - 1
    • Test fixture inconsistency · Line 25-63
      Replace the two inline ``SqlaTable.metadata.create_all(session.get_bind())`` calls with a module-scoped ``_synthetic_tables`` fixture (drop-in copy of the pattern in ``test_soft_delete_mixin.py``) plus ``@pytest.mark.usefixtures("_synthetic_tables")`` decorators — consistent with the 18 other tests in that file that follow this convention.
  • superset/commands/dataset/duplicate.py - 1
    • Missing test coverage for run() · Line 68-74
      The existing test suite for duplicate.py has no tests for the run() method. While the catalog assignment (line 71) is correct and consistent with the model, there is no test verifying that the catalog attribute is preserved when duplicating a dataset.
Review Details
  • Files reviewed - 55 · Commit Range: 55419f6..0b05c4b
    • superset/commands/database/delete.py
    • superset/commands/database/exceptions.py
    • superset/commands/database/sync_permissions.py
    • superset/commands/database/uploaders/base.py
    • superset/commands/dataset/duplicate.py
    • superset/commands/dataset/exceptions.py
    • superset/commands/dataset/importers/v1/utils.py
    • superset/commands/dataset/restore.py
    • superset/connectors/sqla/models.py
    • superset/daos/dataset.py
    • superset/daos/datasource.py
    • superset/datasets/api.py
    • superset/datasets/filters.py
    • superset/migrations/versions/2026-05-08_12-10_3a8e6f2c1b95_add_deleted_at_to_tables.py
    • superset/security/manager.py
    • superset/translations/ar/LC_MESSAGES/messages.po
    • superset/translations/ca/LC_MESSAGES/messages.po
    • superset/translations/cs/LC_MESSAGES/messages.po
    • superset/translations/de/LC_MESSAGES/messages.po
    • superset/translations/en/LC_MESSAGES/messages.po
    • superset/translations/es/LC_MESSAGES/messages.po
    • superset/translations/fa/LC_MESSAGES/messages.po
    • superset/translations/fi/LC_MESSAGES/messages.po
    • superset/translations/fr/LC_MESSAGES/messages.po
    • superset/translations/it/LC_MESSAGES/messages.po
    • superset/translations/ja/LC_MESSAGES/messages.po
    • superset/translations/ko/LC_MESSAGES/messages.po
    • superset/translations/lv/LC_MESSAGES/messages.po
    • superset/translations/messages.pot
    • superset/translations/mi/LC_MESSAGES/messages.po
    • superset/translations/nl/LC_MESSAGES/messages.po
    • superset/translations/pl/LC_MESSAGES/messages.po
    • superset/translations/pt/LC_MESSAGES/messages.po
    • superset/translations/pt_BR/LC_MESSAGES/messages.po
    • superset/translations/ru/LC_MESSAGES/messages.po
    • superset/translations/sk/LC_MESSAGES/messages.po
    • superset/translations/sl/LC_MESSAGES/messages.po
    • superset/translations/th/LC_MESSAGES/messages.po
    • superset/translations/tr/LC_MESSAGES/messages.po
    • superset/translations/uk/LC_MESSAGES/messages.po
    • superset/translations/zh/LC_MESSAGES/messages.po
    • superset/translations/zh_TW/LC_MESSAGES/messages.po
    • tests/integration_tests/dashboard_utils.py
    • tests/integration_tests/datasets/api_tests.py
    • tests/integration_tests/datasets/soft_delete_tests.py
    • tests/integration_tests/security_tests.py
    • tests/unit_tests/commands/databases/sync_permissions_test.py
    • tests/unit_tests/commands/databases/upload_command_test.py
    • tests/unit_tests/commands/dataset/restore_test.py
    • tests/unit_tests/commands/dataset/test_duplicate.py
    • tests/unit_tests/dao/dataset_test.py
    • tests/unit_tests/dao/datasource_test.py
    • tests/unit_tests/databases/commands/delete_test.py
    • tests/unit_tests/datasets/commands/importers/v1/import_test.py
    • tests/unit_tests/migrations/test_add_deleted_at_to_tables.py
  • Files skipped - 1
    • UPDATING.md - Reason: Filter setting
  • Tools
    • MyPy (Static Code Analysis) - ✔︎ Successful
    • Astral Ruff (Static Code Analysis) - ✔︎ Successful
    • Whispers (Secret Scanner) - ✔︎ Successful
    • Detect-secrets (Secret Scanner) - ✔︎ 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/translations/fi/LC_MESSAGES/messages.po
Comment thread tests/unit_tests/commands/dataset/restore_test.py Outdated
Comment thread superset/commands/database/delete.py
Comment thread superset/daos/dataset.py
Comment thread superset/daos/dataset.py
Comment thread superset/datasets/filters.py
Comment thread superset/security/manager.py
Comment thread superset/views/filters.py
Comment thread superset/commands/dataset/importers/v1/utils.py
Comment thread superset/commands/dataset/importers/v1/utils.py
Comment thread superset/commands/dataset/create.py
Comment thread superset/commands/database/exceptions.py
Comment thread superset/views/base.py
Comment thread superset/commands/database/sync_permissions.py
Mike Bridge and others added 14 commits July 6, 2026 11:10
When the SOFT_DELETE feature flag is enabled (default off — ships dark),
DELETE /api/v1/dataset/<id> and the bulk-delete endpoint soft-delete:
the row is marked with deleted_at, hidden from list/detail/lookup
endpoints and relationship loads, and recoverable via the new
POST /api/v1/dataset/<uuid>/restore endpoint (can_write + ownership, or
admin). The restore audience can enumerate soft-deleted rows via the
dataset_deleted_state rison filter (include|only). With the flag off,
deletes remain permanent hard deletes.

Highlights:
- Migration 3a8e6f2c1b95 adds nullable deleted_at + index to tables
  (down_revision 2bee73611e32, current master head).
- Create/update/duplicate uniqueness checks bypass the visibility filter
  (session-scoped bypass — per-query execution options don't reach the
  listener from EXISTS subqueries) so hidden twins still block; restore
  validates against active logical duplicates and returns 422.
- Importer: a dataset YAML whose UUID matches a soft-deleted row is an
  implicit restore-with-update (ownership-gated), preserving the PK,
  table_columns, sql_metrics, and chart back-references; duplicate
  guards cover both directions.
- Deleting a database stays blocked while soft-deleted datasets still
  reference it (documented trade-off until purge lands).
- Perm-string maintenance on database rename rewrites soft-deleted
  datasets' perm/schema_perm/catalog_perm (and their charts') through
  the visibility bypass, in both the security-manager and
  SyncPermissionsCommand paths.
- FAB view-menu/permission rows are preserved on soft delete so restore
  brings access back intact; hard delete (flag off) still cleans up.
- Flag-gated integration suites for soft delete and restore, importer
  permission-matrix unit tests, migration up/down tests; UPDATING.md
  documents the flag gate, semantics, and the two validation changes
  that apply regardless of the flag.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…leted rows

Two review-bot findings verified and fixed:

- The create_datasets fixture purge deleted every dataset matching the
  fixture table names (the lookup bypasses the visibility filter and
  matches by name only), so it could hard-delete live datasets other
  suites created over the same AB tables. Restrict it to rows with
  deleted_at set, matching the comment's stated intent.

- test_deleted_state_list_shows_owner_their_own_deleted ran its teardown
  only on the happy path, unlike its siblings which use try/finally; a
  failed assertion stranded the soft-deleted dataset/database in the
  shared test DB. Wrap in try/finally to match.
… head

apache#40128 (dashboards soft-delete) merged with migration 9e1f3b8c4d2a chaining
off the previous head; re-point onto it to keep a single alembic head.
Rebased onto post-apache#40128 master (0 conflicts).
Addresses the two remaining review-body items from aminghadersohi's
2026-06-29 pass (the inline findings from that pass were fixed in
b9f6282; these two lived in the review body):

- MEDIUM: get_or_create_dataset returned an opaque "already exists" 422
  when a soft-deleted dataset holds the same physical table — while the
  caller's dataset list looks empty. Pre-check with
  find_soft_deleted_logical_duplicate (the same helper the YAML importer
  uses) and return a targeted message naming the hidden twin's uuid and
  the restore endpoint.

- NIT: drop the duplicated "uuid" entry in list_columns.
Review-panel finding (verified): UploadCommand located its target dataset
with a plain visibility-filtered query, so with SOFT_DELETE on, a
soft-deleted dataset over the same (database, schema, table_name) was
invisible and the upload created an active twin — permanently blocking
the hidden row's restore — or died on the legacy unique constraint. Either
way the file's contents had already been written to the analytics
database, outside the metadata transaction. The natural trigger is the
most common flow for uploaded datasets: soft-delete, then re-upload the
same file.

Guard with find_soft_deleted_logical_duplicate (the same helper the
create/get_or_create/duplicate/import paths use) BEFORE reader.read, so a
blocked upload leaves the analytics database untouched, and raise a
typed 422 naming the twin's uuid and the restore endpoint (executable
recoveries only). The DatasetDAO import is deferred inside run() — the
module-top form is a circular import via views.base (the same apache#40573
constraint documented in daos/dataset.py).

Tests pin both the block (raises before reader.read — fails without the
guard) and the no-twin control path.
…dard

Review-panel finding (6 of 8 lenses): the datasets UPDATING.md section and
the delete/bulk_delete/import OpenAPI docstrings predated the flag-accuracy
standard established on apache#40128/apache#40129 and described soft delete as
unconditional — false on default deployments, where SOFT_DELETE is off and
DELETE hard-deletes permanently.

- UPDATING.md now leads with the flag gate + default, documents the
  flag-toggle caveat, and adds an explicit list of the flag-INDEPENDENT
  parts (restore endpoint, deleted-state filter, DB-deletion guard,
  get_or_create pre-check, the uniqueness changes) versus the flag-gated
  soft DELETE itself.
- Added the migration-downgrade consequence (dropping deleted_at silently
  activates un-restored trash; reconcile first, flag off first) and a
  SQL Lab / dataset-creation note describing which flows return the
  targeted hidden-twin 422 (get_or_create, upload) versus the generic
  "already exists" (plain create/update/duplicate), plus the perm-string
  maintenance guarantee on database rename.
- Importer paragraph and import_ docstring state the deliberate
  restore-with-update-without-overwrite asymmetry, matching the siblings.
- delete/bulk_delete docstrings + summaries flag-qualified.
…rced messages, shared helpers

Six coordinated fixes from the 8-lens review panel:

1. Importer overwrite path now re-validates the incoming identity via
   validate_update_uniqueness (mirroring UpdateDatasetCommand): an uploaded
   config can no longer rename an alive dataset onto a soft-deleted twin's
   physical identity — which import_from_dict cannot see — silently
   squatting it and permanently blocking the trash row's restore.

2. Restore-via-import probes the POST-update identity from the config
   (has_active_logical_duplicate gained an optional identity override)
   instead of the stale stored identity, so a rename onto an active
   dataset is refused up front (row stays soft-deleted) rather than
   falling through to MultipleResultsFound; that fallback message also no
   longer asserts a NULL-schema cause it hasn't established.

3. The hidden-twin blocking policy is single-sourced: a new typed
   DatasetSoftDeletedTwinExistsError (422, message defined once) raised
   from CreateDatasetCommand.validate. The plain POST /api/v1/dataset/
   now gets the targeted uuid+restore message instead of the opaque
   "already exists", and get_or_create_dataset's view-level pre-check is
   replaced by the command path (Table import dropped).

4. All importer restore/overwrite errors carry the entity identity
   (name + uuid), adopting the charts identity-bearing form as canonical.

5. Messages name only executable recoveries: dropped "hard-delete it"
   (no such API surface) and reworded the database-delete blocker's
   "permanently purge them first".

6. validate_update_uniqueness collapsed to a one-line delegate of
   validate_uniqueness (rationale lives once); DatasetDeletedStateFilter
   reuses BaseDeletedStateFilter._normalize instead of re-inlining the
   expression the helper exists to prevent drifting from.

New tests pin the create-command twin block (typed 422, executable
recoveries only), the overwrite rename block, and the incoming-identity
restore probe (both importer tests fail without the fixes); two existing
message asserts updated. 905 tests green across the affected suites.
…ate audience scoping

Three approved cross-entity review decisions:

1. Restore-audience ownership leg. An owner who has lost (or never had)
   a datasource grant could neither enumerate nor restore their own
   soft-deleted dataset — a 404 on restore and an empty trash list —
   while the docs said "owner or admin" and raise_for_access counts
   ownership AS datasource access. Two changes close the inconsistency:
   BaseRestoreCommand's lookup adds skip_base_filter=True (the restore
   audience is enforced by raise_for_ownership — owners or admin;
   non-owners holding a valid uuid now get 403 rather than 404), and
   DatasourceFilter gains an owned-soft-deleted-rows leg (inert for
   live rows, and only ever reachable when a deleted-state rison filter
   has opted the request into seeing soft-deleted rows). The dashboards
   access filter already had an ownership criterion; charts parity
   (ChartFilter) follows once apache#40129 is on master.

2. Deleted-state owner-scoping hoisted into BaseDeletedStateFilter.
   The 30-line restore-audience body was a verbatim copy in the merged
   dashboards filter and the datasets filter, with charts carrying a
   third. It lives once on the base (_scope_to_restore_audience);
   both subclasses shrink to pure declarations. Entities without an
   owners relationship opt out automatically, which also keeps the
   synthetic-model unit tests meaningful.

3. The combined-datasource listing's always-on deleted_at filter
   (daos/datasource.py) is documented as deliberately flag-independent
   (code comment + UPDATING.md's flag-independent list) rather than
   gated: always-hiding is the safer failure mode for that aggregate
   endpoint.

Tests: the base-restore contract test now pins BOTH bypasses (it
previously pinned base_filter NOT being skipped — the deliberate
reversal); the dataset restore happy-path pins the same call shape; a
new integration test proves a gamma owner with no datasource grant can
enumerate their own trash via include/only while NOT seeing the row
when it is live (the leg must not leak live rows).
…wire

Review-panel test tier:

- Three tests had success-path-only cleanup of soft-deleted state; a
  failed assertion would strand a soft-deleted row (one of them the
  SHARED example dataset) and cascade failures through later suites.
  All three now clean up in finally, matching the module's own
  _restore_dataset contract.

- New flag-OFF contract test: with SOFT_DELETE disabled (the default),
  DELETE physically removes the row (a bypass query finds nothing) and
  restore 404s. Every other delete-path test runs flag-ON, so the path
  every default deployment runs was the untested one.

- Bypass tripwire for has_active_logical_duplicate: the helper's
  active-rows-only semantics forbid skip_visibility_filter (adding it
  would silently refuse legitimate restores of legacy twin pairs), but
  the suite stayed green under that mutation. The new test spies the
  bypass and fails if it is ever entered — validated by applying the
  mutation (test fails) and reverting (suite green).

Also de-staled the PR body: the merge-ordering warning claimed
soft-delete ships unconditional (the SOFT_DELETE gate is in the merge
base), and QA step 11 asserted flag-off restore inertness, contradicting
the deliberate cross-entity rollback contract.
…logs

Proactive port of @rusackas's pre-merge finding on the charts sibling
(apache#40129): this branch's new _()-wrapped messages were missing from
messages.pot and every messages.po. Added to all 30 catalogs with empty
msgstrs at alphabetical positions:

- the soft-deleted-twin 422 (create path) and its upload-path variant,
  both flagged python-format for the %(uuid)s parameter
- the restore blocked-by-active-duplicate message
- the database-delete blocked-by-soft-deleted-datasets message

Every catalog validated to parse, carry byte-exact msgids matching the
code strings, and carry the python-format flags.
… head

Master's migration head moved to b4a3f2e1d0c9 (apache#40527); chaining off it
restores the single-head invariant. Note: if the charts sibling (apache#40129)
merges first, this needs one final re-point onto 7c4a8d09ca37. No schema
change.
Charts soft-delete (add_deleted_at_to_slices, 7c4a8d09ca37) merged and
is now the master head. add_deleted_at_to_tables still pointed at the
prior head (b4a3f2e1d0c9), which is now a mid-chain ancestor, so it
forked a second alembic head. Re-point down_revision (and the Revises
header) to 7c4a8d09ca37 so the branch presents a single head.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Address substantive automated-review findings on the soft-delete PR:

- MCP create_dataset / create_virtual_dataset caught only DatasetInvalidError,
  but validate() raises DatasetSoftDeletedTwinExistsError directly (a top-level
  CommandException), so the twin case fell through to the generic "unexpected
  error" branch instead of the actionable restore-or-rename message the REST
  API returns. Catch it explicitly in both tools.
- Tighten the database-delete test to assert the specific
  DatabaseDeleteSoftDeletedDatasetsExistFailedError, and add a live-dataset test
  pinning that the live branch raises the base error (not the subclass).
- Document that the active-duplicate DAO read is a best-effort friendly-error
  check; the DB UniqueConstraint (database_id, catalog, schema, table_name) is
  the real guard against a concurrent duplicate create.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
test_restore_soft_deleted_dataset's finally called self._restore_dataset,
but _restore_dataset is a method only on TestDatasetSoftDelete — on
TestDatasetRestore it raised AttributeError, failing the test (and leaving
the shared example dataset soft-deleted) across all DB suites. Call the
module-level _restore_dataset helper directly, matching the sibling test
in the same class.

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

Copy link
Copy Markdown
Contributor Author

Thanks all for the earlier reviews — this PR's head was refreshed and is green and ready for a final look:

  • Rebased onto current master after charts soft-delete (feat(charts): soft-delete and restore #40129) merged, and re-pointed the add_deleted_at_to_tables migration onto the new head so the branch presents a single alembic head (no dangling migration head).
  • Addressed the outstanding automated-review comments: the MCP create_dataset / create_virtual_dataset tools now handle the soft-deleted-twin case explicitly (actionable restore-or-rename message rather than a generic error); tightened the database-delete tests to assert the specific DatabaseDeleteSoftDeletedDatasetsExistFailedError (+ a live-dataset branch test); and documented that the DB UniqueConstraint is the real guard on the duplicate-check path. All review threads are resolved.
  • Fixed a test-cleanup bug (_restore_dataset was called on the wrong class in one finally) that had been failing the DB suites; test-postgres / test-mysql / test-sqlite are now green.

No behavior/scope change — same soft-delete + restore, gated behind SOFT_DELETE. @eschutho (or any migrations code owner) — would appreciate a re-approve on the current head when you have a moment. 🙏

This comment was generated by Claude (AI) on behalf of @mikebridge.

@rusackas rusackas left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

LGTM, approving. Pulled the branch to double-check my earlier asks rather than taking the thread's word for it... the duplicate command now carries catalog, the migration chain is single-headed on current master (no new migrations landed since the rebase), and the PR's 116 unit tests pass locally.

On the one-way door: with the distinct DatabaseDeleteSoftDeletedDatasetsExistFailedError message and the UPDATING.md note, I'm comfortable shipping it ahead of the purge work. And since the whole thing ships dark behind SOFT_DELETE and #40129 already set the precedent on master, I don't think we need hold:next-major after all.

Thanks for the marathon here @mikebridge.

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 change:backend Requires changing the backend data:dataset Related to dataset configurations i18n:brazilian i18n:chinese Translation related to Chinese language i18n:czech i18n:dutch i18n:french Translation related to French language i18n:italian Translation related to Italian language i18n:japanese Translation related to Japanese language i18n:korean Translation related to Korean language i18n:latvian i18n:persian i18n:portuguese i18n:russian Translation related to Russian language i18n:slovak i18n:spanish Translation related to Spanish language i18n:traditional-chinese i18n:ukrainian i18n Namespace | Anything related to localization risk:db-migration PRs that require a DB migration size/XXL

Projects

None yet

Development

Successfully merging this pull request may close these issues.

6 participants