Files
superset2/tests/integration_tests/dashboards/version_history_tests.py
Mike Bridge b68fea17af feat(versioning): foundation — Continuum capture + parent/child shadow tables + VersionDAO
Adds SQLAlchemy-Continuum as a dependency and wires it as the
canonical capture mechanism for chart, dashboard, and dataset edits.

**Schema** — three Alembic migrations, leaving the chain at one
foundation revision plus one child-shadow revision:

- ``version_transaction`` (renamed from Continuum's default
  ``transaction``; SQL-reserved-word workaround) carries the per-save
  ``user_id`` / ``issued_at`` and is the join target for all shadow
  rows. Auto-incrementing PK; user_id has no FK so import / Celery /
  CLI saves can write rows without an active Flask user.
- Parent shadow tables for the three entity types:
  ``dashboards_version``, ``slices_version``, ``tables_version``.
- Child shadow tables for dataset children + dashboard M2M:
  ``table_columns_version``, ``sql_metrics_version``,
  ``dashboard_slices_version`` (composite PK on the M2M shadow,
  matching the live ``dashboard_slices`` reshape from
  sc-105349-composite-association-pks).

**Models** — ``Dashboard``, ``Slice``, ``SqlaTable`` (and dataset
children ``TableColumn`` / ``SqlMetric``) gain ``__versioned__``
class attributes. The exclude lists carry both M2M relationships
(``owners``, ``roles``, ``dashboards``) and the ``AuditMixin``
columns (``changed_on`` / ``created_on`` / ``changed_by_fk`` /
``created_by_fk`` plus ``last_saved_at`` / ``last_saved_by_fk``
on ``Slice``) so auto-bumped audit fields cannot trigger a
version row on their own (FR-025).

**Plugins** — ``superset/versioning/factory.py`` ships three
Continuum plugins:

- ``VersionTransactionFactory`` renames the transaction table and
  appends the unconditional ``user_id`` column.
- ``VersioningFlaskPlugin`` sources the acting user from Superset's
  ``g.user`` rather than ``flask_login.current_user`` (Superset's
  JWT auth populates ``g.user`` but leaves ``current_user``
  anonymous on API routes).
- ``SkipUnmodifiedPlugin`` filters Continuum's UPDATE operations,
  marking content-equivalent re-saves as ``processed=True`` so they
  don't mint no-op shadow rows (FR-026; see follow-up commits for
  the test). Lives in this commit because it shares the file with
  the other plugins.

**Save-path glue** — a ``before_flush`` baseline listener
(``superset/versioning/baseline.py``) inserts an ``operation_type=0``
shadow row the first time a pre-existing entity is saved, including
the slice-baseline-under-dashboard pattern that gives the dashboard
M2M shadow a row to join against. ``UpdateDashboardCommand`` wraps
its body in ``no_autoflush`` so ``process_tab_diff`` /
``process_native_filter_diff`` don't fire intermediate flushes that
would mint extra version rows. ``DatasetDAO.update_columns`` is
rewritten as a natural-key upsert keyed on ``column_name`` so child
edits flow through ORM events Continuum sees.

**DAO** — ``superset/daos/version.py`` exposes the read API used by
the version endpoints in the next commits:
``current_version_number`` (0-based index, unstable under retention
pruning), ``current_live_transaction_id`` (stable across pruning),
``current_live_version_uuid`` (deterministic UUIDv5), plus
``list_versions`` / ``get_version`` / ``restore_version`` and a
batch ``list_change_records_batch`` for N+1 avoidance.

**Initialization** — ``superset/initialization/__init__.py`` wires
``init_versioning()`` after ``make_versioned()`` runs and the
versioned mappers are configured. Registers the baseline listener
plus the change-record listener (the latter's body lives in the
next commit but the registration site is here because it shares
the init function).

**Tests** — version-capture and version-list integration tests for
each entity type, plus a ``VersionDAO`` unit test suite. Retention
test uses a backdated ``issued_at`` so it can drive
``_prune_old_versions_impl`` synchronously.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 15:37:19 -06:00

520 lines
20 KiB
Python

