Files
superset2/superset/versioning/queries.py
Mike Bridge a899e1db41 feat(versioning): entity-version base infrastructure (gated off, dark launch) (#41176)
Co-authored-by: Mike Bridge <michael.bridge@ext.preset.io>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-09 19:57:05 -07:00

519 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.
"""Read-side queries for the entity-versioning API.
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
read helpers here.
Also exposes the deterministic version-UUID derivation
(:data:`VERSION_UUID_NAMESPACE` + :func:`derive_version_uuid`) used by
both the read endpoints and the ETag emission path in
:mod:`superset.versioning.etag`.
"""
from __future__ import annotations
import uuid
from typing import Any
from uuid import UUID
import sqlalchemy as sa
from flask_appbuilder import Model
from sqlalchemy_continuum import version_class
from superset.extensions import db
from superset.versioning.baseline import CONTINUUM_BOOKKEEPING_COLUMNS
# Fixed UUIDv5 namespace under which per-(entity, transaction) version UUIDs
# are derived. Never change this constant — changing it invalidates every
# version_uuid that clients may have cached, bookmarked, or stored.
VERSION_UUID_NAMESPACE = UUID("7a6f5d9b-4c3b-5d8e-9a1c-0e2b4c6d8f10")
# Continuum's integer ``operation_type`` mapped to the string the API
# returns. Kept short and stable for downstream tooling consuming the
# raw response. Continuum guarantees 0/1/2; anything else is a Continuum
# version mismatch and surfaces as ``str(int)`` rather than crashing.
_OP_TYPE_LABELS: dict[int, str] = {0: "baseline", 1: "update", 2: "delete"}
def derive_version_uuid(entity_uuid: UUID, transaction_id: int) -> UUID:
"""Derive a deterministic UUIDv5 identifying one version row.
The UUID is a function of the owning entity's UUID and the Continuum
``transaction_id`` of the version row, so it is stable across retention
pruning (which never changes ``transaction_id``) and portable across
replicas. It is not randomly generated — two Supersets with identical
``(entity.uuid, transaction_id)`` will compute the same version_uuid.
"""
return uuid.uuid5(VERSION_UUID_NAMESPACE, f"{entity_uuid}:{transaction_id}")
def _resolve_version_tables(
model_cls: type[Model],
) -> tuple[sa.Table, sa.Table, sa.Table]:
"""Return the (version, transaction, user) ``Table`` objects used by the
listing and snapshot queries.
All three lookups happen inside this module on every read; centralising
the trio (a) keeps the imports in one place and (b) makes the join helper
below take a uniform signature.
"""
# pylint: disable=import-outside-toplevel
from sqlalchemy_continuum import versioning_manager
from superset import security_manager
ver_tbl = version_class(model_cls).__table__
tx_tbl = versioning_manager.transaction_cls.__table__
user_tbl = security_manager.user_model.__table__
return ver_tbl, tx_tbl, user_tbl
def _version_with_tx_user_join(
ver_tbl: sa.Table, tx_tbl: sa.Table, user_tbl: sa.Table
) -> Any:
"""Build the version → transaction → user left-join used by both
:func:`list_versions` and :func:`get_version`. The user-side join is
a left-outer so saves with no Flask user context (CLI, Celery, import)
still surface in the result with ``changed_by = None``.
"""
return ver_tbl.join(tx_tbl, ver_tbl.c.transaction_id == tx_tbl.c.id).outerjoin(
user_tbl, tx_tbl.c.user_id == user_tbl.c.id
)
def _baseline_first_ordering(ver_tbl: sa.Table) -> tuple[Any, ...]:
"""Order ``(operation_type != 0).asc(), transaction_id.asc()`` so any
op=0 row — Continuum's INSERT or our synthetic baseline — sorts to
position 0 regardless of its transaction_id. A single entity never has
more than one op=0 row (Continuum tracks one creation per live entity;
our baseline listener only fires when no prior version rows exist), so
this gives a stable chronological order with the "original" version
always first.
"""
return (
(ver_tbl.c.operation_type != 0).asc(),
ver_tbl.c.transaction_id.asc(),
)
def _user_select_cols(user_tbl: sa.Table) -> list[Any]:
"""Columns to select from ``user_tbl`` to build a ``changed_by`` dict.
Labels ``user_tbl.c.id`` as ``"user_id"`` so callers can read the row
by a stable key regardless of whether they also select the version
table's ``id`` column.
"""
return [
user_tbl.c.id.label("user_id"),
user_tbl.c.username,
user_tbl.c.first_name,
user_tbl.c.last_name,
]
def _changed_by_from_row(row: Any) -> dict[str, Any] | None:
"""Project the user columns from a query row onto the API's
``changed_by`` shape, or ``None`` for saves with no Flask user context
(CLI / Celery / import / unauthenticated). Expects the user columns to
have been selected via :func:`_user_select_cols` so the row keys are
``user_id`` / ``username`` / ``first_name`` / ``last_name``.
"""
if row["user_id"] is None:
return None
return {
"id": row["user_id"],
"username": row["username"],
"first_name": row["first_name"],
"last_name": row["last_name"],
}
def _entity_kind_for(model_cls: type[Model]) -> str | None:
"""Return the ``version_changes.entity_kind`` value for *model_cls*, or
``None`` when the class isn't in the change-records taxonomy."""
# pylint: disable=import-outside-toplevel
from superset.versioning.changes import ENTITY_KIND_BY_CLASS_NAME
return ENTITY_KIND_BY_CLASS_NAME.get(model_cls.__name__)
def find_active_by_uuid(model_cls: type[Model], entity_uuid: UUID) -> Any | None:
"""Return the live entity matching *entity_uuid*, or None if not found."""
return (
db.session.query(model_cls).filter(model_cls.uuid == entity_uuid).one_or_none()
)
def _get_version_count(model_cls: type[Model], entity_id: int) -> int:
"""Return the number of historical version rows for *entity_id*."""
ver_cls = version_class(model_cls)
return (
db.session.query(sa.func.count())
.select_from(ver_cls)
.filter(ver_cls.id == entity_id)
.scalar()
or 0
)
def current_version_number(model_cls: type[Model], entity_id: int) -> int | None:
"""Return the 0-based ``version_number`` of the live row for *entity_id*
— equivalent to the index of the most recent entry that
:func:`list_versions` would return, or ``None`` when the entity has no
version rows yet.
Note: this index is *unstable under retention pruning*. The scheduled
retention task drops shadow rows older than the configured
retention window, so the same integer can refer to different rows
before and after a prune cycle. Use
:func:`current_live_transaction_id` for a stable identifier.
"""
count = _get_version_count(model_cls, entity_id)
return count - 1 if count > 0 else None
def current_live_transaction_id(model_cls: type[Model], entity_id: int) -> int | None:
"""Return the Continuum ``transaction_id`` of the live row for
*entity_id* — stable across retention pruning, unlike the index
returned by :func:`current_version_number`.
"""
ver_cls = version_class(model_cls)
row = (
db.session.query(ver_cls.transaction_id)
.filter(ver_cls.id == entity_id)
.filter(ver_cls.end_transaction_id.is_(None))
.order_by(ver_cls.transaction_id.desc())
.limit(1)
.first()
)
return row[0] if row else None
def current_live_version_uuid(
model_cls: type[Model], entity_id: int, entity_uuid: UUID
) -> UUID | None:
"""Return the deterministic ``version_uuid`` of the live row, or
``None`` when the entity has no version rows yet."""
tx_id = current_live_transaction_id(model_cls, entity_id)
if tx_id is None:
return None
return derive_version_uuid(entity_uuid, tx_id)
def list_change_records_batch(
entity_kind: str,
entity_id: int,
transaction_ids: list[int],
) -> dict[int, list[dict[str, Any]]]:
"""Return ``version_changes`` rows keyed by ``transaction_id``.
Batches the lookup across multiple transactions with a single
``WHERE transaction_id IN (...) AND entity_kind = ? AND entity_id = ?``
query so the list endpoint avoids N+1 round-trips. Rows are
distributed into per-tx lists sorted by ``sequence`` ascending
(matching the replay order the diff engine emits). Missing
transactions are represented by an empty list in the result so
callers can use ``result.get(tx_id, [])`` without guarding.
If the ``version_changes`` table is missing (pre-migration or
freshly downgraded), returns an empty dict rather than propagating
the error — consistent with this being a descriptive layer that
should not break the list endpoint.
"""
# pylint: disable=import-outside-toplevel
from superset.versioning.changes import version_changes_table
if not transaction_ids:
return {}
# SAVEPOINT so a missing-table failure can't poison the enclosing
# transaction: on PostgreSQL a failed statement aborts the tx, and
# every later query in the request would raise InFailedSqlTransaction
# even though the exception below was caught.
try:
with db.session.connection().begin_nested():
rows = (
db.session.connection()
.execute(
sa.select(
version_changes_table.c.transaction_id,
version_changes_table.c.sequence,
version_changes_table.c.kind,
version_changes_table.c.operation,
version_changes_table.c.path,
version_changes_table.c.from_value,
version_changes_table.c.to_value,
)
.where(
version_changes_table.c.entity_kind == entity_kind,
version_changes_table.c.entity_id == entity_id,
version_changes_table.c.transaction_id.in_(transaction_ids),
)
.order_by(
version_changes_table.c.transaction_id.asc(),
version_changes_table.c.sequence.asc(),
)
)
.mappings()
.all()
)
except (sa.exc.OperationalError, sa.exc.ProgrammingError):
# Missing version_changes table: OperationalError on SQLite/MySQL,
# ProgrammingError (UndefinedTable) on PostgreSQL.
return {}
grouped: dict[int, list[dict[str, Any]]] = {tx: [] for tx in transaction_ids}
for row in rows:
grouped[row["transaction_id"]].append(
{
"kind": row["kind"],
"operation": row["operation"],
"path": row["path"],
"from_value": row["from_value"],
"to_value": row["to_value"],
}
)
return grouped
def list_versions(
model_cls: type[Model],
entity_uuid: UUID,
*,
entity: Any | None = None,
) -> list[dict[str, Any]] | None:
"""Return the version history for the entity identified by *entity_uuid*.
Returns ``None`` when no active entity matches the UUID — callers should
translate that into a 404. Returns an empty list when the entity exists
but has no version rows yet (pre-migration, or never edited).
The list is ordered by ``transaction_id`` ascending and each entry is
assigned a 0-based sequential ``version_number``. ``operation_type`` is
mapped from Continuum's integer constants to a string (``0`` → baseline,
``1`` → update, ``2`` → delete). ``changed_by`` is the User row keyed
off ``version_transaction.user_id``, or ``None`` when the save had no
Flask user context (CLI, import, etc.).
Pass *entity* to skip the ``find_active_by_uuid`` lookup when the
caller has already resolved the entity (API handlers do this to enforce
``raise_for_ownership`` before calling here). The skip saves one
``WHERE uuid = ?`` query — that lookup isn't identity-map-cacheable
because ``uuid`` is a unique non-PK column.
"""
if entity is None:
entity = find_active_by_uuid(model_cls, entity_uuid)
if entity is None:
return None
ver_tbl, tx_tbl, user_tbl = _resolve_version_tables(model_cls)
stmt = (
sa.select(
ver_tbl.c.transaction_id,
ver_tbl.c.operation_type,
tx_tbl.c.issued_at,
*_user_select_cols(user_tbl),
)
.select_from(_version_with_tx_user_join(ver_tbl, tx_tbl, user_tbl))
.where(ver_tbl.c.id == entity.id)
.order_by(*_baseline_first_ordering(ver_tbl))
)
rows = db.session.execute(stmt).mappings().all()
# Batch-load change records for every listed transaction in one query.
# ``entity_kind`` is derived from the model class so the API
# filter ``WHERE entity_kind = 'chart' AND entity_id = ?`` can be
# precise when multiple versioned entities share a flush.
changes_by_tx: dict[int, list[dict[str, Any]]] = {}
if (entity_kind := _entity_kind_for(model_cls)) is not None:
tx_ids = [row["transaction_id"] for row in rows]
changes_by_tx = list_change_records_batch(entity_kind, entity.id, tx_ids)
return [
{
"version_uuid": derive_version_uuid(entity_uuid, row["transaction_id"]),
"version_number": version_number,
"transaction_id": row["transaction_id"],
"operation_type": _OP_TYPE_LABELS.get(
row["operation_type"], str(row["operation_type"])
),
"issued_at": row["issued_at"],
"changed_by": _changed_by_from_row(row),
"changes": changes_by_tx.get(row["transaction_id"], []),
}
for version_number, row in enumerate(rows)
]
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 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.
Implementation note: the loop re-derives ``version_uuid`` per
transaction in Python because there's no portable SQL form for a
UUIDv5 derivation across PostgreSQL / MySQL / SQLite (Postgres has
``uuid_generate_v5``; the other two do not). The iteration count is
bounded by the configured retention window worth of edits — the
retention task ages older shadow rows out — so the
practical N is at most a few hundred. If retention is ever
disabled on a heavily-edited entity, this loop is the
place to revisit.
Pass *entity* to skip the ``find_active_by_uuid`` lookup; see
:func:`list_versions` for the rationale.
"""
if entity is None:
entity = find_active_by_uuid(model_cls, entity_uuid)
if entity is None:
return None
ver_cls = version_class(model_cls)
tx_ids = (
db.session.query(ver_cls.transaction_id)
.filter(ver_cls.id == entity.id)
.order_by(
(ver_cls.operation_type != 0).asc(),
ver_cls.transaction_id.asc(),
)
.all()
)
for version_number, (tx_id,) in enumerate(tx_ids):
if derive_version_uuid(entity_uuid, tx_id) == version_uuid:
return version_number
return None
def get_version(
model_cls: type[Model],
entity_uuid: UUID,
version_uuid: UUID,
*,
entity: Any | None = None,
) -> dict[str, Any] | None:
"""Return the entity's state at the specified version as a dict.
Read-only — nothing in the live database is modified. The returned
shape is intended to mirror a regular single-entity GET response
(scalar columns plus restored ``columns`` / ``metrics`` lists for
``SqlaTable``), with a ``_version`` key holding the version-level
metadata (uuid, transaction_id, operation_type, issued_at,
changed_by) so callers can tell which version they're looking at.
Returns ``None`` when either *entity_uuid* or *version_uuid* does not
match — callers should translate to 404.
Pass *entity* to skip the ``find_active_by_uuid`` lookup; see
:func:`list_versions` for the rationale. The same *entity* is threaded
into :func:`resolve_version_uuid` to eliminate a second redundant
lookup on the same request.
"""
# pylint: disable=import-outside-toplevel
from superset.connectors.sqla.models import SqlaTable
if entity is None:
entity = find_active_by_uuid(model_cls, entity_uuid)
if entity is None:
return None
version_num = resolve_version_uuid(
model_cls, entity_uuid, version_uuid, entity=entity
)
if version_num is None:
return None
ver_tbl, tx_tbl, user_tbl = _resolve_version_tables(model_cls)
stmt = (
sa.select(
ver_tbl,
tx_tbl.c.issued_at,
*_user_select_cols(user_tbl),
)
.select_from(_version_with_tx_user_join(ver_tbl, tx_tbl, user_tbl))
.where(ver_tbl.c.id == entity.id)
.order_by(*_baseline_first_ordering(ver_tbl))
.offset(version_num)
.limit(1)
)
row = db.session.execute(stmt).mappings().first()
if row is None:
return None
# Project the entity's own scalar fields, skipping versioning
# metadata columns.
result: dict[str, Any] = {}
for col in ver_tbl.columns:
if col.name in CONTINUUM_BOOKKEEPING_COLUMNS:
continue
value = row[col.name]
# uuid columns come back as UUID instances; make them JSON-safe.
if isinstance(value, UUID):
value = str(value)
result[col.name] = value
changes: list[dict[str, Any]] = []
if (entity_kind := _entity_kind_for(model_cls)) is not None:
changes = list_change_records_batch(
entity_kind, entity.id, [row["transaction_id"]]
).get(row["transaction_id"], [])
result["_version"] = {
"version_uuid": str(version_uuid),
"version_number": version_num,
"transaction_id": row["transaction_id"],
"operation_type": _OP_TYPE_LABELS.get(
row["operation_type"], str(row["operation_type"])
),
"issued_at": row["issued_at"],
"changed_by": _changed_by_from_row(row),
"changes": changes,
}
# For datasets, attach the columns/metrics as they were at this
# transaction by reading from Continuum's child shadow tables
# (``table_columns_version`` / ``sql_metrics_version``). Empty lists
# when the dataset had no children at this tx.
if model_cls is SqlaTable:
# pylint: disable=import-outside-toplevel
from superset.connectors.sqla.models import SqlMetric, TableColumn
from superset.versioning.changes import shadow_rows_valid_at
target_tx = row["transaction_id"]
cols_tbl = version_class(TableColumn).__table__
metrics_tbl = version_class(SqlMetric).__table__
result["columns"] = shadow_rows_valid_at(
db.session, cols_tbl, "table_id", entity.id, target_tx
)
result["metrics"] = shadow_rows_valid_at(
db.session, metrics_tbl, "table_id", entity.id, target_tx
)
return result