This is an automated email from the ASF dual-hosted git repository.
eschutho pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/superset.git
The following commit(s) were added to refs/heads/master by this push:
new 940b670636e feat(versioning): version-restore engine and endpoints for
charts, dashboards, and datasets (#42469)
940b670636e is described below
commit 940b670636e4360423035c1a57322fcdf4d018c3
Author: Mike Bridge <[email protected]>
AuthorDate: Wed Jul 29 17:54:37 2026 +0100
feat(versioning): version-restore engine and endpoints for charts,
dashboards, and datasets (#42469)
Co-authored-by: Mike Bridge <[email protected]>
Co-authored-by: Claude Opus 4.8 <[email protected]>
---
superset/charts/api.py | 68 +++
superset/commands/chart/restore_version.py | 44 ++
superset/commands/dashboard/restore_version.py | 45 ++
superset/commands/dataset/restore_version.py | 41 ++
superset/commands/version_restore.py | 152 ++++++
superset/daos/version.py | 18 +-
superset/dashboards/api.py | 72 +++
superset/datasets/api.py | 72 +++
superset/initialization/__init__.py | 7 +-
superset/versioning/api_helpers.py | 72 ++-
superset/versioning/baseline/__init__.py | 2 +
superset/versioning/baseline/shadow.py | 5 +
superset/versioning/changes/shadow_queries.py | 11 +-
superset/versioning/queries.py | 43 +-
superset/versioning/restore.py | 264 +++++++++++
superset/versioning/utils.py | 53 +++
.../charts/version_restore_tests.py | 456 ++++++++++++++++++
.../dashboards/version_restore_tests.py | 418 +++++++++++++++++
.../datasets/version_restore_tests.py | 507 +++++++++++++++++++++
tests/unit_tests/versioning/test_restore.py | 133 ++++++
20 files changed, 2455 insertions(+), 28 deletions(-)
diff --git a/superset/charts/api.py b/superset/charts/api.py
index 137c6dfac6e..9ddada7aaf7 100644
--- a/superset/charts/api.py
+++ b/superset/charts/api.py
@@ -111,6 +111,7 @@ from superset.versioning.api_helpers import (
current_entity_version_info,
get_version_endpoint,
list_versions_endpoint,
+ restore_version_endpoint,
)
from superset.versioning.etag import set_version_etag
from superset.versioning.schemas import VersionListItemSchema
@@ -159,6 +160,7 @@ class ChartRestApi(SoftDeleteApiMixin,
BaseSupersetModelRestApi):
"list_versions",
"get_version",
"activity",
+ "restore_version",
}
class_permission_name = "Chart"
# Custom methods (``restore``) need an explicit entry; FAB's @protect()
@@ -171,6 +173,7 @@ class ChartRestApi(SoftDeleteApiMixin,
BaseSupersetModelRestApi):
method_permission_name = {
**MODEL_API_RW_METHOD_PERMISSION_MAP,
"restore": "write",
+ "restore_version": "write",
}
list_columns = [
@@ -1604,3 +1607,68 @@ class ChartRestApi(SoftDeleteApiMixin,
BaseSupersetModelRestApi):
from superset.versioning.activity import activity_endpoint
return activity_endpoint(self, Slice, uuid_str, request.args)
+
+ @expose(
+ "/<uuid_str>/versions/<version_uuid_str>/restore",
+ methods=("POST",),
+ )
+ @protect()
+ @safe
+ @statsd_metrics
+ @event_logger.log_this_with_context(
+ action=lambda self, *args, **kwargs: (
+ f"{self.__class__.__name__}.restore_version"
+ ),
+ log_to_statsd=False,
+ )
+ def restore_version(self, uuid_str: str, version_uuid_str: str) ->
Response:
+ """Restore a chart to a previous version.
+ ---
+ post:
+ summary: Revert a chart to an earlier version (non-destructive)
+ parameters:
+ - in: path
+ schema:
+ type: string
+ format: uuid
+ name: uuid_str
+ description: Chart UUID
+ - in: path
+ schema:
+ type: string
+ format: uuid
+ name: version_uuid_str
+ description: >-
+ Version UUID as returned by the list-versions endpoint.
+ Stable across retention pruning.
+ responses:
+ 200:
+ description: Chart was restored
+ content:
+ application/json:
+ schema:
+ type: object
+ properties:
+ message:
+ type: string
+ 400:
+ $ref: '#/components/responses/400'
+ 401:
+ $ref: '#/components/responses/401'
+ 403:
+ $ref: '#/components/responses/403'
+ 404:
+ $ref: '#/components/responses/404'
+ 422:
+ $ref: '#/components/responses/422'
+ """
+ # pylint: disable=import-outside-toplevel
+ # Local import: the command module transitively imports the
+ # versioning bootstrap graph; see changes/listener.py.
+ from superset.commands.chart.restore_version import (
+ RestoreChartVersionCommand,
+ )
+
+ return restore_version_endpoint(
+ self, Slice, RestoreChartVersionCommand, uuid_str, version_uuid_str
+ )
diff --git a/superset/commands/chart/restore_version.py
b/superset/commands/chart/restore_version.py
new file mode 100644
index 00000000000..a7b011e2d6b
--- /dev/null
+++ b/superset/commands/chart/restore_version.py
@@ -0,0 +1,44 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
+"""Command that restores a chart to a previous version."""
+
+from __future__ import annotations
+
+from superset.commands.chart.exceptions import (
+ ChartForbiddenError,
+ ChartNotFoundError,
+ ChartUpdateFailedError,
+)
+from superset.commands.version_restore import BaseRestoreVersionCommand
+from superset.models.slice import Slice
+
+
+class RestoreChartVersionCommand(BaseRestoreVersionCommand):
+ """Revert a chart to a previous version.
+
+ The restore is non-destructive: it produces a new version row (authored
+ by the restoring user), so prior versions remain in the history and the
+ change is itself reversible. The base builds the ``@transaction``
+ boundary from :attr:`failed_exc`, binding the commit that fires
+ Continuum's ``after_flush`` hook — the one that writes the new version
+ row — to this command's lifecycle.
+ """
+
+ model_cls = Slice
+ not_found_exc = ChartNotFoundError
+ forbidden_exc = ChartForbiddenError
+ failed_exc = ChartUpdateFailedError
diff --git a/superset/commands/dashboard/restore_version.py
b/superset/commands/dashboard/restore_version.py
new file mode 100644
index 00000000000..07e1c546362
--- /dev/null
+++ b/superset/commands/dashboard/restore_version.py
@@ -0,0 +1,45 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
+"""Command that restores a dashboard to a previous version."""
+
+from __future__ import annotations
+
+from superset.commands.dashboard.exceptions import (
+ DashboardForbiddenError,
+ DashboardNotFoundError,
+ DashboardUpdateFailedError,
+)
+from superset.commands.version_restore import BaseRestoreVersionCommand
+from superset.models.dashboard import Dashboard
+
+
+class RestoreDashboardVersionCommand(BaseRestoreVersionCommand):
+ """Revert a dashboard to a previous version.
+
+ Restores the dashboard's own fields and its chart *membership* — which
+ charts sit on it — reattaching only charts that still exist (snapshot
+ members deleted since the snapshot stay deleted and are reported as
+ skipped). Member charts' content is never modified; restoring a
+ chart's content is the chart restore endpoint's job. See
+ :class:`superset.commands.version_restore.BaseRestoreVersionCommand`
+ for the general contract.
+ """
+
+ model_cls = Dashboard
+ not_found_exc = DashboardNotFoundError
+ forbidden_exc = DashboardForbiddenError
+ failed_exc = DashboardUpdateFailedError
diff --git a/superset/commands/dataset/restore_version.py
b/superset/commands/dataset/restore_version.py
new file mode 100644
index 00000000000..045885baeef
--- /dev/null
+++ b/superset/commands/dataset/restore_version.py
@@ -0,0 +1,41 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
+"""Command that restores a dataset (and its columns/metrics) to a
+previous version."""
+
+from __future__ import annotations
+
+from superset.commands.dataset.exceptions import (
+ DatasetForbiddenError,
+ DatasetNotFoundError,
+ DatasetUpdateFailedError,
+)
+from superset.commands.version_restore import BaseRestoreVersionCommand
+from superset.connectors.sqla.models import SqlaTable
+
+
+class RestoreDatasetVersionCommand(BaseRestoreVersionCommand):
+ """Revert a dataset (and its columns + metrics — the aggregate's own
+ parts) to a previous version. See
+ :class:`superset.commands.chart.restore_version.RestoreChartVersionCommand`
+ for the general contract.
+ """
+
+ model_cls = SqlaTable
+ not_found_exc = DatasetNotFoundError
+ forbidden_exc = DatasetForbiddenError
+ failed_exc = DatasetUpdateFailedError
diff --git a/superset/commands/version_restore.py
b/superset/commands/version_restore.py
new file mode 100644
index 00000000000..93bb7cb3a62
--- /dev/null
+++ b/superset/commands/version_restore.py
@@ -0,0 +1,152 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
+"""Shared base for the per-entity restore-version commands.
+
+The three concrete commands (:mod:`superset.commands.chart.restore_version`,
+:mod:`superset.commands.dashboard.restore_version`,
+:mod:`superset.commands.dataset.restore_version`) differ only in:
+
+* the model class they operate on
+* the per-entity ``NotFoundError`` / ``ForbiddenError`` / ``UpdateFailedError``
+ triplet they raise
+
+Everything else — capture gate, lookup, editorship check, version-uuid
+resolution, action-kind stamping, restore dispatch, transactional
+boundary — lives here. Subclasses are pure declarations: the base builds
+the ``@transaction`` wrapper at call time from the ``failed_exc``
+ClassVar (mirroring ``BaseRestoreCommand``), so a new entity rollout
+cannot forget the decorator.
+"""
+
+from __future__ import annotations
+
+from functools import partial
+from typing import Any, ClassVar
+from uuid import UUID
+
+from superset import security_manager
+from superset.commands.base import BaseCommand
+from superset.exceptions import SupersetSecurityException
+from superset.extensions import db
+from superset.utils.decorators import on_error, transaction
+from superset.versioning.queries import find_active_by_uuid, resolve_version
+from superset.versioning.restore import restore_version, RestoreResult
+from superset.versioning.utils import capture_enabled
+
+
+class BaseRestoreVersionCommand(BaseCommand):
+ """Workflow for a non-destructive version restore on one entity.
+
+ Subclasses declare the model class plus the three entity-specific
+ exception ClassVars; the base owns the workflow and the transactional
+ boundary.
+ """
+
+ #: Subclass overrides — the versioned model class (``Slice`` /
+ #: ``Dashboard`` / ``SqlaTable``).
+ model_cls: ClassVar[type]
+
+ #: Subclass overrides — exception classes raised on the matching
+ #: failure modes. ``not_found_exc`` covers "no such entity",
+ #: "version_uuid not on this entity", and "capture disabled" (the
+ #: route is inert under the kill-switch); the API handler maps each
+ #: to HTTP 404. ``forbidden_exc`` covers the row-level editorship
+ #: denial (HTTP 403). ``failed_exc`` wraps unexpected failures inside
+ #: the transaction (HTTP 422).
+ not_found_exc: ClassVar[type[Exception]]
+ forbidden_exc: ClassVar[type[Exception]]
+ failed_exc: ClassVar[type[Exception]]
+
+ def __init__(self, entity_uuid: UUID, version_uuid: UUID) -> None:
+ self._uuid = entity_uuid
+ self._version_uuid = version_uuid
+
+ def run(self) -> RestoreResult:
+ # Build the transactional wrapper at call time so ``on_error`` can
+ # reference ``self.failed_exc`` — a per-subclass ClassVar that
+ # isn't available when this method is defined on the base (same
+ # pattern and rationale as ``BaseRestoreCommand.run``).
+ @transaction(on_error=partial(on_error, reraise=self.failed_exc))
+ def _perform() -> RestoreResult:
+ return self._do_restore()
+
+ return _perform()
+
+ def _do_restore(self) -> RestoreResult:
+ entity = self.validate()
+ resolved = resolve_version(
+ self.model_cls, self._uuid, self._version_uuid, entity=entity
+ )
+ if resolved is None:
+ raise self.not_found_exc()
+ version_number, transaction_id = resolved
+
+ # Stamp the transaction so the change-record listener writes
+ # ``action_kind='restore'`` and the ``__meta__`` headline — the
+ # activity feed renders the restore as "Restored to version N",
+ # not an ordinary save. Contract documented in
+ # ``versioning/changes/listener.py`` (import/clone stamp the same
+ # way); the listener pops both keys after use.
+ # pylint: disable=import-outside-toplevel
+ # Local import: the changes package bootstraps the versioning
+ # listener graph; see its module docstring for the init-order
+ # rationale.
+ from superset.versioning.changes import (
+ ACTION_KIND_KEY,
+ ACTION_KIND_RESTORE,
+ ACTION_META_KEY,
+ build_action_headline,
+ ENTITY_KIND_BY_CLASS_NAME,
+ )
+
+ db.session.info[ACTION_KIND_KEY] = ACTION_KIND_RESTORE
+ entity_kind = ENTITY_KIND_BY_CLASS_NAME.get(self.model_cls.__name__)
+ if entity_kind is not None:
+ db.session.info[ACTION_META_KEY] = build_action_headline(
+ entity_kind,
+ entity.id,
+ {
+ "version_uuid": str(self._version_uuid),
+ "version_number": version_number,
+ },
+ )
+
+ result = restore_version(
+ self.model_cls, self._uuid, transaction_id, entity=entity
+ )
+ if result is None:
+ # Race: entity deleted, or the target version row pruned,
+ # between validate()/resolve and the engine's re-check.
+ raise self.not_found_exc()
+ return result
+
+ def validate(self) -> Any:
+ # With capture off, Continuum's write listeners are detached: a
+ # revert would mutate the live entity with NO new version row —
+ # a destructive, untracked write. The whole restore surface is
+ # therefore inert under the kill-switch, matching the read-side
+ # convention (404, indistinguishable from "no such version").
+ if not capture_enabled():
+ raise self.not_found_exc()
+ entity = find_active_by_uuid(self.model_cls, self._uuid)
+ if entity is None:
+ raise self.not_found_exc()
+ try:
+ security_manager.raise_for_editorship(entity)
+ except SupersetSecurityException as ex:
+ raise self.forbidden_exc() from ex
+ return entity
diff --git a/superset/daos/version.py b/superset/daos/version.py
index f73c7b4e347..23d2f555a6a 100644
--- a/superset/daos/version.py
+++ b/superset/daos/version.py
@@ -16,12 +16,12 @@
# under the License.
"""Backward-compat façade for the entity-versioning DAO surface.
-The actual implementation lives in :mod:`superset.versioning.queries`
-(read side: list/get/resolve/find/UUID derivation). This module
-re-exports it under a single ``VersionDAO`` class plus the module-level
-UUID helpers so existing callers keep working without changes. (The
-write side — restore + audit stamping — ships in a later PR; only the
-read surface is wired here.)
+The read side lives in :mod:`superset.versioning.queries`
+(list/get/resolve/find/UUID derivation) and the write side in
+:mod:`superset.versioning.restore` (non-destructive version restore).
+This module re-exports both under a single ``VersionDAO`` class plus the
+module-level UUID helpers so existing callers keep working without
+changes.
New code should import from the versioning sub-modules directly.
"""
@@ -38,9 +38,11 @@ from superset.versioning.queries import (
get_version,
list_change_records_batch,
list_versions,
+ resolve_version,
resolve_version_uuid,
VERSION_UUID_NAMESPACE,
)
+from superset.versioning.restore import restore_version
# Re-exports for ``from superset.daos.version import …`` consumers.
__all__ = [
@@ -65,5 +67,9 @@ class VersionDAO:
current_live_version_uuid = staticmethod(current_live_version_uuid)
list_change_records_batch = staticmethod(list_change_records_batch)
list_versions = staticmethod(list_versions)
+ resolve_version = staticmethod(resolve_version)
resolve_version_uuid = staticmethod(resolve_version_uuid)
get_version = staticmethod(get_version)
+
+ # --- write side (restore.py) ------------------------------------------
+ restore_version = staticmethod(restore_version)
diff --git a/superset/dashboards/api.py b/superset/dashboards/api.py
index 5a138d0d401..4061ddb4aca 100644
--- a/superset/dashboards/api.py
+++ b/superset/dashboards/api.py
@@ -168,6 +168,7 @@ from superset.versioning.api_helpers import (
current_entity_version_info,
get_version_endpoint,
list_versions_endpoint,
+ restore_version_endpoint,
)
from superset.versioning.etag import set_version_etag
from superset.versioning.schemas import VersionListItemSchema
@@ -291,6 +292,7 @@ class DashboardRestApi(
"list_versions",
"get_version",
"activity",
+ "restore_version",
}
resource_name = "dashboard"
allow_browser_login = True
@@ -306,6 +308,7 @@ class DashboardRestApi(
method_permission_name = {
**MODEL_API_RW_METHOD_PERMISSION_MAP,
"restore": "write",
+ "restore_version": "write",
# Reuse the dashboard ``can_export`` permission (the frontend gates the
# menu item on it) instead of the ``can_export_xlsx`` FAB would
otherwise
# derive from the method name.
@@ -2774,3 +2777,72 @@ class DashboardRestApi(
from superset.versioning.activity import activity_endpoint
return activity_endpoint(self, Dashboard, uuid_str, request.args)
+
+ @expose(
+ "/<uuid_str>/versions/<version_uuid_str>/restore",
+ methods=("POST",),
+ )
+ @protect()
+ @safe
+ @statsd_metrics
+ @event_logger.log_this_with_context(
+ action=lambda self, *args, **kwargs: (
+ f"{self.__class__.__name__}.restore_version"
+ ),
+ log_to_statsd=False,
+ )
+ def restore_version(self, uuid_str: str, version_uuid_str: str) ->
Response:
+ """Restore a dashboard to a previous version.
+ ---
+ post:
+ summary: Revert a dashboard to an earlier version (non-destructive)
+ parameters:
+ - in: path
+ schema:
+ type: string
+ format: uuid
+ name: uuid_str
+ description: Dashboard UUID
+ - in: path
+ schema:
+ type: string
+ format: uuid
+ name: version_uuid_str
+ description: >-
+ Version UUID as returned by the list-versions endpoint.
+ Stable across retention pruning.
+ responses:
+ 200:
+ description: Dashboard was restored
+ content:
+ application/json:
+ schema:
+ type: object
+ properties:
+ message:
+ type: string
+ 400:
+ $ref: '#/components/responses/400'
+ 401:
+ $ref: '#/components/responses/401'
+ 403:
+ $ref: '#/components/responses/403'
+ 404:
+ $ref: '#/components/responses/404'
+ 422:
+ $ref: '#/components/responses/422'
+ """
+ # pylint: disable=import-outside-toplevel
+ # Local import: the command module transitively imports the
+ # versioning bootstrap graph; see changes/listener.py.
+ from superset.commands.dashboard.restore_version import (
+ RestoreDashboardVersionCommand,
+ )
+
+ return restore_version_endpoint(
+ self,
+ Dashboard,
+ RestoreDashboardVersionCommand,
+ uuid_str,
+ version_uuid_str,
+ )
diff --git a/superset/datasets/api.py b/superset/datasets/api.py
index 46be170e868..3ee633f94f1 100644
--- a/superset/datasets/api.py
+++ b/superset/datasets/api.py
@@ -97,6 +97,7 @@ from superset.versioning.api_helpers import (
current_entity_version_info,
get_version_endpoint,
list_versions_endpoint,
+ restore_version_endpoint,
)
from superset.versioning.etag import set_version_etag
from superset.versioning.schemas import VersionListItemSchema
@@ -135,6 +136,7 @@ class DatasetRestApi(SoftDeleteApiMixin,
BaseSupersetModelRestApi):
method_permission_name = {
**MODEL_API_RW_METHOD_PERMISSION_MAP,
"restore": "write",
+ "restore_version": "write",
}
include_route_methods = RouteMethod.REST_MODEL_VIEW_CRUD_SET | {
RouteMethod.EXPORT,
@@ -152,6 +154,7 @@ class DatasetRestApi(SoftDeleteApiMixin,
BaseSupersetModelRestApi):
"list_versions",
"get_version",
"activity",
+ "restore_version",
}
list_columns = [
"id",
@@ -1952,3 +1955,72 @@ class DatasetRestApi(SoftDeleteApiMixin,
BaseSupersetModelRestApi):
from superset.versioning.activity import activity_endpoint
return activity_endpoint(self, SqlaTable, uuid_str, request.args)
+
+ @expose(
+ "/<uuid_str>/versions/<version_uuid_str>/restore",
+ methods=("POST",),
+ )
+ @protect()
+ @safe
+ @statsd_metrics
+ @event_logger.log_this_with_context(
+ action=lambda self, *args, **kwargs: (
+ f"{self.__class__.__name__}.restore_version"
+ ),
+ log_to_statsd=False,
+ )
+ def restore_version(self, uuid_str: str, version_uuid_str: str) ->
Response:
+ """Restore a dataset to a previous version.
+ ---
+ post:
+ summary: Revert a dataset to an earlier version (non-destructive)
+ parameters:
+ - in: path
+ schema:
+ type: string
+ format: uuid
+ name: uuid_str
+ description: Dataset UUID
+ - in: path
+ schema:
+ type: string
+ format: uuid
+ name: version_uuid_str
+ description: >-
+ Version UUID as returned by the list-versions endpoint.
+ Stable across retention pruning.
+ responses:
+ 200:
+ description: Dataset was restored
+ content:
+ application/json:
+ schema:
+ type: object
+ properties:
+ message:
+ type: string
+ 400:
+ $ref: '#/components/responses/400'
+ 401:
+ $ref: '#/components/responses/401'
+ 403:
+ $ref: '#/components/responses/403'
+ 404:
+ $ref: '#/components/responses/404'
+ 422:
+ $ref: '#/components/responses/422'
+ """
+ # pylint: disable=import-outside-toplevel
+ # Local import: the command module transitively imports the
+ # versioning bootstrap graph; see changes/listener.py.
+ from superset.commands.dataset.restore_version import (
+ RestoreDatasetVersionCommand,
+ )
+
+ return restore_version_endpoint(
+ self,
+ SqlaTable,
+ RestoreDatasetVersionCommand,
+ uuid_str,
+ version_uuid_str,
+ )
diff --git a/superset/initialization/__init__.py
b/superset/initialization/__init__.py
index 36a5fc31bd8..9bd94bd54ef 100644
--- a/superset/initialization/__init__.py
+++ b/superset/initialization/__init__.py
@@ -788,8 +788,11 @@ class SupersetAppInitializer: # pylint:
disable=too-many-public-methods
"versioning: ENABLE_VERSIONING_CAPTURE is False; "
"skipping baseline + change-record listener registration "
"and detaching Continuum's write listeners. Save-path "
- "capture is disabled; existing shadow tables and "
- "/versions/ endpoints continue to work read-only."
+ "capture is disabled; existing shadow tables and the "
+ "read-side /versions/ endpoints continue to work "
+ "read-only, and the version-restore endpoints refuse "
+ "with 404 (a restore without capture would be a "
+ "destructive, untracked write)."
)
self._remove_continuum_write_listeners()
return
diff --git a/superset/versioning/api_helpers.py
b/superset/versioning/api_helpers.py
index 705bb2f46a1..72fd6f4627b 100644
--- a/superset/versioning/api_helpers.py
+++ b/superset/versioning/api_helpers.py
@@ -25,18 +25,21 @@ lets each per-resource method collapse to a single
delegation call, while
the OpenAPI docstring + FAB decorators stay at the method site where they
belong.
-(The restore endpoint ships in a later PR; only the read + activity
-endpoints are wired here.)
+The write side follows the same pattern: ``restore_version_endpoint``
+holds the shared body of the three ``POST .../versions/<uuid>/restore``
+routes; authorization and the capture kill-switch gate live in the
+restore command's ``validate()``, not here.
"""
from __future__ import annotations
+import logging
from dataclasses import dataclass
from typing import Any
from uuid import UUID
import sqlalchemy as sa
-from flask import current_app, Response
+from flask import Response
from flask_appbuilder import Model
from superset.daos.version import VersionDAO
@@ -53,6 +56,8 @@ from superset.versioning.schemas import VersionListItemSchema
#: carry UUID instances, the snapshot block pre-stringifies).
_version_item_schema = VersionListItemSchema()
+logger = logging.getLogger(__name__)
+
@dataclass
class EntityVersionInfo:
@@ -70,7 +75,13 @@ class EntityVersionInfo:
def _capture_enabled() -> bool:
- return bool(current_app.config.get("ENABLE_VERSIONING_CAPTURE", False))
+ # Delegates to the shared gate so the read helpers and the restore
+ # command can't disagree about what "capture is on" means.
+ from superset.versioning.utils import ( # pylint:
disable=import-outside-toplevel
+ capture_enabled,
+ )
+
+ return capture_enabled()
def current_entity_version_info(
@@ -264,3 +275,56 @@ def get_version_endpoint(
entity_uuid,
entity_id=entity.id,
)
+
+
+def restore_version_endpoint(
+ api: Any,
+ model_cls: type[Model],
+ command_cls: type[Any],
+ uuid_str: str,
+ version_uuid_str: str,
+) -> Response:
+ """Body of ``POST
/api/v1/{resource}/<uuid>/versions/<version_uuid>/restore``.
+
+ *command_cls* is the entity's ``BaseRestoreVersionCommand`` subclass;
+ its ``not_found_exc`` / ``forbidden_exc`` / ``failed_exc`` ClassVars
+ drive the exception→HTTP mapping, so this body stays generic.
+ Authorization and the ``ENABLE_VERSIONING_CAPTURE`` kill-switch gate
+ live in the command's ``validate()`` — with capture off the route is
+ inert (404) because a revert without Continuum's write listeners
+ would be a destructive, untracked write.
+ """
+ try:
+ entity_uuid = UUID(uuid_str)
+ except ValueError:
+ return api.response_400(message="Invalid UUID")
+ try:
+ version_uuid = UUID(version_uuid_str)
+ except ValueError:
+ return api.response_400(message="Invalid version UUID")
+
+ try:
+ result = command_cls(entity_uuid, version_uuid).run()
+ except command_cls.not_found_exc:
+ return api.response_404()
+ except command_cls.forbidden_exc:
+ return api.response_403()
+ except command_cls.failed_exc as ex:
+ logger.exception("Error restoring %s version", model_cls.__name__)
+ return api.response_422(message=str(ex))
+
+ message = "OK"
+ if result.skipped_slice_ids:
+ message = (
+ f"OK; {len(result.skipped_slice_ids)} chart(s) referenced by "
+ "the snapshot no longer exist and were not reattached"
+ )
+ return set_version_etag_by_uuid(
+ api.response(200, message=message),
+ model_cls,
+ entity_uuid,
+ # The command already loaded the entity; passing its id skips the
+ # extra id-by-uuid SELECT (same optimization as the sibling
+ # list/get endpoints).
+ entity_id=result.entity.id,
+ )
diff --git a/superset/versioning/baseline/__init__.py
b/superset/versioning/baseline/__init__.py
index 664af88c4cb..7c1acaee6b0 100644
--- a/superset/versioning/baseline/__init__.py
+++ b/superset/versioning/baseline/__init__.py
@@ -57,10 +57,12 @@ from superset.versioning.baseline.listener import
register_baseline_listener
from superset.versioning.baseline.shadow import (
CONTINUUM_BOOKKEEPING_COLUMNS,
insert_baseline_shadow_row,
+ OPERATION_DELETE,
)
__all__ = [
"CONTINUUM_BOOKKEEPING_COLUMNS",
+ "OPERATION_DELETE",
"VERSIONED_MODELS",
"child_to_parent_registry",
"insert_baseline_shadow_row",
diff --git a/superset/versioning/baseline/shadow.py
b/superset/versioning/baseline/shadow.py
index 49d5980467a..0656cb72900 100644
--- a/superset/versioning/baseline/shadow.py
+++ b/superset/versioning/baseline/shadow.py
@@ -46,6 +46,11 @@ CONTINUUM_BOOKKEEPING_COLUMNS: frozenset[str] = frozenset(
{"transaction_id", "end_transaction_id", "operation_type"}
)
+#: Continuum ``operation_type`` code for a DELETE version row. Shared by
+#: every validity-window predicate (a DELETE shadow row is never "live at
+#: tx") and by the restore engine's refuse-DELETE-target guard.
+OPERATION_DELETE: int = 2
+
def insert_baseline_shadow_row(
conn: Any,
diff --git a/superset/versioning/changes/shadow_queries.py
b/superset/versioning/changes/shadow_queries.py
index 1f6a290007f..250fa5c1fc5 100644
--- a/superset/versioning/changes/shadow_queries.py
+++ b/superset/versioning/changes/shadow_queries.py
@@ -37,7 +37,10 @@ from typing import Any
import sqlalchemy as sa
from sqlalchemy.orm import Session
-from superset.versioning.baseline import CONTINUUM_BOOKKEEPING_COLUMNS
+from superset.versioning.baseline import (
+ CONTINUUM_BOOKKEEPING_COLUMNS,
+ OPERATION_DELETE,
+)
from superset.versioning.changes.state import jsonable
from superset.versioning.diff import (
ChangeRecord,
@@ -76,7 +79,7 @@ def shadow_rows_valid_at(
shadow_table.c.end_transaction_id.is_(None),
shadow_table.c.end_transaction_id > tx,
),
- shadow_table.c.operation_type != 2,
+ shadow_table.c.operation_type != OPERATION_DELETE,
)
)
.mappings()
@@ -266,13 +269,13 @@ def _dashboard_slice_uuids_at_tx(
m2m_tbl.c.end_transaction_id.is_(None),
m2m_tbl.c.end_transaction_id > tx,
),
- m2m_tbl.c.operation_type != 2,
+ m2m_tbl.c.operation_type != OPERATION_DELETE,
slices_tbl.c.transaction_id <= tx,
sa.or_(
slices_tbl.c.end_transaction_id.is_(None),
slices_tbl.c.end_transaction_id > tx,
),
- slices_tbl.c.operation_type != 2,
+ slices_tbl.c.operation_type != OPERATION_DELETE,
)
)
.all()
diff --git a/superset/versioning/queries.py b/superset/versioning/queries.py
index 29ffd103cb5..47b36c30430 100644
--- a/superset/versioning/queries.py
+++ b/superset/versioning/queries.py
@@ -18,8 +18,8 @@
Pure-read helpers that translate Continuum shadow rows and
``version_changes`` records into the shapes the API endpoints return.
-The corresponding write side (restore) ships in a later PR; the
-``VersionDAO`` façade in :mod:`superset.daos.version` re-exports the
+The corresponding write side lives in :mod:`superset.versioning.restore`;
+the ``VersionDAO`` façade in :mod:`superset.daos.version` re-exports the
read helpers here.
Also exposes the deterministic version-UUID derivation
@@ -363,20 +363,23 @@ def list_versions(
]
-def resolve_version_uuid(
+def resolve_version(
model_cls: type[Model],
entity_uuid: UUID,
version_uuid: UUID,
*,
entity: Any | None = None,
-) -> int | None:
- """Translate a ``version_uuid`` into the 0-based ``version_number`` that
the
- restore path (ships in a later PR) accepts, or ``None`` when the UUID does
- not match any version row of the given entity.
-
- Ordering matches :func:`list_versions` — op=0 rows first, then by
- transaction_id — so the version_number returned here is the same index
- a client would see in the list response.
+) -> tuple[int, int] | None:
+ """Translate a ``version_uuid`` into ``(version_number, transaction_id)``,
+ or ``None`` when the UUID does not match any version row of the given
+ entity.
+
+ ``version_number`` is the 0-based display index matching
+ :func:`list_versions` (op=0 rows first, then by transaction_id).
+ ``transaction_id`` is the stable identifier — write paths must address
+ the target row by it, never by the positional index, because the index
+ shifts whenever retention pruning removes older rows (see the
+ ``current_version_number`` docstring).
Implementation note: the loop re-derives ``version_uuid`` per
transaction in Python because there's no portable SQL form for a
@@ -410,10 +413,26 @@ def resolve_version_uuid(
)
for version_number, (tx_id,) in enumerate(tx_ids):
if derive_version_uuid(entity_uuid, tx_id) == version_uuid:
- return version_number
+ return version_number, tx_id
return None
+def resolve_version_uuid(
+ model_cls: type[Model],
+ entity_uuid: UUID,
+ version_uuid: UUID,
+ *,
+ entity: Any | None = None,
+) -> int | None:
+ """Translate a ``version_uuid`` into its 0-based ``version_number``.
+
+ Thin wrapper over :func:`resolve_version` for read-side callers that
+ only need the display index.
+ """
+ resolved = resolve_version(model_cls, entity_uuid, version_uuid,
entity=entity)
+ return None if resolved is None else resolved[0]
+
+
def get_version(
model_cls: type[Model],
entity_uuid: UUID,
diff --git a/superset/versioning/restore.py b/superset/versioning/restore.py
new file mode 100644
index 00000000000..a088ecdc8ca
--- /dev/null
+++ b/superset/versioning/restore.py
@@ -0,0 +1,264 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
+"""Write-side: restore a versioned entity to an earlier state.
+
+Companion to :mod:`superset.versioning.queries`. The
+``BaseRestoreVersionCommand`` in :mod:`superset.commands.version_restore`
+is the only intended caller; the backward-compat ``VersionDAO`` façade
+in :mod:`superset.daos.version` re-exports ``restore_version``.
+
+Restore semantics are strictly per-entity: a restore rewrites the target
+entity's own fields (and, for datasets, its own columns/metrics — the
+aggregate's internal parts), never the content of other entities. A
+dashboard restore reattaches membership to charts that still exist;
+charts that have been deleted since the snapshot stay deleted and are
+reported as skipped rather than revived or dangling.
+"""
+
+from __future__ import annotations
+
+import logging
+from dataclasses import dataclass, field
+from datetime import datetime
+from typing import Any
+from uuid import UUID
+
+from sqlalchemy_continuum import version_class
+
+from superset.extensions import db
+from superset.versioning.baseline import OPERATION_DELETE
+from superset.versioning.queries import find_active_by_uuid
+from superset.versioning.utils import single_flush_scope
+
+logger = logging.getLogger(__name__)
+
+# A DELETE version row (``OPERATION_DELETE``) is never a valid restore
+# target: Continuum's ``Reverter`` would delete the live entity and report
+# success — the opposite of the non-destructive contract — so the engine
+# treats it as not-found.
+
+
+# Per-model relationships that Continuum's Reverter recurses into during a
+# restore — deliberately limited to the entity's OWN aggregate parts
+# (``TableColumn`` / ``SqlMetric`` on ``SqlaTable``). ``Dashboard`` is NOT
+# given ``slices`` here: recursing into the M2M would run a full child
+# revert on every member chart, overwriting live charts' content with
+# historical values (charts are shared entities with their own restore),
+# and re-creating hard-deleted charts. Dashboard membership is instead
+# reconstructed by :func:`_restore_dashboard_membership`.
+#
+# Unknown models fail closed (``LookupError``) rather than defaulting to a
+# relation-less restore — a silently partial restore is worse than a loud
+# failure (mirrors ``_RAISE_FOR_ACCESS_KWARG`` in ``api_helpers``).
+_RESTORE_RELATIONS: dict[str, list[str]] = {
+ "SqlaTable": ["columns", "metrics"],
+ "Dashboard": [],
+ "Slice": [],
+}
+
+
+@dataclass
+class RestoreResult:
+ """Outcome of a successful restore.
+
+ ``skipped_slice_ids`` is only ever populated for dashboard restores:
+ member charts referenced by the snapshot that no longer exist and were
+ therefore not reattached (they stay deleted — restore never revives
+ entities).
+ """
+
+ entity: Any
+ skipped_slice_ids: list[int] = field(default_factory=list)
+
+
+def restore_version(
+ model_cls: type,
+ entity_uuid: UUID,
+ transaction_id: int,
+ *,
+ entity: Any | None = None,
+) -> RestoreResult | None:
+ """Restore the entity identified by *entity_uuid* to the state captured
+ at *transaction_id* (the stable identifier resolved from a
+ ``version_uuid`` by :func:`superset.versioning.queries.resolve_version`).
+
+ Returns a :class:`RestoreResult` wrapping the live entity, or ``None``
+ when the UUID does not match an active entity, no version row exists at
+ *transaction_id*, or the target row is a DELETE — callers should
+ translate all three to a 404.
+
+ Pass *entity* to skip the ``find_active_by_uuid`` lookup when the
+ caller has already loaded the row (the command's ``validate()`` has).
+
+ Uses SQLAlchemy-Continuum's native ``version_obj.revert(relations=...)``
+ and delegates commit to the caller (expected to be a command decorated
+ with ``@transaction()``). The ``relations`` list depends on the model
+ type and is looked up in :data:`_RESTORE_RELATIONS`; unknown models
+ raise ``LookupError`` rather than silently restoring without children.
+
+ Within the same flush, ``changed_on`` / ``changed_by_fk`` are
+ re-stamped with the current time and the restoring user's id so the
+ new version row produced by the restoring commit reflects who clicked
+ Restore, not the original author. ``created_on`` / ``created_by_fk``
+ are left alone.
+ """
+ if entity is None:
+ entity = find_active_by_uuid(model_cls, entity_uuid)
+ if entity is None:
+ return None
+ elif entity.uuid != entity_uuid:
+ # The caller-supplied shortcut must describe the same row as
+ # *entity_uuid*: everything downstream (the version lookup, the
+ # audit stamp, the caller's logging) trusts them to agree. Fail
+ # loudly rather than restore one entity while reporting another.
+ raise ValueError(
+ f"entity.uuid ({entity.uuid!r}) does not match entity_uuid "
+ f"({entity_uuid!r}); the preloaded entity must be the one "
+ "identified by entity_uuid"
+ )
+
+ ver_cls = version_class(model_cls)
+ target_version = (
+ db.session.query(ver_cls)
+ .filter(
+ ver_cls.id == entity.id,
+ ver_cls.transaction_id == transaction_id,
+ )
+ .one_or_none()
+ )
+ if target_version is None or target_version.operation_type ==
OPERATION_DELETE:
+ return None
+
+ relations = _RESTORE_RELATIONS.get(model_cls.__name__)
+ if relations is None:
+ raise LookupError(
+ f"No restore relations registered for {model_cls.__name__!r}; "
+ "register the model in _RESTORE_RELATIONS before wiring a "
+ "restore command for it."
+ )
+
+ # Run the whole revert — including membership reconstruction and audit
+ # stamping — inside a single flush scope so SQLAlchemy-Continuum's
+ # ``Reverter`` can iterate relations without tripping its autoflush
+ # race, and so the change-records listener sees the complete state in
+ # one ``after_flush`` pass. See ``single_flush_scope`` for the full
+ # rationale.
+ skipped_slice_ids: list[int] = []
+ try:
+ with single_flush_scope(db.session):
+ target_version.revert(relations=relations)
+ if model_cls.__name__ == "Dashboard":
+ skipped_slice_ids = _restore_dashboard_membership(
+ entity, transaction_id
+ )
+ _stamp_audit_fields_for_restore(entity)
+ except Exception:
+ logger.exception(
+ "Continuum revert() failed for %s id=%s tx=%s relations=%s",
+ model_cls.__name__,
+ entity.id,
+ transaction_id,
+ relations,
+ )
+ raise
+
+ logger.info(
+ "versioning: restored %s id=%s uuid=%s to tx=%s (skipped_slices=%s)",
+ model_cls.__name__,
+ entity.id,
+ entity_uuid,
+ transaction_id,
+ skipped_slice_ids or None,
+ )
+ return RestoreResult(entity=entity, skipped_slice_ids=skipped_slice_ids)
+
+
+def _restore_dashboard_membership(dashboard: Any, transaction_id: int) ->
list[int]:
+ """Reset *dashboard*'s chart membership to what it was at
+ *transaction_id*, reattaching only charts that still exist.
+
+ Reads the validity-windowed ``dashboard_slices_version`` shadow
+ (Continuum's auto-generated M2M table): a slice was a member at tx T
+ iff a non-DELETE row has ``transaction_id <= T`` and an open or
+ later-closing validity window.
+
+ Returns the ids of snapshot members that no longer exist and were
+ skipped. Live charts' content is never touched — restoring a chart's
+ content is the chart's own restore endpoint's job.
+ """
+ # pylint: disable=import-outside-toplevel
+ # Local imports: models.slice transitively imports models.core, which
+ # needs the initialised app — module-top import would recreate the
+ # bootstrap cycle documented in changes/listener.py; shadow_queries is
+ # imported lazily for the same reason (see queries.get_version).
+ from superset.models.slice import Slice
+ from superset.versioning.changes import shadow_rows_valid_at
+
+ ver_cls = version_class(type(dashboard))
+ m2m_tbl = ver_cls.__table__.metadata.tables.get("dashboard_slices_version")
+ if m2m_tbl is None: # pragma: no cover — shadow tables always exist here
+ return []
+
+ # shadow_rows_valid_at owns the validity-window semantics (open or
+ # later-closing window, non-DELETE) — the same predicate the version
+ # snapshot's column/metric reconstruction uses.
+ member_ids = sorted(
+ {
+ row["slice_id"]
+ for row in shadow_rows_valid_at(
+ db.session,
+ m2m_tbl,
+ "dashboard_id",
+ dashboard.id,
+ transaction_id,
+ )
+ if row["slice_id"] is not None
+ }
+ )
+ if not member_ids:
+ dashboard.slices = []
+ return []
+
+ live_slices =
db.session.query(Slice).filter(Slice.id.in_(member_ids)).all()
+ live_ids = {slc.id for slc in live_slices}
+ skipped = sorted(set(member_ids) - live_ids)
+ if skipped:
+ logger.warning(
+ "versioning: dashboard id=%s restore to tx=%s skipped %d "
+ "member chart(s) that no longer exist: %s",
+ dashboard.id,
+ transaction_id,
+ len(skipped),
+ skipped,
+ )
+ dashboard.slices = live_slices
+ return skipped
+
+
+def _stamp_audit_fields_for_restore(entity: Any) -> None:
+ """Overwrite ``changed_on`` / ``changed_by_fk`` on *entity* with the
+ current time and current user id, so that the restore is attributed
+ to the restoring user rather than the version snapshot's original
+ author. Runs inside the restore's single flush scope so the stamp
+ rides the same Continuum transaction as the revert."""
+ # pylint: disable=import-outside-toplevel
+ # Local import: utils.core pulls in the feature-flag manager, which
+ # needs the initialised app (same cycle as models.slice above).
+ from superset.utils.core import get_user_id
+
+ entity.changed_on = datetime.now()
+ entity.changed_by_fk = get_user_id()
diff --git a/superset/versioning/utils.py b/superset/versioning/utils.py
index 47fa3c23ace..3a740a971c5 100644
--- a/superset/versioning/utils.py
+++ b/superset/versioning/utils.py
@@ -18,12 +18,65 @@
from __future__ import annotations
+from collections.abc import Iterator
+from contextlib import contextmanager
from typing import Any
import sqlalchemy as sa
+from flask import current_app
from sqlalchemy.orm import Session
+def capture_enabled() -> bool:
+ """Whether ``ENABLE_VERSIONING_CAPTURE`` is on for the current app.
+
+ The single gate shared by the read helpers (which degrade to inert
+ responses when off) and the write side (which must refuse: with
+ Continuum's write listeners detached, a restore would mutate the live
+ entity with no new version row — a destructive, untracked write that
+ violates the append-only contract).
+
+ Operational constraint: this reads config at call time, but the
+ Continuum listeners themselves attach or detach only when
+ ``init_versioning()`` runs at startup. The two agree only because the
+ flag is static per-process — flipping it at runtime (dynamic config
+ reload, a future admin toggle) without re-running ``init_versioning()``
+ would let this gate pass while listeners stay detached, producing
+ exactly the untracked write it exists to prevent. Restart the process
+ (or re-run ``init_versioning()``) after changing the flag.
+ """
+ return bool(current_app.config.get("ENABLE_VERSIONING_CAPTURE", False))
+
+
+@contextmanager
+def single_flush_scope(session: Session) -> Iterator[None]:
+ """Suppress autoflushes inside the block, flush once on clean exit.
+
+ Intended for operations that (a) make multiple mutations across
+ relationships and (b) issue intermediate queries which would
+ otherwise autoflush. Iterating from one relationship to another
+ inside SQLAlchemy-Continuum's ``Reverter`` is the canonical case:
+ a mid-iteration autoflush transitions pending DELETEs to
+ ``state.deleted=True``, and the subsequent
+ ``session.add(version_parent)`` cascade walk trips on the
+ deleted-state instances with ``InvalidRequestError``. Wrapping the
+ whole revert keeps marked-for-deletion instances in
+ ``state.persistent`` until the trailing flush drains DELETEs +
+ INSERTs in one atomic step. That single flush is also load-bearing
+ for the ``after_flush`` change-records listener — splitting the
+ work across multiple flushes would split it across multiple
+ Continuum transactions, and the listener's tx-dedup guard would
+ silently drop the second pass's records.
+
+ On exception, the trailing flush is skipped — the session's normal
+ rollback flow handles cleanup, and flushing a partially-mutated
+ state would be wrong.
+ """
+ with session.no_autoflush:
+ yield
+ session.flush()
+
+
def read_row_outside_flush(
session: Session, table: sa.Table, entity_id: int
) -> dict[str, Any] | None:
diff --git a/tests/integration_tests/charts/version_restore_tests.py
b/tests/integration_tests/charts/version_restore_tests.py
new file mode 100644
index 00000000000..3c60bfbb173
--- /dev/null
+++ b/tests/integration_tests/charts/version_restore_tests.py
@@ -0,0 +1,456 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
+"""Integration tests for chart (Slice) version restore.
+
+Covers POST /api/v1/chart/<uuid>/versions/<version_uuid>/restore: the
+non-destructive revert applies the target snapshot, appends a new
+version row, attributes the change to the restoring user, and returns
+the documented 400/404 errors for malformed or unknown UUIDs.
+"""
+
+from __future__ import annotations
+
+from typing import Any
+
+import pytest
+from sqlalchemy_continuum import version_class
+
+from superset.extensions import db
+from superset.models.slice import Slice
+from superset.utils import json as _json
+from tests.integration_tests.base_tests import SupersetTestCase
+from tests.integration_tests.constants import ADMIN_USERNAME
+from tests.integration_tests.fixtures.birth_names_dashboard import ( # noqa:
F401
+ load_birth_names_dashboard_with_slices,
+ load_birth_names_data,
+)
+
+
+def _get_version_rows(chart: Slice) -> list[Any]:
+ ver_cls = version_class(Slice)
+ return (
+ db.session.query(ver_cls)
+ .filter(ver_cls.id == chart.id)
+ .order_by(ver_cls.transaction_id.asc())
+ .all()
+ )
+
+
+def _persist_fixture_state() -> None:
+ """Force fixture's pending INSERTs to commit in their own transaction.
+
+ The birth_names fixture stages charts and the dashboard via session.add()
+ but does not commit. Without this, the test's first commit batches the
+ INSERTs and UPDATEs into the same Continuum transaction, causing the
+ existing version row to be updated in place instead of a new one being
+ created.
+ """
+ db.session.commit()
+
+
+class TestChartRestoreApi(SupersetTestCase):
+ """T037 — POST /api/v1/chart/<uuid>/versions/<version_uuid>/restore."""
+
+ @pytest.fixture(autouse=True)
+ def _load_data(self, load_birth_names_dashboard_with_slices): # noqa:
PT004, F811
+ pass
+
+ def _restore(self, chart_uuid: str, version_uuid: str) -> Any:
+ return self.client.post(
+ f"/api/v1/chart/{chart_uuid}/versions/{version_uuid}/restore"
+ )
+
+ def _list(self, chart_uuid: str) -> Any:
+ return self.client.get(f"/api/v1/chart/{chart_uuid}/versions/")
+
+ def test_restore_applies_scalar_field_from_target_version(self) -> None:
+ """Restoring version 0 puts the slice_name back to its pre-edit value
+ and appends a new version entry."""
+ _persist_fixture_state()
+ chart: Slice = (
+ db.session.query(Slice).filter(Slice.slice_name == "Girls").first()
+ )
+ assert chart is not None
+ chart_uuid = str(chart.uuid)
+ original_name = chart.slice_name
+
+ # Produce two additional saves so version history is 0/1/2.
+ chart.slice_name = "Girls v1"
+ db.session.commit()
+ chart.slice_name = "Girls v2"
+ db.session.commit()
+
+ self.login(ADMIN_USERNAME)
+ rv_list = self._list(chart_uuid)
+ assert rv_list.status_code == 200
+ listing = _json.loads(rv_list.data.decode("utf-8"))
+ initial_count = listing["count"]
+ assert initial_count >= 3
+ target_uuid = listing["result"][0]["version_uuid"]
+
+ # Restore to the first version (the original "Girls" name).
+ rv = self._restore(chart_uuid, target_uuid)
+ assert rv.status_code == 200, rv.data
+
+ # Live state matches the restored snapshot.
+ db.session.expire_all()
+ chart = db.session.query(Slice).filter(Slice.uuid == chart.uuid).one()
+ assert chart.slice_name == original_name
+
+ # A new version row was recorded (non-destructive).
+ rv_list2 = self._list(chart_uuid)
+ body = _json.loads(rv_list2.data.decode("utf-8"))
+ assert body["count"] == initial_count + 1
+
+ # Cleanup
+ chart.slice_name = original_name
+ db.session.commit()
+
+ def test_restore_returns_404_for_unknown_uuid(self) -> None:
+ self.login(ADMIN_USERNAME)
+ rv = self._restore(
+ "00000000-0000-0000-0000-000000000000",
+ "00000000-0000-0000-0000-000000000001",
+ )
+ assert rv.status_code == 404
+
+ def test_restore_returns_404_for_unknown_version_uuid(self) -> None:
+ _persist_fixture_state()
+ chart: Slice = (
+ db.session.query(Slice).filter(Slice.slice_name == "Boys").first()
+ )
+ assert chart is not None
+ self.login(ADMIN_USERNAME)
+ rv = self._restore(str(chart.uuid),
"00000000-0000-0000-0000-000000000099")
+ assert rv.status_code == 404
+
+ def test_restore_returns_400_for_invalid_entity_uuid(self) -> None:
+ self.login(ADMIN_USERNAME)
+ rv = self._restore("not-a-uuid",
"00000000-0000-0000-0000-000000000001")
+ assert rv.status_code == 400
+
+ def test_restore_returns_400_for_invalid_version_uuid(self) -> None:
+ _persist_fixture_state()
+ chart: Slice = (
+ db.session.query(Slice).filter(Slice.slice_name == "Boys").first()
+ )
+ assert chart is not None
+ self.login(ADMIN_USERNAME)
+ rv = self._restore(str(chart.uuid), "not-a-uuid")
+ assert rv.status_code == 400
+
+ def test_get_version_returns_historical_snapshot(self) -> None:
+ """GET /versions/<uuid>/ returns the chart's fields at that version
+ without modifying live state."""
+ _persist_fixture_state()
+ chart: Slice = (
+ db.session.query(Slice).filter(Slice.slice_name == "Girls").first()
+ )
+ assert chart is not None
+ chart_uuid = str(chart.uuid)
+ original_name = chart.slice_name
+
+ chart.slice_name = "Girls (v1)"
+ db.session.commit()
+
+ self.login(ADMIN_USERNAME)
+ listing = _json.loads(self._list(chart_uuid).data.decode("utf-8"))
+ assert listing["count"] >= 2
+ # The earliest entry should still hold the original slice_name.
+ first_version_uuid = listing["result"][0]["version_uuid"]
+
+ rv = self.client.get(
+ f"/api/v1/chart/{chart_uuid}/versions/{first_version_uuid}/"
+ )
+ assert rv.status_code == 200, rv.data
+ body = _json.loads(rv.data.decode("utf-8"))["result"]
+ assert body["slice_name"] == original_name
+ assert body["_version"]["version_uuid"] == first_version_uuid
+ assert body["_version"]["version_number"] == 0
+ # Live row unchanged.
+ db.session.expire_all()
+ live = db.session.query(Slice).filter(Slice.uuid == chart.uuid).one()
+ assert live.slice_name == "Girls (v1)"
+
+ # Cleanup
+ live.slice_name = original_name
+ db.session.commit()
+
+ def test_get_version_returns_404_for_unknown_entity(self) -> None:
+ self.login(ADMIN_USERNAME)
+ rv = self.client.get(
+ "/api/v1/chart/00000000-0000-0000-0000-000000000000"
+ "/versions/00000000-0000-0000-0000-000000000001/"
+ )
+ assert rv.status_code == 404
+
+ def test_get_version_returns_400_for_invalid_uuid(self) -> None:
+ self.login(ADMIN_USERNAME)
+ rv = self.client.get(
+
"/api/v1/chart/not-a-uuid/versions/00000000-0000-0000-0000-000000000001/"
+ )
+ assert rv.status_code == 400
+
+ def test_restore_stamps_changed_by_with_restoring_user(self) -> None:
+ """After a restore, changed_by_fk on the live entity must point at
+ the restoring user (not at whoever authored the version being
+ restored). created_by_fk stays unchanged. The new version row
+ produced by the restore also carries the restoring user in its
+ changed_by metadata.
+ """
+ from superset.daos.version import derive_version_uuid
+
+ _persist_fixture_state()
+ self.login(ADMIN_USERNAME)
+ admin_id = self.get_user(ADMIN_USERNAME).id
+ chart: Slice = (
+ db.session.query(Slice).filter(Slice.slice_name == "Girls").first()
+ )
+ assert chart is not None
+ chart_id = chart.id
+ chart_uuid = str(chart.uuid)
+ entity_uuid = chart.uuid
+ original_name = chart.slice_name
+ original_created_by = chart.created_by_fk
+ before_changed_on = chart.changed_on
+
+ # Produce a second version to restore to.
+ chart.slice_name = "Girls v1"
+ db.session.commit()
+
+ ver_cls = version_class(Slice)
+ first_tx = (
+ db.session.query(ver_cls.transaction_id)
+ .filter(ver_cls.id == chart_id)
+ .order_by(ver_cls.transaction_id.asc())
+ .limit(1)
+ .scalar()
+ )
+ assert first_tx is not None
+ target_uuid = str(derive_version_uuid(entity_uuid, first_tx))
+
+ rv = self.client.post(
+ f"/api/v1/chart/{chart_uuid}/versions/{target_uuid}/restore"
+ )
+ assert rv.status_code == 200, rv.data
+
+ db.session.expire_all()
+ chart = db.session.query(Slice).filter(Slice.id == chart_id).one()
+
+ # Live entity checks.
+ assert chart.slice_name == original_name
+ assert chart.created_by_fk == original_created_by
+ assert chart.changed_by_fk == admin_id, (
+ f"Expected changed_by_fk to be restoring user id={admin_id}, "
+ f"got {chart.changed_by_fk}"
+ )
+ if before_changed_on is not None and chart.changed_on is not None:
+ assert chart.changed_on >= before_changed_on
+
+ # The new version row produced by the restore must attribute the
+ # change to the restoring user.
+ rv_list = self.client.get(f"/api/v1/chart/{chart_uuid}/versions/")
+ assert rv_list.status_code == 200
+ body = _json.loads(rv_list.data.decode("utf-8"))
+ latest_entry = body["result"][-1]
+ assert latest_entry["changed_by"] is not None, (
+ "New version row should have a changed_by"
+ )
+ assert latest_entry["changed_by"]["id"] == admin_id
+
+ # Cleanup
+ chart.slice_name = original_name
+ db.session.commit()
+
+ def test_put_response_returns_old_and_new_version_numbers(self) -> None:
+ """PUT /api/v1/chart/<id> response must include old_version and
+ new_version matching the list-versions ordering."""
+ _persist_fixture_state()
+ chart: Slice = (
+ db.session.query(Slice).filter(Slice.slice_name == "Girls").first()
+ )
+ assert chart is not None
+ chart_id = chart.id
+ original_name = chart.slice_name
+
+ ver_cls = version_class(Slice)
+ count_before = db.session.query(ver_cls).filter(ver_cls.id ==
chart_id).count()
+ expected_old = count_before - 1 if count_before > 0 else None
+
+ self.login(ADMIN_USERNAME)
+ rv = self.client.put(
+ f"/api/v1/chart/{chart_id}",
+ json={"slice_name": "put-response-version-test"},
+ )
+ assert rv.status_code == 200, rv.data
+ body = _json.loads(rv.data.decode("utf-8"))
+ assert body["id"] == chart_id
+ assert body["old_version"] == expected_old
+ assert body["new_version"] is not None
+ assert "old_transaction_id" in body
+ assert "new_transaction_id" in body
+ if body["old_transaction_id"] is not None:
+ assert body["new_transaction_id"] != body["old_transaction_id"]
+
+ # Cleanup
+ chart = db.session.query(Slice).filter(Slice.id == chart_id).one()
+ chart.slice_name = original_name
+ db.session.commit()
+
+ def test_restore_denies_non_editor_with_write_permission(self) -> None:
+ """A user holding can_write on Chart but who is not an editor of
+ THIS chart gets 403 from the command's ``raise_for_editorship``
+ branch — the interesting case route-level ``@protect()`` cannot
+ catch."""
+ from superset.daos.version import derive_version_uuid
+ from tests.integration_tests.constants import ALPHA_USERNAME
+
+ _persist_fixture_state()
+ chart: Slice = (
+ db.session.query(Slice).filter(Slice.slice_name == "Girls").first()
+ )
+ assert chart is not None
+ # Ensure alpha is not an editor of the fixture chart.
+ alpha = self.get_user(ALPHA_USERNAME)
+ assert alpha not in chart.editors
+
+ ver_cls = version_class(Slice)
+ first_tx = (
+ db.session.query(ver_cls.transaction_id)
+ .filter(ver_cls.id == chart.id)
+ .order_by(ver_cls.transaction_id.asc())
+ .limit(1)
+ .scalar()
+ )
+ assert first_tx is not None
+ target_uuid = str(derive_version_uuid(chart.uuid, first_tx))
+
+ self.login(ALPHA_USERNAME)
+ rv = self._restore(str(chart.uuid), target_uuid)
+ assert rv.status_code == 403, rv.data
+ db.session.refresh(chart)
+ assert chart.slice_name == "Girls"
+
+ def test_restore_returns_404_when_capture_disabled(self) -> None:
+ """With ENABLE_VERSIONING_CAPTURE off, the restore route is inert:
+ Continuum's write listeners are detached, so a revert would mutate
+ the live entity with no new version row — a destructive, untracked
+ write. The command refuses with 404 before touching anything."""
+ _persist_fixture_state()
+ chart: Slice = (
+ db.session.query(Slice).filter(Slice.slice_name == "Girls").first()
+ )
+ assert chart is not None
+ original_name = chart.slice_name
+
+ self.login(ADMIN_USERNAME)
+ listing = _json.loads(self._list(str(chart.uuid)).data.decode("utf-8"))
+ target_uuid = listing["result"][0]["version_uuid"]
+
+ self.app.config["ENABLE_VERSIONING_CAPTURE"] = False
+ try:
+ rv = self._restore(str(chart.uuid), target_uuid)
+ finally:
+ self.app.config["ENABLE_VERSIONING_CAPTURE"] = True
+ assert rv.status_code == 404, rv.data
+
+ db.session.expire_all()
+ live = db.session.query(Slice).filter(Slice.id == chart.id).one()
+ assert live.slice_name == original_name
+
+ def test_restore_returns_404_for_other_entitys_version_uuid(self) -> None:
+ """A version_uuid belonging to a DIFFERENT chart must not resolve:
+ version identity is (entity_uuid, transaction), so entity A's
+ version_uuid presented under entity B's path is a 404."""
+ from superset.daos.version import derive_version_uuid
+
+ _persist_fixture_state()
+ girls: Slice = (
+ db.session.query(Slice).filter(Slice.slice_name == "Girls").first()
+ )
+ boys: Slice = db.session.query(Slice).filter(Slice.slice_name ==
"Boys").first()
+ assert girls is not None
+ assert boys is not None
+
+ ver_cls = version_class(Slice)
+ boys_tx = (
+ db.session.query(ver_cls.transaction_id)
+ .filter(ver_cls.id == boys.id)
+ .order_by(ver_cls.transaction_id.asc())
+ .limit(1)
+ .scalar()
+ )
+ assert boys_tx is not None
+ boys_version_uuid = str(derive_version_uuid(boys.uuid, boys_tx))
+
+ self.login(ADMIN_USERNAME)
+ rv = self._restore(str(girls.uuid), boys_version_uuid)
+ assert rv.status_code == 404, rv.data
+
+ def test_restore_stamps_action_kind_restore_on_transaction(self) -> None:
+ """The restoring commit's version_transaction row must carry
+ ``action_kind='restore'`` so the activity feed renders it as a
+ restore, not an ordinary save (contract in versioning/changes)."""
+ from sqlalchemy_continuum import versioning_manager
+
+ from superset.daos.version import derive_version_uuid
+
+ _persist_fixture_state()
+ chart: Slice = (
+ db.session.query(Slice).filter(Slice.slice_name == "Girls").first()
+ )
+ assert chart is not None
+ chart_id = chart.id
+ original_name = chart.slice_name
+
+ chart.slice_name = "Girls action-kind v1"
+ db.session.commit()
+
+ ver_cls = version_class(Slice)
+ first_tx = (
+ db.session.query(ver_cls.transaction_id)
+ .filter(ver_cls.id == chart_id)
+ .order_by(ver_cls.transaction_id.asc())
+ .limit(1)
+ .scalar()
+ )
+ target_uuid = str(derive_version_uuid(chart.uuid, first_tx))
+
+ self.login(ADMIN_USERNAME)
+ rv = self._restore(str(chart.uuid), target_uuid)
+ assert rv.status_code == 200, rv.data
+
+ latest_tx = (
+ db.session.query(ver_cls.transaction_id)
+ .filter(ver_cls.id == chart_id)
+ .order_by(ver_cls.transaction_id.desc())
+ .limit(1)
+ .scalar()
+ )
+ tx_tbl = versioning_manager.transaction_cls.__table__
+ action_kind = (
+ db.session.execute(tx_tbl.select().where(tx_tbl.c.id == latest_tx))
+ .mappings()
+ .one()["action_kind"]
+ )
+ assert action_kind == "restore"
+
+ # Cleanup
+ db.session.expire_all()
+ chart = db.session.query(Slice).filter(Slice.id == chart_id).one()
+ chart.slice_name = original_name
+ db.session.commit()
diff --git a/tests/integration_tests/dashboards/version_restore_tests.py
b/tests/integration_tests/dashboards/version_restore_tests.py
new file mode 100644
index 00000000000..93bc71fdef6
--- /dev/null
+++ b/tests/integration_tests/dashboards/version_restore_tests.py
@@ -0,0 +1,418 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
+"""Integration tests for Dashboard version restore.
+
+Covers POST /api/v1/dashboard/<uuid>/versions/<version_uuid>/restore:
+the non-destructive revert applies the target snapshot (including
+reattaching a chart removed after that snapshot) and returns the
+documented 404s for unknown entity/version UUIDs.
+"""
+
+from __future__ import annotations
+
+from typing import Any
+
+import pytest
+import sqlalchemy as sa
+from sqlalchemy_continuum import version_class
+
+from superset.extensions import db
+from superset.models.dashboard import Dashboard
+from superset.utils import json as _json
+from tests.integration_tests.base_tests import SupersetTestCase
+from tests.integration_tests.constants import ADMIN_USERNAME
+from tests.integration_tests.fixtures.birth_names_dashboard import ( # noqa:
F401
+ load_birth_names_dashboard_with_slices,
+ load_birth_names_data,
+)
+
+
+def _get_version_rows(dashboard: Dashboard) -> list[Any]:
+ ver_cls = version_class(Dashboard)
+ return (
+ db.session.query(ver_cls)
+ .filter(ver_cls.id == dashboard.id)
+ .order_by(ver_cls.transaction_id.asc())
+ .all()
+ )
+
+
+def _persist_fixture_state() -> None:
+ """Force fixture's pending INSERTs to commit in their own transaction.
+
+ The birth_names fixture stages charts and the dashboard via session.add()
+ but does not commit. Without this, the test's first commit batches the
+ INSERTs and UPDATEs into the same Continuum transaction, causing the
+ existing version row to be updated in place instead of a new one being
+ created.
+ """
+ db.session.commit()
+
+
+class TestDashboardRestoreApi(SupersetTestCase):
+ """T038 — POST /api/v1/dashboard/<uuid>/versions/<version_uuid>/restore."""
+
+ @pytest.fixture(autouse=True)
+ def _load_data(self, load_birth_names_dashboard_with_slices): # noqa:
PT004, F811
+ pass
+
+ def _restore(self, dashboard_uuid: str, version_uuid: str) -> Any:
+ return self.client.post(
+
f"/api/v1/dashboard/{dashboard_uuid}/versions/{version_uuid}/restore"
+ )
+
+ def test_restore_applies_scalar_field(self) -> None:
+ """Restore a dashboard title edit."""
+ from superset.daos.version import derive_version_uuid
+
+ _persist_fixture_state()
+ dashboard: Dashboard = (
+ db.session.query(Dashboard)
+ .filter(Dashboard.dashboard_title == "USA Births Names")
+ .first()
+ )
+ assert dashboard is not None
+ dashboard_uuid = str(dashboard.uuid)
+ original_title = dashboard.dashboard_title
+ dashboard_id = dashboard.id
+ entity_uuid = dashboard.uuid
+
+ # Make two more edits so we have a known non-trivial history to
+ # navigate: [initial, v1, v2].
+ dashboard.dashboard_title = "USA Births Names v1"
+ db.session.commit()
+ dashboard.dashboard_title = "USA Births Names v2"
+ db.session.commit()
+
+ ver_cls = version_class(Dashboard)
+ rows = (
+ db.session.query(
+ ver_cls.transaction_id,
+ ver_cls.operation_type,
+ ver_cls.dashboard_title,
+ ver_cls.end_transaction_id,
+ )
+ .filter(ver_cls.id == dashboard_id)
+ .order_by(ver_cls.transaction_id.asc())
+ .all()
+ )
+ # Find the version whose snapshot has the original title.
+ target_row = next(
+ (row for row in rows if row.dashboard_title == original_title),
+ None,
+ )
+ assert target_row is not None, (
+ f"Expected at least one version row with original title;
rows={rows}"
+ )
+ target_uuid = str(derive_version_uuid(entity_uuid,
target_row.transaction_id))
+
+ self.login(ADMIN_USERNAME)
+ rv = self._restore(dashboard_uuid, target_uuid)
+ assert rv.status_code == 200, rv.data
+
+ db.session.expire_all()
+ dashboard = (
+ db.session.query(Dashboard).filter(Dashboard.id ==
dashboard_id).one()
+ )
+ assert dashboard.dashboard_title == original_title, (
+ f"Restore did not revert title; rows={rows}"
+ )
+
+ # Cleanup
+ dashboard.dashboard_title = original_title
+ db.session.commit()
+
+ def test_restore_reattaches_chart_removed_after_snapshot(self) -> None:
+ """After the target snapshot is captured, detaching a chart and saving
+ must be undone by restore — the chart comes back on
dashboard_slices."""
+ from superset.daos.version import derive_version_uuid
+
+ _persist_fixture_state()
+ dashboard: Dashboard = (
+ db.session.query(Dashboard)
+ .filter(Dashboard.dashboard_title == "USA Births Names")
+ .first()
+ )
+ assert dashboard is not None
+ dashboard_uuid = str(dashboard.uuid)
+ dashboard_id = dashboard.id
+ entity_uuid = dashboard.uuid
+
+ original_slice_ids = sorted(s.id for s in dashboard.slices)
+ assert len(original_slice_ids) >= 2, (
+ f"fixture expected to attach >= 2 charts; got {original_slice_ids}"
+ )
+ slice_to_drop = dashboard.slices[0]
+ drop_id = slice_to_drop.id
+
+ # Touch the dashboard so a snapshot row is captured at a known tx.
+ dashboard.dashboard_title = "USA Births Names — snapshot point"
+ db.session.commit()
+
+ ver_cls = version_class(Dashboard)
+ target_tx = (
+ db.session.query(ver_cls.transaction_id)
+ .filter(ver_cls.id == dashboard_id)
+ .order_by(ver_cls.transaction_id.desc())
+ .limit(1)
+ .scalar()
+ )
+ assert target_tx is not None
+ target_uuid = str(derive_version_uuid(entity_uuid, target_tx))
+
+ # Detach the chart and commit — moves history forward.
+ dashboard.slices.remove(slice_to_drop)
+ db.session.commit()
+
+ db.session.expire_all()
+ dashboard = (
+ db.session.query(Dashboard).filter(Dashboard.id ==
dashboard_id).one()
+ )
+ live_ids = {s.id for s in dashboard.slices}
+ assert drop_id not in live_ids, "pre-restore: dropped chart should be
detached"
+
+ self.login(ADMIN_USERNAME)
+ rv = self._restore(dashboard_uuid, target_uuid)
+ assert rv.status_code == 200, rv.data
+
+ db.session.expire_all()
+ dashboard = (
+ db.session.query(Dashboard).filter(Dashboard.id ==
dashboard_id).one()
+ )
+ restored_ids = sorted(s.id for s in dashboard.slices)
+ assert restored_ids == original_slice_ids, (
+ f"restore did not re-attach chart: expected {original_slice_ids}, "
+ f"got {restored_ids}"
+ )
+
+ def test_restore_preserves_live_chart_content(self) -> None:
+ """Dashboard restore is membership-only: a member chart edited
+ AFTER the snapshot keeps its current content — charts are shared
+ entities with their own restore endpoint, so a dashboard restore
+ must never rewrite them to historical values."""
+ from superset.daos.version import derive_version_uuid
+ from superset.models.slice import Slice
+
+ _persist_fixture_state()
+ dashboard: Dashboard = (
+ db.session.query(Dashboard)
+ .filter(Dashboard.dashboard_title == "USA Births Names")
+ .first()
+ )
+ assert dashboard is not None
+ dashboard_id = dashboard.id
+ member = dashboard.slices[0]
+ member_id = member.id
+ original_chart_name = member.slice_name
+ original_title = dashboard.dashboard_title
+
+ # Snapshot point: chart still has its original name.
+ dashboard.dashboard_title = "USA Births Names — content snapshot"
+ db.session.commit()
+
+ ver_cls = version_class(Dashboard)
+ target_tx = (
+ db.session.query(ver_cls.transaction_id)
+ .filter(ver_cls.id == dashboard_id)
+ .order_by(ver_cls.transaction_id.desc())
+ .limit(1)
+ .scalar()
+ )
+ target_uuid = str(derive_version_uuid(dashboard.uuid, target_tx))
+
+ # Edit the member chart AFTER the snapshot.
+ member = db.session.query(Slice).filter(Slice.id == member_id).one()
+ member.slice_name = "edited after snapshot"
+ db.session.commit()
+
+ self.login(ADMIN_USERNAME)
+ rv = self._restore(str(dashboard.uuid), target_uuid)
+ assert rv.status_code == 200, rv.data
+
+ db.session.expire_all()
+ member = db.session.query(Slice).filter(Slice.id == member_id).one()
+ assert member.slice_name == "edited after snapshot", (
+ "dashboard restore must not rewrite live member chart content"
+ )
+
+ # Cleanup
+ member.slice_name = original_chart_name
+ dashboard = (
+ db.session.query(Dashboard).filter(Dashboard.id ==
dashboard_id).one()
+ )
+ dashboard.dashboard_title = original_title
+ db.session.commit()
+
+ def test_restore_skips_member_chart_that_no_longer_exists(self) -> None:
+ """A snapshot member whose chart row has been hard-deleted stays
+ deleted: restore succeeds, reattaches the surviving members, does
+ NOT revive the deleted chart, and says so in the response."""
+ from superset.daos.version import derive_version_uuid
+ from superset.models.slice import Slice
+
+ _persist_fixture_state()
+ dashboard: Dashboard = (
+ db.session.query(Dashboard)
+ .filter(Dashboard.dashboard_title == "USA Births Names")
+ .first()
+ )
+ assert dashboard is not None
+ dashboard_id = dashboard.id
+ original_ids = sorted(s.id for s in dashboard.slices)
+ assert len(original_ids) >= 2
+ victim = dashboard.slices[0]
+ victim_id = victim.id
+
+ # Snapshot point: victim is a member.
+ dashboard.dashboard_title = "USA Births Names — skip snapshot"
+ db.session.commit()
+
+ ver_cls = version_class(Dashboard)
+ target_tx = (
+ db.session.query(ver_cls.transaction_id)
+ .filter(ver_cls.id == dashboard_id)
+ .order_by(ver_cls.transaction_id.desc())
+ .limit(1)
+ .scalar()
+ )
+ target_uuid = str(derive_version_uuid(dashboard.uuid, target_tx))
+
+ # Detach, then hard-delete the victim via raw SQL so no live row
+ # remains (bypasses the soft-delete listener deliberately — the
+ # scenario is a purged/legacy-deleted chart).
+ dashboard.slices.remove(victim)
+ db.session.commit()
+ for assoc in ("chart_editors", "chart_viewers"):
+ db.session.execute(
+ sa.text(f"DELETE FROM {assoc} WHERE chart_id = :sid"), #
noqa: S608
+ {"sid": victim_id},
+ )
+ db.session.execute(
+ sa.text("DELETE FROM slices WHERE id = :sid"), {"sid": victim_id}
+ )
+ db.session.commit()
+
+ self.login(ADMIN_USERNAME)
+ rv = self._restore(str(dashboard.uuid), target_uuid)
+ assert rv.status_code == 200, rv.data
+ message = _json.loads(rv.data.decode("utf-8")).get("message", "")
+ assert "no longer exist" in message, message
+
+ db.session.expire_all()
+ dashboard = (
+ db.session.query(Dashboard).filter(Dashboard.id ==
dashboard_id).one()
+ )
+ restored_ids = sorted(s.id for s in dashboard.slices)
+ assert victim_id not in restored_ids, "deleted chart must stay deleted"
+ assert restored_ids == sorted(sid for sid in original_ids if sid !=
victim_id)
+ assert (
+ db.session.query(Slice).filter(Slice.id ==
victim_id).one_or_none() is None
+ ), "restore must not revive a hard-deleted chart"
+
+ def test_restore_denies_non_editor_with_write_permission(self) -> None:
+ """A user holding can_write on Dashboard but who is not an editor
+ of THIS dashboard gets 403 from the command's editorship check."""
+ from superset.daos.version import derive_version_uuid
+ from tests.integration_tests.constants import ALPHA_USERNAME
+
+ _persist_fixture_state()
+ dashboard: Dashboard = (
+ db.session.query(Dashboard)
+ .filter(Dashboard.dashboard_title == "USA Births Names")
+ .first()
+ )
+ assert dashboard is not None
+ alpha = self.get_user(ALPHA_USERNAME)
+ assert alpha not in dashboard.editors
+
+ ver_cls = version_class(Dashboard)
+ first_tx = (
+ db.session.query(ver_cls.transaction_id)
+ .filter(ver_cls.id == dashboard.id)
+ .order_by(ver_cls.transaction_id.asc())
+ .limit(1)
+ .scalar()
+ )
+ assert first_tx is not None
+ target_uuid = str(derive_version_uuid(dashboard.uuid, first_tx))
+
+ self.login(ALPHA_USERNAME)
+ rv = self._restore(str(dashboard.uuid), target_uuid)
+ assert rv.status_code == 403, rv.data
+ db.session.refresh(dashboard)
+ assert dashboard.dashboard_title == "USA Births Names"
+
+ def test_restore_returns_404_for_unknown_uuid(self) -> None:
+ self.login(ADMIN_USERNAME)
+ rv = self._restore(
+ "00000000-0000-0000-0000-000000000000",
+ "00000000-0000-0000-0000-000000000001",
+ )
+ assert rv.status_code == 404
+
+ def test_restore_returns_404_for_unknown_version_uuid(self) -> None:
+ _persist_fixture_state()
+ dashboard: Dashboard = (
+ db.session.query(Dashboard)
+ .filter(Dashboard.dashboard_title == "USA Births Names")
+ .first()
+ )
+ assert dashboard is not None
+ self.login(ADMIN_USERNAME)
+ rv = self._restore(str(dashboard.uuid),
"00000000-0000-0000-0000-000000000099")
+ assert rv.status_code == 404
+
+ def test_put_response_returns_old_and_new_version_numbers(self) -> None:
+ """PUT /api/v1/dashboard/<id> response must include old_version and
+ new_version matching the list-versions ordering."""
+ _persist_fixture_state()
+ dashboard: Dashboard = (
+ db.session.query(Dashboard)
+ .filter(Dashboard.dashboard_title == "USA Births Names")
+ .first()
+ )
+ assert dashboard is not None
+ dashboard_id = dashboard.id
+ original_title = dashboard.dashboard_title
+
+ ver_cls = version_class(Dashboard)
+ count_before = (
+ db.session.query(ver_cls).filter(ver_cls.id ==
dashboard_id).count()
+ )
+ expected_old = count_before - 1 if count_before > 0 else None
+
+ self.login(ADMIN_USERNAME)
+ rv = self.client.put(
+ f"/api/v1/dashboard/{dashboard_id}",
+ json={"dashboard_title": "put-response-version-test"},
+ )
+ assert rv.status_code == 200, rv.data
+ body = _json.loads(rv.data.decode("utf-8"))
+ assert body["id"] == dashboard_id
+ assert body["old_version"] == expected_old
+ assert body["new_version"] is not None
+ assert "old_transaction_id" in body
+ assert "new_transaction_id" in body
+ if body["old_transaction_id"] is not None:
+ assert body["new_transaction_id"] != body["old_transaction_id"]
+
+ # Cleanup
+ dashboard = (
+ db.session.query(Dashboard).filter(Dashboard.id ==
dashboard_id).one()
+ )
+ dashboard.dashboard_title = original_title
+ db.session.commit()
diff --git a/tests/integration_tests/datasets/version_restore_tests.py
b/tests/integration_tests/datasets/version_restore_tests.py
new file mode 100644
index 00000000000..f8e82baddae
--- /dev/null
+++ b/tests/integration_tests/datasets/version_restore_tests.py
@@ -0,0 +1,507 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
+"""Integration tests for Dataset (SqlaTable) version restore.
+
+Covers POST /api/v1/dataset/<uuid>/versions/<version_uuid>/restore: the
+non-destructive revert applies the target snapshot, reverts child
+column/metric edits (re-adding removed children and dropping added ones)
+in a single transaction, denies callers without write permission, and
+returns the documented 400/404 errors.
+"""
+
+from __future__ import annotations
+
+from typing import Any
+
+import pytest
+import sqlalchemy as sa
+from sqlalchemy_continuum import version_class
+
+from superset.connectors.sqla.models import SqlaTable, SqlMetric, TableColumn
+from superset.extensions import db
+from superset.utils import json as _json
+from tests.integration_tests.base_tests import SupersetTestCase
+from tests.integration_tests.constants import ADMIN_USERNAME, GAMMA_USERNAME
+from tests.integration_tests.fixtures.birth_names_dashboard import ( # noqa:
F401
+ load_birth_names_dashboard_with_slices,
+ load_birth_names_data,
+)
+
+
+def _get_table_column_version_rows(column: TableColumn) -> list[Any]:
+ ver_cls = version_class(TableColumn)
+ return (
+ db.session.query(ver_cls)
+ .filter(ver_cls.id == column.id)
+ .order_by(ver_cls.transaction_id.asc())
+ .all()
+ )
+
+
+def _get_sql_metric_version_rows(metric: SqlMetric) -> list[Any]:
+ ver_cls = version_class(SqlMetric)
+ return (
+ db.session.query(ver_cls)
+ .filter(ver_cls.id == metric.id)
+ .order_by(ver_cls.transaction_id.asc())
+ .all()
+ )
+
+
+def _get_table_version_rows(table: SqlaTable) -> list[Any]:
+ ver_cls = version_class(SqlaTable)
+ return (
+ db.session.query(ver_cls)
+ .filter(ver_cls.id == table.id)
+ .order_by(ver_cls.transaction_id.asc())
+ .all()
+ )
+
+
+def _persist_fixture_state() -> None:
+ """Force fixture's pending INSERTs to commit in their own transaction.
+
+ The birth_names fixture stages charts and the dashboard via session.add()
+ but does not commit. Without this, the test's first commit batches the
+ INSERTs and UPDATEs into the same Continuum transaction, causing the
+ existing version row to be updated in place instead of a new one being
+ created.
+ """
+ db.session.commit()
+
+
+class TestDatasetRestoreApi(SupersetTestCase):
+ """T039 — POST /api/v1/dataset/<uuid>/versions/<version_uuid>/restore."""
+
+ @pytest.fixture(autouse=True)
+ def _load_data(self, load_birth_names_dashboard_with_slices): # noqa:
PT004, F811
+ pass
+
+ def _restore(self, dataset_uuid: str, version_uuid: str) -> Any:
+ return self.client.post(
+ f"/api/v1/dataset/{dataset_uuid}/versions/{version_uuid}/restore"
+ )
+
+ def test_restore_applies_scalar_field(self) -> None:
+ """Restore a dataset's description edit."""
+ from superset.daos.version import derive_version_uuid
+
+ _persist_fixture_state()
+ table: SqlaTable = (
+ db.session.query(SqlaTable)
+ .filter(SqlaTable.table_name == "birth_names")
+ .first()
+ )
+ assert table is not None
+ table_uuid = str(table.uuid)
+ entity_uuid = table.uuid
+ table_id = table.id
+ original_description = table.description
+
+ # Two more edits to produce a non-trivial history.
+ table.description = "restore-test v1"
+ db.session.commit()
+ table.description = "restore-test v2"
+ db.session.commit()
+
+ ver_cls = version_class(SqlaTable)
+ rows = (
+ db.session.query(
+ ver_cls.transaction_id,
+ ver_cls.operation_type,
+ ver_cls.description,
+ )
+ .filter(ver_cls.id == table_id)
+ .order_by(ver_cls.transaction_id.asc())
+ .all()
+ )
+ target_row = next(
+ (row for row in rows if row.description == original_description),
+ None,
+ )
+ assert target_row is not None, (
+ f"No version with original description; rows={rows}"
+ )
+ target_uuid = str(derive_version_uuid(entity_uuid,
target_row.transaction_id))
+
+ self.login(ADMIN_USERNAME)
+ rv = self._restore(table_uuid, target_uuid)
+ assert rv.status_code == 200, rv.data
+
+ db.session.expire_all()
+ table = db.session.query(SqlaTable).filter(SqlaTable.id ==
table_id).one()
+ assert table.description == original_description
+
+ # Cleanup
+ table.description = original_description
+ db.session.commit()
+
+ def test_restore_with_column_edits_reverts_columns(self) -> None:
+ """After editing a column's description, restoring an earlier version
+ reverts the column."""
+ from superset.daos.version import derive_version_uuid
+
+ _persist_fixture_state()
+ table: SqlaTable = (
+ db.session.query(SqlaTable)
+ .filter(SqlaTable.table_name == "birth_names")
+ .first()
+ )
+ assert table is not None
+ table_uuid = str(table.uuid)
+ entity_uuid = table.uuid
+ table_id = table.id
+
+ col = table.columns[0]
+ col_name = col.column_name
+ original_col_description = col.description
+
+ # Snapshot target version before our column edit.
+ ver_cls = version_class(SqlaTable)
+ last_tx = (
+ db.session.query(ver_cls.transaction_id)
+ .filter(ver_cls.id == table_id)
+ .order_by(ver_cls.transaction_id.desc())
+ .limit(1)
+ .scalar()
+ )
+ assert last_tx is not None
+ target_uuid = str(derive_version_uuid(entity_uuid, last_tx))
+
+ col.description = "restore-test column edit"
+ db.session.commit()
+
+ self.login(ADMIN_USERNAME)
+ rv = self._restore(table_uuid, target_uuid)
+ assert rv.status_code == 200, rv.data
+
+ # JSON-snapshot restore reassigns child PKs, so look up by natural
+ # key (column_name) rather than the old id.
+ db.session.expire_all()
+ col = (
+ db.session.query(TableColumn)
+ .filter(TableColumn.table_id == table_id)
+ .filter(TableColumn.column_name == col_name)
+ .one()
+ )
+ assert col.description == original_col_description
+
+ # Cleanup
+ col.description = original_col_description
+ db.session.commit()
+
+ def test_restore_adds_back_removed_column_and_drops_added_one(self) ->
None:
+ """After a snapshot is taken, removing an existing column and adding
+ a new one, restoring the snapshot must undo both operations."""
+ from superset.daos.version import derive_version_uuid
+
+ _persist_fixture_state()
+ table: SqlaTable = (
+ db.session.query(SqlaTable)
+ .filter(SqlaTable.table_name == "birth_names")
+ .first()
+ )
+ assert table is not None
+ table_id = table.id
+ table_uuid = str(table.uuid)
+ entity_uuid = table.uuid
+
+ original_col_names = sorted(c.column_name for c in table.columns)
+ removed_name = table.columns[0].column_name
+
+ # Capture a snapshot tx point by touching the dataset.
+ table.description = "snapshot before column-swap"
+ db.session.commit()
+
+ ver_cls = version_class(SqlaTable)
+ target_tx = (
+ db.session.query(ver_cls.transaction_id)
+ .filter(ver_cls.id == table_id)
+ .order_by(ver_cls.transaction_id.desc())
+ .limit(1)
+ .scalar()
+ )
+ assert target_tx is not None
+ target_uuid = str(derive_version_uuid(entity_uuid, target_tx))
+
+ # Remove a column, add a new one, commit (moves history forward).
+ db.session.delete(table.columns[0])
+ db.session.add(
+ TableColumn(
+ table_id=table_id,
+ column_name="__restore_test_calc__",
+ expression="1",
+ )
+ )
+ db.session.commit()
+
+ assert removed_name not in {c.column_name for c in table.columns}
+ assert "__restore_test_calc__" in {c.column_name for c in
table.columns}
+
+ self.login(ADMIN_USERNAME)
+ rv = self._restore(table_uuid, target_uuid)
+ assert rv.status_code == 200, rv.data
+
+ db.session.expire_all()
+ table = db.session.query(SqlaTable).filter(SqlaTable.id ==
table_id).one()
+ restored_names = sorted(c.column_name for c in table.columns)
+ assert restored_names == original_col_names
+
+ def test_restore_emits_full_child_diff_in_one_transaction(self) -> None:
+ """A restore that re-adds one column and drops another MUST write
+ *both* change records under the same transaction. Under the prior
+ per-relation flush loop the first flush emitted only the
+ easier-to-detect change (the modification of a surviving
+ column), the listener's tx-dedup guard then suppressed the
+ second pass, and the addition record was silently lost from
+ ``version_changes`` — the dropdown rendered the restore as an
+ empty "Baseline" entry. Locks in the single-flush restore
+ behavior in ``VersionDAO.restore_version``.
+ """
+ from superset.daos.version import derive_version_uuid
+ from superset.versioning.changes import version_changes_table
+
+ _persist_fixture_state()
+ table: SqlaTable = (
+ db.session.query(SqlaTable)
+ .filter(SqlaTable.table_name == "birth_names")
+ .first()
+ )
+ assert table is not None
+ table_id = table.id
+ table_uuid = str(table.uuid)
+ entity_uuid = table.uuid
+ removed_name = table.columns[0].column_name
+ added_name = "__restore_full_diff_test__"
+
+ # Snapshot point captures the baseline.
+ table.description = "snapshot before full-diff column swap"
+ db.session.commit()
+
+ ver_cls = version_class(SqlaTable)
+ target_tx = (
+ db.session.query(ver_cls.transaction_id)
+ .filter(ver_cls.id == table_id)
+ .order_by(ver_cls.transaction_id.desc())
+ .limit(1)
+ .scalar()
+ )
+ assert target_tx is not None
+ target_uuid = str(derive_version_uuid(entity_uuid, target_tx))
+
+ db.session.delete(table.columns[0])
+ db.session.add(
+ TableColumn(table_id=table_id, column_name=added_name,
expression="1")
+ )
+ db.session.commit()
+
+ self.login(ADMIN_USERNAME)
+ rv = self._restore(table_uuid, target_uuid)
+ assert rv.status_code == 200, rv.data
+ db.session.expire_all()
+
+ restore_tx = (
+ db.session.query(ver_cls.transaction_id)
+ .filter(ver_cls.id == table_id)
+ .order_by(ver_cls.transaction_id.desc())
+ .limit(1)
+ .scalar()
+ )
+ rows = (
+ db.session.connection()
+ .execute(
+ sa.select(
+ version_changes_table.c.kind,
+ version_changes_table.c.path,
+ ).where(
+ version_changes_table.c.transaction_id == restore_tx,
+ version_changes_table.c.entity_kind == "dataset",
+ version_changes_table.c.entity_id == table_id,
+ )
+ )
+ .all()
+ )
+ paths = {tuple(row.path) for row in rows}
+ assert ("columns", added_name) in paths, (
+ f"restore tx {restore_tx} did not emit removal record for "
+ f"the added-then-restored-away column {added_name!r}; "
+ f"observed paths={paths}"
+ )
+ assert ("columns", removed_name) in paths, (
+ f"restore tx {restore_tx} did not emit addition record for "
+ f"the deleted-then-restored column {removed_name!r}; "
+ f"observed paths={paths}"
+ )
+
+ def test_restore_returns_404_for_unknown_uuid(self) -> None:
+ self.login(ADMIN_USERNAME)
+ rv = self._restore(
+ "00000000-0000-0000-0000-000000000000",
+ "00000000-0000-0000-0000-000000000001",
+ )
+ assert rv.status_code == 404
+
+ def test_restore_returns_404_for_unknown_version_uuid(self) -> None:
+ _persist_fixture_state()
+ table: SqlaTable = (
+ db.session.query(SqlaTable)
+ .filter(SqlaTable.table_name == "birth_names")
+ .first()
+ )
+ assert table is not None
+ self.login(ADMIN_USERNAME)
+ rv = self._restore(str(table.uuid),
"00000000-0000-0000-0000-000000000099")
+ assert rv.status_code == 404
+
+ def test_restore_returns_400_for_invalid_entity_uuid(self) -> None:
+ self.login(ADMIN_USERNAME)
+ rv = self._restore("not-a-uuid",
"00000000-0000-0000-0000-000000000001")
+ assert rv.status_code == 400
+
+ def test_restore_returns_400_for_invalid_version_uuid(self) -> None:
+ _persist_fixture_state()
+ table: SqlaTable = (
+ db.session.query(SqlaTable)
+ .filter(SqlaTable.table_name == "birth_names")
+ .first()
+ )
+ assert table is not None
+ self.login(ADMIN_USERNAME)
+ rv = self._restore(str(table.uuid), "not-a-uuid")
+ assert rv.status_code == 400
+
+ def test_get_version_returns_historical_snapshot_with_children(self) ->
None:
+ """GET /versions/<uuid>/ on a dataset returns scalar fields and
+ reconstructed columns/metrics, without modifying live state."""
+ from superset.daos.version import derive_version_uuid
+
+ _persist_fixture_state()
+ table: SqlaTable = (
+ db.session.query(SqlaTable)
+ .filter(SqlaTable.table_name == "birth_names")
+ .first()
+ )
+ assert table is not None
+ table_id = table.id
+ table_uuid = str(table.uuid)
+ entity_uuid = table.uuid
+ original_description = table.description
+ original_col_names = sorted(c.column_name for c in table.columns)
+
+ # Capture a snapshot point now; make a change after.
+ ver_cls = version_class(SqlaTable)
+ target_tx = (
+ db.session.query(ver_cls.transaction_id)
+ .filter(ver_cls.id == table_id)
+ .order_by(ver_cls.transaction_id.desc())
+ .limit(1)
+ .scalar()
+ )
+ assert target_tx is not None
+ target_uuid = str(derive_version_uuid(entity_uuid, target_tx))
+
+ table.description = "edited after snapshot"
+ db.session.commit()
+
+ self.login(ADMIN_USERNAME)
+ rv =
self.client.get(f"/api/v1/dataset/{table_uuid}/versions/{target_uuid}/")
+ assert rv.status_code == 200, rv.data
+ body = _json.loads(rv.data.decode("utf-8"))["result"]
+
+ # Scalar fields reflect the snapshot, not the live edit.
+ assert body["description"] == original_description
+ assert body["_version"]["version_uuid"] == target_uuid
+
+ # Columns list matches original set.
+ snapshot_col_names = sorted(c["column_name"] for c in body["columns"])
+ assert snapshot_col_names == original_col_names
+
+ # Metrics reconstructed.
+ assert isinstance(body["metrics"], list)
+ assert all("metric_name" in m for m in body["metrics"])
+
+ # Live row remains in its edited state.
+ db.session.expire_all()
+ live = db.session.query(SqlaTable).filter(SqlaTable.id ==
table_id).one()
+ assert live.description == "edited after snapshot"
+
+ # Cleanup
+ live.description = original_description
+ db.session.commit()
+
+ def test_put_response_returns_old_and_new_version_numbers(self) -> None:
+ """PUT /api/v1/dataset/<id> should include old_version and new_version
+ fields that match the list-versions endpoint's version_number
values."""
+ _persist_fixture_state()
+ table: SqlaTable = (
+ db.session.query(SqlaTable)
+ .filter(SqlaTable.table_name == "birth_names")
+ .first()
+ )
+ assert table is not None
+ table_id = table.id
+ original_description = table.description
+
+ ver_cls = version_class(SqlaTable)
+ count_before = db.session.query(ver_cls).filter(ver_cls.id ==
table_id).count()
+ expected_old = count_before - 1 if count_before > 0 else None
+
+ self.login(ADMIN_USERNAME)
+ rv = self.client.put(
+ f"/api/v1/dataset/{table_id}",
+ json={"description": "version-number response test"},
+ )
+ assert rv.status_code == 200, rv.data
+ body = _json.loads(rv.data.decode("utf-8"))
+ assert body["id"] == table_id
+ assert "old_version" in body
+ assert "new_version" in body
+ assert "old_transaction_id" in body
+ assert "new_transaction_id" in body
+ assert body["old_version"] == expected_old
+ # new_version points to the live row post-commit. It is usually
+ # old_version + 1, but can equal old_version when retention pruning
+ # removed an older closed row in the same commit.
+ assert body["new_version"] is not None
+ assert body["new_version"] >= 0
+ # Transaction ids are stable identifiers, so a successful update
+ # always produces a new_transaction_id distinct from the previous
+ # one (when old_transaction_id is known).
+ if body["old_transaction_id"] is not None:
+ assert body["new_transaction_id"] != body["old_transaction_id"]
+
+ # Cleanup
+ table = db.session.query(SqlaTable).filter(SqlaTable.id ==
table_id).one()
+ table.description = original_description
+ db.session.commit()
+
+ def test_restore_denies_without_write_permission(self) -> None:
+ """Gamma is read-only on Dataset — 403 on restore."""
+ _persist_fixture_state()
+ table: SqlaTable = (
+ db.session.query(SqlaTable)
+ .filter(SqlaTable.table_name == "birth_names")
+ .first()
+ )
+ assert table is not None
+ table_uuid = str(table.uuid)
+
+ self.login(GAMMA_USERNAME)
+ rv = self._restore(table_uuid, "00000000-0000-0000-0000-000000000001")
+ assert rv.status_code == 403
+ db.session.refresh(table)
+ assert table.table_name == "birth_names"
diff --git a/tests/unit_tests/versioning/test_restore.py
b/tests/unit_tests/versioning/test_restore.py
new file mode 100644
index 00000000000..4e1c2144426
--- /dev/null
+++ b/tests/unit_tests/versioning/test_restore.py
@@ -0,0 +1,133 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
+"""Unit-level coverage for the version-restore engine control flow.
+
+The happy path is exercised end-to-end in the per-entity integration
+suites (``version_restore_tests.py``); here we pin the cheap, DB-free
+guard branches with mocks: unknown entity, missing target transaction,
+DELETE-row target, unregistered model, and the ``single_flush_scope``
+flush contract.
+"""
+
+from __future__ import annotations
+
+from unittest.mock import MagicMock, patch
+from uuid import UUID
+
+import pytest
+
+from superset.versioning.baseline import OPERATION_DELETE
+from superset.versioning.restore import restore_version
+from superset.versioning.utils import single_flush_scope
+
+_UUID = UUID("00000000-0000-0000-0000-000000000000")
+
+
+@patch("superset.versioning.restore.find_active_by_uuid", return_value=None)
+def test_restore_version_returns_none_for_unknown_entity(mock_find) -> None:
+ """Unknown entity UUID → engine returns None (caller raises 404)."""
+ result = restore_version(MagicMock(__name__="Dashboard"), _UUID, 123)
+ assert result is None
+ mock_find.assert_called_once()
+
+
+def _engine_with_target(target: object) -> MagicMock:
+ """A db.session mock whose version query resolves to *target*."""
+ session = MagicMock()
+ session.query.return_value.filter.return_value.one_or_none.return_value =
target
+ return session
+
+
+@patch("superset.versioning.restore.version_class")
+@patch("superset.versioning.restore.db")
+def test_restore_version_returns_none_for_missing_transaction(
+ mock_db, mock_version_class
+) -> None:
+ """No version row at the resolved transaction_id → None (404), e.g.
+ the row was retention-pruned between resolve and restore."""
+ mock_db.session = _engine_with_target(None)
+ result = restore_version(
+ MagicMock(__name__="Slice"), _UUID, 123, entity=MagicMock(id=1,
uuid=_UUID)
+ )
+ assert result is None
+
+
+@patch("superset.versioning.restore.version_class")
+@patch("superset.versioning.restore.db")
+def test_restore_version_refuses_delete_row_target(mock_db,
mock_version_class) -> None:
+ """A DELETE version row is never a valid target: Continuum's Reverter
+ would delete the live entity and report success. Engine treats it as
+ not-found."""
+ target = MagicMock(operation_type=OPERATION_DELETE)
+ mock_db.session = _engine_with_target(target)
+ result = restore_version(
+ MagicMock(__name__="Slice"), _UUID, 123, entity=MagicMock(id=1,
uuid=_UUID)
+ )
+ assert result is None
+ target.revert.assert_not_called()
+
+
+@patch("superset.versioning.restore.version_class")
+@patch("superset.versioning.restore.db")
+def test_restore_version_fails_closed_for_unregistered_model(
+ mock_db, mock_version_class
+) -> None:
+ """An unregistered model must raise, not silently restore without its
+ child relations (mirrors _RAISE_FOR_ACCESS_KWARG's fail-closed
+ dispatch)."""
+ mock_db.session = _engine_with_target(MagicMock(operation_type=0))
+ with pytest.raises(LookupError, match="SomeNewModel"):
+ restore_version(
+ MagicMock(__name__="SomeNewModel"),
+ _UUID,
+ 123,
+ entity=MagicMock(id=1, uuid=_UUID),
+ )
+
+
+@patch("superset.versioning.restore.version_class")
+@patch("superset.versioning.restore.db")
+def test_restore_version_rejects_entity_uuid_mismatch(
+ mock_db, mock_version_class
+) -> None:
+ """A preloaded *entity* must be the row *entity_uuid* names. If they
+ disagree the engine would restore one entity while the caller logs
+ another, so it raises instead of guessing."""
+ mock_db.session = _engine_with_target(MagicMock(operation_type=0))
+ other_uuid = UUID("00000000-0000-0000-0000-0000000000ff")
+ with pytest.raises(ValueError, match="does not match entity_uuid"):
+ restore_version(
+ MagicMock(__name__="Slice"),
+ _UUID,
+ 123,
+ entity=MagicMock(id=1, uuid=other_uuid),
+ )
+
+
+def test_single_flush_scope_flushes_once_on_clean_exit() -> None:
+ session = MagicMock()
+ with single_flush_scope(session):
+ session.flush.assert_not_called()
+ session.flush.assert_called_once()
+
+
+def test_single_flush_scope_skips_flush_on_exception() -> None:
+ session = MagicMock()
+ with pytest.raises(RuntimeError):
+ with single_flush_scope(session):
+ raise RuntimeError("boom")
+ session.flush.assert_not_called()