# 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 history capture.
T015 — dashboard version capture (single version per save; no extra rows from
process_tab_diff)
T018 — retention pruning (keep at most SUPERSET_VERSION_HISTORY_MAX_VERSIONS)
T027 — dashboard version list endpoint
"""
from __future__ import annotations
from typing import Any
import pytest
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 TestDashboardVersionCapture(SupersetTestCase):
"""T015 — one version row per save; no multiple rows from tab/filter diff processing.""" # noqa: E501
@pytest.fixture(autouse=True)
def _load_data(self, load_birth_names_dashboard_with_slices): # noqa: PT004, F811
pass
def test_single_save_creates_one_version_row(self) -> None:
"""Saving a dashboard title creates exactly one update version row."""
_persist_fixture_state()
dashboard: Dashboard = (
db.session.query(Dashboard)
.filter(Dashboard.dashboard_title == "USA Births Names")
.first()
)
assert dashboard is not None
# Capture tx IDs that exist before this save — we'll verify that
# exactly ONE new tx_id with operation_type=1 appears after the save
# (comparing by tx_id makes the test robust against retention
# pruning of older rows).
tx_ids_before = {r.transaction_id for r in _get_version_rows(dashboard)}
original_title = dashboard.dashboard_title
dashboard.dashboard_title = "USA Births Names (edited)"
db.session.commit()
rows_after = _get_version_rows(dashboard)
new_update_rows = [
r
for r in rows_after
if r.operation_type == 1 and r.transaction_id not in tx_ids_before
]
assert len(new_update_rows) == 1, (
f"Expected 1 new update row from this save, got {len(new_update_rows)}"
" — possible no_autoflush regression"
)
# Cleanup
dashboard.dashboard_title = original_title
db.session.commit()
def test_second_save_adds_one_row(self) -> None:
"""Each subsequent save adds exactly one more version row."""
_persist_fixture_state()
dashboard: Dashboard = (
db.session.query(Dashboard)
.filter(Dashboard.dashboard_title == "USA Births Names")
.first()
)
assert dashboard is not None
original_title = dashboard.dashboard_title
# Track tx IDs across saves; compare by tx_id to sidestep retention
# pruning of older rows.
tx_before_v1 = {r.transaction_id for r in _get_version_rows(dashboard)}
dashboard.dashboard_title = "USA Births Names v1"
db.session.commit()
tx_after_v1 = {r.transaction_id for r in _get_version_rows(dashboard)}
new_txs_v1 = tx_after_v1 - tx_before_v1
assert len(new_txs_v1) == 1, (
f"Expected 1 new tx from v1 save, got {len(new_txs_v1)}"
)
dashboard.dashboard_title = "USA Births Names v2"
db.session.commit()
tx_after_v2 = {r.transaction_id for r in _get_version_rows(dashboard)}
new_txs_v2 = tx_after_v2 - tx_after_v1
assert len(new_txs_v2) == 1, (
f"Expected 1 new tx from v2 save, got {len(new_txs_v2)}"
)
# Cleanup
dashboard.dashboard_title = original_title
db.session.commit()
class TestDashboardVersionRetention(SupersetTestCase):
"""T018 — retention pruning caps history at SUPERSET_VERSION_HISTORY_MAX_VERSIONS.""" # noqa: E501
@pytest.fixture(autouse=True)
def _load_data(self, load_birth_names_dashboard_with_slices): # noqa: PT004, F811
pass
def test_retention_prunes_old_rows(self) -> None:
"""``prune_old_versions`` removes shadow rows whose owning
``version_transaction.issued_at`` is older than the retention
window, while preserving the live row and the baseline."""
from datetime import datetime, timedelta
import sqlalchemy as sa
from superset.extensions import db as _db
from superset.tasks.version_history_retention import (
_prune_old_versions_impl,
)
_persist_fixture_state()
dashboard: Dashboard = (
db.session.query(Dashboard)
.filter(Dashboard.dashboard_title == "USA Births Names")
.first()
)
assert dashboard is not None
original_title = dashboard.dashboard_title
try:
# Force a few saves so we have ≥ 2 closed shadow rows plus
# a baseline plus the live row.
for i in range(3):
dashboard.dashboard_title = f"USA Births Names retention test {i}"
db.session.commit()
rows_before = _get_version_rows(dashboard)
assert len(rows_before) >= 3, "Expected at least 3 version rows"
# Backdate every version_transaction row by 100 days so the
# prune sees them as old. Skip baseline+live rows; the prune
# itself preserves them.
from sqlalchemy_continuum import versioning_manager
tx_table = versioning_manager.transaction_cls.__table__
with _db.engine.begin() as conn:
conn.execute(
sa.update(tx_table).values(
issued_at=datetime.utcnow() - timedelta(days=100)
)
)
stats = _prune_old_versions_impl(retention_days=30)
assert stats.get("pruned_transactions", 0) >= 1, stats
rows_after = _get_version_rows(dashboard)
# Live row must still exist (this is the only preservation rule)
live_rows = [r for r in rows_after if r.end_transaction_id is None]
assert len(live_rows) >= 1, "Live row must never be pruned"
# Some rows should have been pruned. Closed historical rows —
# including the synthetic baseline (operation_type=0) — are
# subject to retention like everything else.
assert len(rows_after) < len(rows_before), (
f"Expected fewer rows after prune; before={len(rows_before)} "
f"after={len(rows_after)}"
)
finally:
dashboard.dashboard_title = original_title
db.session.commit()
class TestDashboardVersionListApi(SupersetTestCase):
"""T027 — GET /api/v1/dashboard/<uuid>/versions/ endpoint."""
@pytest.fixture(autouse=True)
def _load_data(self, load_birth_names_dashboard_with_slices): # noqa: PT004, F811
pass
def _list_versions(self, dashboard_uuid: str) -> Any:
return self.client.get(f"/api/v1/dashboard/{dashboard_uuid}/versions/")
def test_list_versions_returns_ordered_sequence(self) -> None:
"""Saving a dashboard three times extends the version list by three."""
_persist_fixture_state()
dashboard: Dashboard = (
db.session.query(Dashboard)
.filter(Dashboard.dashboard_title == "USA Births Names")
.first()
)
assert dashboard is not None
original_title = dashboard.dashboard_title
dashboard_uuid = str(dashboard.uuid)
self.login(ADMIN_USERNAME)
rv = self._list_versions(dashboard_uuid)
assert rv.status_code == 200
assert "count" in _json.loads(rv.data.decode("utf-8"))
for i in range(3):
dashboard.dashboard_title = f"USA Births Names v{i}"
db.session.commit()
rv = self._list_versions(dashboard_uuid)
assert rv.status_code == 200
body = _json.loads(rv.data.decode("utf-8"))
# Delta-based assertion — retention pruning from other tests can lower
# the absolute count, but each of our three saves must produce exactly
# one new entry. We compare by transaction_id instead.
assert len(body["result"]) == body["count"]
for idx, entry in enumerate(body["result"]):
assert entry["version_number"] == idx
# Cleanup
dashboard.dashboard_title = original_title
db.session.commit()
def test_list_versions_empty_for_untouched_entity(self) -> None:
"""A dashboard with no version rows returns [] (not 404)."""
_persist_fixture_state()
dashboard = Dashboard(dashboard_title="Untouched dashboard", slug="untouched")
db.session.add(dashboard)
db.session.commit()
dashboard_uuid = str(dashboard.uuid)
ver_cls = version_class(Dashboard)
db.session.query(ver_cls).filter(ver_cls.id == dashboard.id).delete(
synchronize_session=False
)
db.session.commit()
self.login(ADMIN_USERNAME)
rv = self._list_versions(dashboard_uuid)
assert rv.status_code == 200
body = _json.loads(rv.data.decode("utf-8"))
assert body["count"] == 0
assert body["result"] == []
# Cleanup
db.session.delete(dashboard)
db.session.commit()
def test_list_versions_returns_404_for_unknown_uuid(self) -> None:
"""An unknown UUID returns 404."""
self.login(ADMIN_USERNAME)
rv = self._list_versions("00000000-0000-0000-0000-000000000000")
assert rv.status_code == 404
def test_list_versions_returns_400_for_invalid_uuid(self) -> None:
"""A malformed UUID string is rejected with 400."""
self.login(ADMIN_USERNAME)
rv = self._list_versions("not-a-uuid")
assert rv.status_code == 400
@pytest.mark.skip(
reason=(
"Superset's default Gamma role has can_write on Dashboard — there "
"is no built-in no-write user to exercise the 403 branch for this "
"resource. See dataset tests (T028) for a working 403 check."
)
)
def test_list_versions_denies_without_write_permission(self) -> None:
"""A user without can_write on Dashboard gets 403."""
def test_list_versions_admin_sees_all_entities(self) -> None:
"""FR-013: workspace admin can list versions for any entity."""
_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)
self.login(ADMIN_USERNAME)
rv = self._list_versions(dashboard_uuid)
assert rv.status_code == 200
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_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()