mirror of
https://github.com/apache/superset.git
synced 2026-07-12 09:45:43 +00:00
Co-authored-by: Mike Bridge <michael.bridge@ext.preset.io> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
244 lines
11 KiB
Python
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),
|
|
)
|