mirror of
https://github.com/apache/superset.git
synced 2026-07-12 01:35:36 +00:00
Co-authored-by: Mike Bridge <michael.bridge@ext.preset.io> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
313 lines
14 KiB
Python
313 lines
14 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.
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
from collections.abc import Callable
|
|
from typing import Any
|
|
|
|
import sqlalchemy as sa
|
|
import sqlalchemy.orm as sa_orm
|
|
from sqlalchemy_continuum import is_modified, version_class
|
|
from sqlalchemy_continuum.operation import Operation
|
|
from sqlalchemy_continuum.plugins.base import Plugin
|
|
from sqlalchemy_continuum.plugins.flask import FlaskPlugin
|
|
from sqlalchemy_continuum.transaction import TransactionFactory
|
|
from sqlalchemy_continuum.utils import versioned_column_properties
|
|
|
|
from superset.utils import json
|
|
from superset.versioning.diff import DASHBOARD_JSON_METADATA_AUDIT_KEYS
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
def _normalize_dashboard_json_metadata(value: Any) -> Any:
|
|
"""Parse ``dashboards.json_metadata`` and drop frontend-stamped audit
|
|
sub-keys so a save that only re-stamps ``map_label_colors`` (etc.)
|
|
compares equal to its predecessor.
|
|
|
|
``map_label_colors`` is regenerated client-side from the
|
|
``LabelsColorMap`` singleton on every save (see
|
|
``saveDashboardRequest`` in
|
|
``superset-frontend/src/dashboard/actions/dashboardState.ts``).
|
|
The singleton's contents depend on which charts have rendered in
|
|
the page session, so two saves with no user-authored change produce
|
|
different bytes. The diff engine ignores the same audit sub-keys
|
|
(``DASHBOARD_JSON_METADATA_AUDIT_KEYS`` in
|
|
``superset/versioning/diff.py``); aligning the skip-plugin's
|
|
comparison with that filter keeps the two paths consistent.
|
|
"""
|
|
if value is None or value == "":
|
|
return value
|
|
try:
|
|
parsed = json.loads(value)
|
|
except (TypeError, ValueError):
|
|
return value
|
|
if not isinstance(parsed, dict):
|
|
return parsed
|
|
return {
|
|
k: v for k, v in parsed.items() if k not in DASHBOARD_JSON_METADATA_AUDIT_KEYS
|
|
}
|
|
|
|
|
|
# Per-class column normalizers, keyed on (class_name, column_name). Class
|
|
# name is used (rather than class itself) so importing the model classes
|
|
# at module load is unnecessary — keeps the plugin importable before
|
|
# ``make_versioned()`` has registered the version classes.
|
|
_COLUMN_NORMALIZERS: dict[tuple[str, str], Callable[[Any], Any]] = {
|
|
("Dashboard", "json_metadata"): _normalize_dashboard_json_metadata,
|
|
}
|
|
|
|
|
|
def _normalize_for_compare(target: Any, col_name: str, value: Any) -> Any:
|
|
"""Return *value* run through any per-class column normalizer registered
|
|
in ``_COLUMN_NORMALIZERS``, else *value* unchanged.
|
|
"""
|
|
normalizer = _COLUMN_NORMALIZERS.get((type(target).__name__, col_name))
|
|
return normalizer(value) if normalizer is not None else value
|
|
|
|
|
|
def _has_dirty_versioned_children(target: Any, uow: Any) -> bool:
|
|
"""Return ``True`` when *uow* contains an operation for a versioned
|
|
child of *target* (e.g. a ``TableColumn`` whose ``table`` is *target*).
|
|
|
|
Used by :meth:`SkipUnmodifiedPlugin._is_no_op_update` so a parent
|
|
UPDATE that was force-flagged by
|
|
:func:`baseline.force_parent_dirty_on_child_change` is preserved
|
|
even though the parent's own scalars match the previous version.
|
|
"""
|
|
# pylint: disable=import-outside-toplevel
|
|
from superset.versioning.baseline import child_to_parent_registry
|
|
|
|
child_map = child_to_parent_registry()
|
|
target_cls = type(target)
|
|
for _key, op in uow.operations.items():
|
|
entry = child_map.get(type(op.target))
|
|
if entry is None:
|
|
continue
|
|
parent_attr, parent_cls = entry
|
|
if parent_cls is not target_cls:
|
|
continue
|
|
parent = getattr(op.target, parent_attr, None)
|
|
if parent is target:
|
|
return True
|
|
return False
|
|
|
|
|
|
class VersionTransactionFactory(TransactionFactory):
|
|
"""TransactionFactory that renames the transaction table and adds a bare
|
|
``user_id`` integer column so the FlaskPlugin can record the acting user
|
|
without requiring a FK relationship to ``ab_user``.
|
|
|
|
Continuum only adds ``user_id`` when ``user_cls`` is set on the manager.
|
|
We add it unconditionally (no FK) so that both the FlaskPlugin's
|
|
``transaction_args()`` and our ``baseline.py`` direct inserts can record
|
|
which user triggered the version event.
|
|
"""
|
|
|
|
def create_class(self, manager: Any) -> Any:
|
|
cls = super().create_class(manager)
|
|
cls.__table__.name = "version_transaction"
|
|
# Rename the PostgreSQL sequence for consistent naming.
|
|
for col in cls.__table__.columns:
|
|
if col.name == "id" and col.default is not None:
|
|
col.default.name = "version_transaction_id_seq"
|
|
# Add user_id INTEGER (no FK) for user tracking. The mapper has not
|
|
# been configured yet at this point, so append_column + add_property
|
|
# is safe here.
|
|
user_id_col = sa.Column("user_id", sa.Integer, nullable=True)
|
|
cls.__table__.append_column(user_id_col)
|
|
cls.__mapper__.add_property("user_id", sa_orm.column_property(user_id_col))
|
|
# ``action_kind`` — high-level avenue that produced this commit
|
|
# (``restore`` / ``import`` / ``clone`` / ``NULL`` for ordinary
|
|
# saves). The DDL is in the consolidated Alembic migration; we
|
|
# also declare it on the SQLAlchemy Table here so consumers
|
|
# can reference ``versioning_manager.transaction_cls.__table__
|
|
# .c.action_kind`` at runtime, and so the change-record
|
|
# listener's ``sa.update()`` stamp emits the correctly-quoted
|
|
# identifier per dialect.
|
|
action_kind_col = sa.Column("action_kind", sa.String(32), nullable=True)
|
|
cls.__table__.append_column(action_kind_col)
|
|
cls.__mapper__.add_property(
|
|
"action_kind", sa_orm.column_property(action_kind_col)
|
|
)
|
|
return cls
|
|
|
|
|
|
class VersioningFlaskPlugin(FlaskPlugin):
|
|
"""FlaskPlugin subclass that uses Superset's :func:`get_user_id` (which
|
|
reads ``g.user``) instead of Flask-Login's ``current_user``. Superset's
|
|
JWT auth for API routes populates ``g.user`` but leaves
|
|
``flask_login.current_user`` anonymous, so the upstream plugin would
|
|
record ``user_id=NULL`` on version_transaction rows created by API
|
|
calls. Returns an empty dict (so the transaction row is written
|
|
anyway) when no user is available — e.g. CLI, Celery, import/export.
|
|
"""
|
|
|
|
def transaction_args(self, uow: Any, session: Any) -> dict[str, Any]:
|
|
# pylint: disable=import-outside-toplevel
|
|
from flask import has_request_context, request
|
|
|
|
from superset.utils.core import get_user_id
|
|
|
|
user_id = get_user_id()
|
|
# get_user_id() returns an int in a real request, or None for a
|
|
# context-less save (CLI, Celery, import/export). Guard against any
|
|
# non-int so a bogus value never reaches the integer
|
|
# ``version_transaction.user_id`` column — notably a mocked ``g`` in
|
|
# tests, where ``g.user.id`` is a Mock rather than None and would
|
|
# otherwise blow up the flush with a SQL bind error.
|
|
if not isinstance(user_id, int):
|
|
return {}
|
|
|
|
remote_addr: str | None
|
|
try:
|
|
remote_addr = request.remote_addr if has_request_context() else None
|
|
except RuntimeError:
|
|
remote_addr = None
|
|
|
|
return {"user_id": user_id, "remote_addr": remote_addr}
|
|
|
|
|
|
class SkipUnmodifiedPlugin(Plugin):
|
|
"""Skip creating version rows for UPDATE operations whose post-flush
|
|
column values are byte-identical to the previous live version row.
|
|
|
|
Continuum creates a version row for every entity in ``session.dirty``,
|
|
including saves where the SQLAlchemy ORM marked a column dirty (because
|
|
Superset re-serialised ``json_metadata`` via ``json.dumps`` on the save
|
|
path, or AuditMixin auto-bumped ``changed_on``) but the resulting value
|
|
is unchanged from the previous version. Those rows pollute the version
|
|
history with no-op entries.
|
|
|
|
``is_modified()`` from Continuum is not enough: it consults SQLAlchemy's
|
|
attribute history, which is "did setattr produce a different value?",
|
|
not "did the final stored value change?". So we compare each
|
|
non-excluded versioned column on ``operation.target`` against the
|
|
previous live version row's value; if all are equal, the operation
|
|
is marked ``processed`` and Continuum skips it (see
|
|
``UnitOfWork.create_version_objects``).
|
|
|
|
The associated transaction is not removed; if every operation is a
|
|
no-op the transaction becomes an orphan in ``version_transaction``
|
|
and is swept by the retention task at cutoff. Deleting the row
|
|
inline (in this hook) was considered and rejected: it would couple
|
|
this plugin to the change-records listener's buffer state — both
|
|
would have to agree that the flush produced nothing before we
|
|
could safely DROP the tx row, since ``version_changes.transaction_id``
|
|
has an ON DELETE CASCADE FK that would silently drop any buffered
|
|
diff records the listener was about to insert. The orphan's storage
|
|
cost (~40 bytes/row) is small enough that the coordination isn't
|
|
worth it; retention handles the cleanup correctly by construction
|
|
(orphans have no parent shadow → they're never "preserved" by the
|
|
"preserve transactions whose shadow has the live row" rule and
|
|
age out with the rest of the history).
|
|
"""
|
|
|
|
def before_create_version_objects(self, uow: Any, session: Any) -> None:
|
|
# ``uow.operations`` is a custom Continuum ``Operations`` collection;
|
|
# use its ``.items()`` method (not ``.values()``) to iterate.
|
|
# INSERTs always create a row (no prior to compare against);
|
|
# DELETEs can't be no-ops. Only UPDATE operations are candidates.
|
|
for _key, operation in uow.operations.items():
|
|
if operation.processed or operation.type != Operation.UPDATE:
|
|
continue
|
|
try:
|
|
if self._is_no_op_update(operation.target, session, uow):
|
|
operation.processed = True
|
|
except Exception: # pylint: disable=broad-except
|
|
# Defensive — if introspection fails for any reason, fall
|
|
# back to creating the version row.
|
|
logger.exception(
|
|
"SkipUnmodifiedPlugin: skip-check raised for %s",
|
|
type(operation.target).__name__,
|
|
)
|
|
|
|
@classmethod
|
|
def _is_no_op_update(cls, target: Any, session: Any, uow: Any) -> bool:
|
|
"""Return ``True`` when this UPDATE produces no observable change to
|
|
any non-excluded versioned column **and** no versioned children of
|
|
*target* are being modified in this flush.
|
|
|
|
Stages:
|
|
|
|
1. If any versioned child (e.g. a ``TableColumn`` whose ``table``
|
|
is *target*) has an operation in ``uow.operations``, the parent
|
|
is being force-touched by
|
|
``baseline.force_parent_dirty_on_child_change`` to anchor the
|
|
child changes against a parent shadow row. Keep the row.
|
|
2. ``is_modified(target)`` — cheap SQLAlchemy attribute-history
|
|
check. Returns ``False`` when only excluded columns/relationships
|
|
(``owners``, ``changed_on``, …) are dirty. This is the common
|
|
case (every save auto-bumps ``changed_on``); short-circuiting
|
|
here saves the DB round-trip in stage 3.
|
|
3. Compare post-flush column values against the previous live
|
|
version row's stored values. Catches the case where SQLAlchemy
|
|
sees a column as dirty (e.g. ``set_dash_metadata`` re-serialised
|
|
``json_metadata`` to a different byte sequence) but the
|
|
resulting parsed content matches the prior version.
|
|
"""
|
|
if _has_dirty_versioned_children(target, uow):
|
|
return False
|
|
if not is_modified(target):
|
|
return True
|
|
return cls._matches_previous_version(target, session)
|
|
|
|
@staticmethod
|
|
def _matches_previous_version(target: Any, session: Any) -> bool:
|
|
"""Return ``True`` when every non-excluded versioned column on
|
|
*target* matches the value stored in its previous live version row
|
|
(i.e., the row with ``end_transaction_id IS NULL``).
|
|
|
|
Returns ``False`` for entities with no prior version row — letting
|
|
Continuum create the first one. In practice this case is rare:
|
|
``register_baseline_listener`` (in ``superset.versioning.baseline``)
|
|
runs ahead of Continuum's ``before_flush`` and inserts a baseline
|
|
row for any entity being saved for the first time, so the second
|
|
save (and beyond) is what flows through this path.
|
|
"""
|
|
cls = type(target)
|
|
try:
|
|
ver_cls = version_class(cls)
|
|
except Exception: # pylint: disable=broad-except
|
|
return False
|
|
ver_table = ver_cls.__table__
|
|
|
|
col_keys = [prop.key for prop in versioned_column_properties(target)]
|
|
if not col_keys:
|
|
return False
|
|
|
|
select_stmt = (
|
|
sa.select(*[ver_table.c[c] for c in col_keys])
|
|
.where(ver_table.c.id == target.id)
|
|
.where(ver_table.c.end_transaction_id.is_(None))
|
|
.order_by(ver_table.c.transaction_id.desc())
|
|
.limit(1)
|
|
)
|
|
row = session.connection().execute(select_stmt).first()
|
|
if row is None:
|
|
return False # no previous version → let Continuum create one
|
|
|
|
for col_name, prev_value in zip(col_keys, row, strict=False):
|
|
post = _normalize_for_compare(
|
|
target, col_name, getattr(target, col_name, None)
|
|
)
|
|
pre = _normalize_for_compare(target, col_name, prev_value)
|
|
if post != pre:
|
|
return False
|
|
return True
|