Files
superset2/superset/versioning/baseline/dirty.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

244 lines
11 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.
"""Parent-dirty force machinery for child-only saves.
When a versioned child (``TableColumn`` / ``SqlMetric``) is in
``session.dirty`` / ``new`` / ``deleted`` but its parent's scalar
columns haven't been touched, the parent is *missing* from the dirty
set — so Continuum's UnitOfWork never creates a parent UPDATE
operation, no parent shadow row is written, and the version-history
dropdown comes back empty for column/metric-only saves.
:func:`force_parent_dirty_on_child_change` walks dirty/new/deleted
children, looks them up in the child→parent registry (in
:mod:`.collection`), and ``attributes.flag_modified``s a deterministic
non-excluded column on the parent. SQLAlchemy adds the parent to
``session.dirty``; Continuum then writes a parent shadow row whose
scalars mirror the previous version (only the children actually
changed).
:func:`pin_audit_columns` is a companion: when the parent is force-
flagged, we pin ``changed_by_fk`` / ``changed_on`` to their current
in-memory values so the parent UPDATE doesn't invoke the audit
columns' ``onupdate=get_user_id`` / ``onupdate=datetime.now`` hooks
(which would attribute the synthetic flush to whoever ``g.user`` is
at the time, possibly a deleted test user under autoflush teardown).
**Inline imports.** Same init-order rationale as
:mod:`superset.versioning.baseline.collection`.
"""
from __future__ import annotations
import logging
from collections.abc import Iterator
from typing import Any
from sqlalchemy.exc import InvalidRequestError
from sqlalchemy.orm import attributes, Session
from superset.versioning.baseline.collection import child_to_parent_registry
logger = logging.getLogger(__name__)
def force_parent_dirty_on_child_change(session: Session) -> None:
"""Mark a versioned parent as dirty whenever one of its versioned
children appears in ``session.dirty``/``new``/``deleted`` but the
parent's own scalars haven't been edited.
Without this hook, edits that only touch ``TableColumn`` or
``SqlMetric`` rows leave the parent ``SqlaTable`` out of
``session.dirty`` — so Continuum's UnitOfWork never creates a
parent UPDATE operation and ``list_versions`` (which queries the
parent shadow ``tables_version``) returns just the baseline. The
user-visible symptom is "I edited a column description but the
dataset's version history dropdown is empty".
For each child that represents a real edit, we resolve its parent
and ``attributes.flag_modified`` a deterministic non-excluded
column so SQLAlchemy adds the parent to ``session.dirty`` without
altering any column values. Continuum then writes a parent shadow
row at this transaction; its scalar columns mirror the previous
version (only the children changed).
``SkipUnmodifiedPlugin._is_no_op_update`` is taught to recognize
the "scalars match but children dirty" case and keep the row.
"""
child_map = child_to_parent_registry()
new_set = session.new
for child in _real_dirty_versioned_children(session, child_map):
parent = _resolve_parent(child, child_map)
if parent is None:
continue
if parent in new_set:
# Already-new short-circuit. If the parent itself is in
# ``session.new`` (typical during an import that adds a
# ``SqlaTable`` plus 50 fresh ``TableColumn`` children), it
# will INSERT in this flush regardless — the
# ``flag_modified`` call is redundant (and the attribute-
# default-not-yet-fired case in ``_flag_parent`` would just
# swallow an ``InvalidRequestError``). Skip the work.
continue
if _flag_parent(parent):
pin_audit_columns(parent)
def _real_dirty_versioned_children(
session: Session, child_map: dict[type, Any]
) -> Iterator[Any]:
"""Yield child instances that are (a) of a versioned-child class
registered in *child_map*, and (b) represent a real content edit —
not a phantom-dirty entry from lazy-load side effects or audit-
column auto-bumps.
Phantom-dirty filter rationale: a child can appear in
``session.dirty`` for reasons that don't represent real content
edits — lazy-load side effects, ``AuditMixin`` auto-bumps from
prior code paths, M2M relationship-cascade artifacts (e.g.,
``rls_entry.tables.extend([dataset])`` in setUp), Reverter side
passes. Force-touching the parent in those cases produces an
incidental ``UPDATE tables SET description=…, changed_on=…,
changed_by_fk=…`` that can violate FK integrity on some dialects
(observed in ``test_rls_filter_alters_no_role_user_birth_names_query``).
The filter applies ONLY to persistent rows in ``session.dirty``:
``session.new`` (creation) and ``session.deleted`` (removal) are
always real content changes — deletion in particular is a state
transition with no attribute history, so ``is_modified`` returns
False there even when the change is real (column-removed records
must still emit).
"""
# pylint: disable=import-outside-toplevel
from sqlalchemy_continuum import is_modified
# ``session.dirty`` / ``session.new`` are IdentitySets — ``__contains__``
# uses identity comparison, which is what we need for the phantom-
# dirty filter below.
dirty_set = session.dirty
for obj in list(session.dirty) + list(session.new) + list(session.deleted):
if type(obj) not in child_map:
continue
if obj in dirty_set and not is_modified(obj):
continue
yield obj
def _resolve_parent(child: Any, child_map: dict[type, Any]) -> Any | None:
"""Resolve the versioned parent for *child* via the child→parent
registry; return ``None`` when the registered parent attribute
isn't loaded or has been swapped for an unexpected type."""
parent_attr, parent_cls = child_map[type(child)]
parent = getattr(child, parent_attr, None)
if parent is None or type(parent) is not parent_cls: # noqa: E721
return None
return parent
def _flag_parent(parent: Any) -> bool:
"""``flag_modified`` a stable non-excluded column on *parent* so
SQLAlchemy adds it to ``session.dirty`` without altering values.
Returns ``True`` on success.
Column choice: ``description`` is a plain ``Text`` column on all
three versioned parent classes (Dashboard, Slice, SqlaTable) and is
in none of their ``__versioned__`` excludes — pick it
deterministically so the flagged attribute is stable across
SQLAlchemy versions / mapper-configuration orders. We deliberately
avoid ``uuid``: when a versioned-parent UPDATE goes through with
``uuid`` flagged, the column's ``UUIDType``/BLOB round-trip
produces a memoryview that fails an FK integrity check on some
dialects (observed in
``test_rls_filter_alters_no_role_user_birth_names_query`` and
``test_restore_applies_scalar_field``). ``description`` is a plain
text column with no marshaling layer, so flagging it safely
round-trips its current value. Falls back to ``uuid`` then
``col_keys[0]`` for forks that excluded ``description``.
Returns ``False`` for the freshly-constructed ``session.new``
instance whose attribute defaults haven't fired yet — the
attribute is unloaded in instance state, so ``flag_modified``
rejects it with ``InvalidRequestError``. The parent will INSERT in
this flush regardless, so the flag was redundant; safely skip.
Hit by ``test_create_dataset_item`` (POST /api/v1/dataset/).
"""
# pylint: disable=import-outside-toplevel
from sqlalchemy_continuum.utils import versioned_column_properties
col_keys = [prop.key for prop in versioned_column_properties(parent)]
if not col_keys:
return False
if "description" in col_keys:
flag_col = "description"
elif "uuid" in col_keys:
flag_col = "uuid"
else:
flag_col = col_keys[0]
try:
attributes.flag_modified(parent, flag_col)
except InvalidRequestError:
return False
return True
def pin_audit_columns(parent: Any) -> None:
"""Pin ``changed_by_fk`` and ``changed_on`` to their current in-memory
values on a flag-flushed parent.
``changed_by_fk`` carries ``onupdate=get_user_id`` from ``AuditMixin``:
any UPDATE statement that doesn't explicitly set this column lets
SQLAlchemy invoke ``get_user_id()`` and write whoever ``g.user`` is
at flush time. When the flush is autoflush-triggered during an
earlier test's teardown (after the test user has been deleted from
``ab_user``), the bumped value points at a non-existent row and the
parent UPDATE fails the FK to ``ab_user``. The same applies to
``changed_on``'s ``onupdate=datetime.now`` (cosmetic only, but it's
cheap to pin together).
``flag_modified`` on both columns marks them as having dirty
attribute history, which tells SQLAlchemy to use the in-memory
(previously-committed) values instead of invoking ``onupdate`` —
the parent UPDATE then carries the existing audit values rather
than whatever ``g.user`` resolves to during the synthetic flag
flush. Hits ``test_rls_filter_alters_no_role_user_birth_names_query``
and ``TestDatasetRestoreApi::test_restore_applies_scalar_field``
in CI's full-suite ordering (autoflush during teardown).
"""
pinned_any = False
for audit_col in ("changed_by_fk", "changed_on"):
if hasattr(parent, audit_col):
try:
attributes.flag_modified(parent, audit_col)
pinned_any = True
except InvalidRequestError:
continue
if not pinned_any and hasattr(parent, "changed_by_fk"):
# Both audit columns are present on the parent but neither
# ``flag_modified`` succeeded — typically because the parent is
# a freshly-constructed ``session.new`` instance whose attribute
# defaults haven't fired yet. Without the pin, the synthetic
# parent UPDATE in this flush invokes ``onupdate=get_user_id``
# and writes whoever ``g.user`` is at flush time, which under
# autoflush-during-teardown can point at a deleted test user
# and fail the FK to ``ab_user``. Surface this so the failure
# mode is debuggable from the log without inspection.
logger.info(
"baseline: skipped audit-column pin on %s id=%s "
"(attribute defaults not loaded)",
type(parent).__name__,
getattr(parent, "id", None),
)