mirror of
https://github.com/apache/superset.git
synced 2026-07-16 11:46:09 +00:00
The force-parent-dirty listener was calling attributes.flag_modified on every parent reachable from a dirty child — including parents themselves in session.new (e.g. brand-new SqlaTable + brand-new TableColumns from POST /api/v1/dataset/). flag_modified rejects unloaded attributes, and a session.new SqlaTable's uuid (default=uuid4 fires at flush time) is unloaded until then. CI caught this with InvalidRequestError cascading into 422s across dataset creation / upload / Playwright dataset specs. The hook is only needed for the persistent-and-clean case (child edited, parent's own scalars untouched, dropdown otherwise empty). Anything in session.new will flush anyway; anything in session.dirty is already flagged; session.deleted shouldn't be touched. Short- circuit before the flag_modified call. Unblocks test-sqlite, test-mysql, test-postgres (previous), and playwright dataset specs.
571 lines
23 KiB
Python
571 lines
23 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.
|
|
"""before_flush listener that captures a baseline version (version 0) for entities
|
|
being updated for the first time after the versioning migration.
|
|
|
|
The module reads top-down in stepdown order: the public entry point
|
|
(``register_baseline_listener``) is at the top; helpers descend to leaf
|
|
builders at the bottom. Module-level state (``VERSIONED_MODELS``,
|
|
``_CHILD_BASELINE_HANDLERS``) sits next to the helpers that consume it.
|
|
|
|
VERSIONED_MODELS is populated at app startup by the initialisation code after
|
|
make_versioned() has run and all versioned model classes have been defined.
|
|
|
|
**Inline imports.** Several helpers below use ``# pylint: disable=
|
|
import-outside-toplevel`` for imports of ``sqlalchemy_continuum`` and
|
|
Superset model classes. The reason is uniform: this module is imported
|
|
from ``init_versioning()`` in ``superset/initialization/__init__.py``
|
|
before all SQLAlchemy mappers are configured and before Continuum's
|
|
``make_versioned()`` has finished wiring shadow classes. Top-level
|
|
imports of model classes or Continuum helpers would either trip an
|
|
unresolved-mapper error or create an init-order cycle. The lazy form
|
|
defers resolution until the helper actually runs, by which point app
|
|
init is complete. Per-call ``why-`` comments are omitted to avoid
|
|
repeating the same explanation at every callsite; unusual cases (if
|
|
any are added) should be commented explicitly.
|
|
"""
|
|
|
|
import functools
|
|
import logging
|
|
from typing import Any, Callable, Optional
|
|
|
|
import sqlalchemy as sa
|
|
from sqlalchemy import event
|
|
from sqlalchemy.exc import OperationalError
|
|
from sqlalchemy.orm import Session, attributes
|
|
|
|
from superset.versioning.utils import read_row_outside_flush
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
# Populated at app startup (superset/initialization/__init__.py) before
|
|
# register_baseline_listener() is called.
|
|
VERSIONED_MODELS: list[type] = []
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Entry point
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def register_baseline_listener() -> None:
|
|
"""Attach the before_flush listener that captures baseline versions.
|
|
|
|
Call this after VERSIONED_MODELS has been populated and make_versioned() has run.
|
|
"""
|
|
from superset.extensions import db # pylint: disable=import-outside-toplevel
|
|
|
|
# insert=True prepends us in the listener chain so we run BEFORE
|
|
# Continuum's before_flush. Continuum's pending Transaction object
|
|
# (added in its own before_flush) would otherwise get a lower
|
|
# auto-increment tx_id than our direct-SQL baseline insert, placing the
|
|
# baseline row after the update in version_number order. Prepending
|
|
# ensures our baseline's tx_id comes first.
|
|
@event.listens_for(db.session, "before_flush", insert=True)
|
|
def capture_baseline(session: Session, flush_context: Any, instances: Any) -> None:
|
|
if not VERSIONED_MODELS:
|
|
return
|
|
# Make sure a child-only edit promotes the parent to ``session.dirty``
|
|
# before Continuum's before_flush reads the dirty set.
|
|
_force_parent_dirty_on_child_change(session)
|
|
for obj in _collect_parents_to_baseline(session).values():
|
|
if type(obj) not in VERSIONED_MODELS:
|
|
continue
|
|
version_table = _version_table_for(obj)
|
|
if version_table is None:
|
|
continue
|
|
count = _shadow_row_count(session, obj, version_table)
|
|
if count == 0:
|
|
_insert_baseline_and_children(session, obj, version_table)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# High-level helpers used by ``capture_baseline``
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
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".
|
|
|
|
We use ``attributes.flag_modified`` against the parent's first
|
|
non-excluded versioned 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.
|
|
"""
|
|
# pylint: disable=import-outside-toplevel
|
|
from sqlalchemy_continuum.utils import versioned_column_properties
|
|
|
|
child_map = _child_to_parent_registry()
|
|
for obj in list(session.dirty) + list(session.new) + list(session.deleted):
|
|
entry = child_map.get(type(obj))
|
|
if entry is None:
|
|
continue
|
|
parent_attr, parent_cls = entry
|
|
parent = getattr(obj, parent_attr, None)
|
|
if parent is None or type(parent) is not parent_cls: # noqa: E721
|
|
continue
|
|
# Only flag *persistent + clean* parents. Anything else is
|
|
# either already going to surface in the flush (``session.new``
|
|
# → INSERT, ``session.dirty`` → already flagged) or shouldn't be
|
|
# flagged (``session.deleted`` → being removed). Also avoids
|
|
# InvalidRequestError from ``attributes.flag_modified`` when a
|
|
# brand-new parent's ``uuid`` default (``default=uuid4``) hasn't
|
|
# fired yet so the attribute is unloaded in instance state.
|
|
if parent in session.new or parent in session.dirty or parent in session.deleted:
|
|
continue
|
|
col_keys = [prop.key for prop in versioned_column_properties(parent)]
|
|
if not col_keys:
|
|
continue
|
|
# ``uuid`` is 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. Falls back to the first available column for forks or
|
|
# subclasses that excluded ``uuid``.
|
|
flag_col = "uuid" if "uuid" in col_keys else col_keys[0]
|
|
attributes.flag_modified(parent, flag_col)
|
|
|
|
|
|
def _collect_parents_to_baseline(session: Session) -> dict[int, Any]:
|
|
"""Return parents-to-baseline as ``{id(obj): obj}`` keyed by Python
|
|
object identity to dedupe across ``session.dirty + new + deleted``.
|
|
|
|
Includes both directly-dirty versioned parents and parents reachable
|
|
from dirty/new/deleted children via the child→parent registry.
|
|
"""
|
|
parents: dict[int, Any] = {}
|
|
child_map = _child_to_parent_registry()
|
|
for obj in list(session.dirty) + list(session.new) + list(session.deleted):
|
|
if type(obj) in VERSIONED_MODELS:
|
|
parents[id(obj)] = obj
|
|
continue
|
|
entry = child_map.get(type(obj))
|
|
if entry is None:
|
|
continue
|
|
parent_attr, parent_cls = entry
|
|
parent = getattr(obj, parent_attr, None)
|
|
if parent is not None and type(parent) is parent_cls: # noqa: E721
|
|
parents[id(parent)] = parent
|
|
return parents
|
|
|
|
|
|
@functools.cache
|
|
def _child_to_parent_registry() -> dict[type, tuple[str, type]]:
|
|
"""Map child entity class → (parent-relationship-attr, parent class).
|
|
|
|
When a dirty child of a known type appears in session.dirty/new/deleted,
|
|
we walk to its parent and baseline the parent (+ siblings) under the
|
|
SAME flush so pre-edit child values land in the baseline shadow rows.
|
|
Without this, edits that only touch child rows produce a "silent" flush
|
|
A (just ``TableColumn``) followed by flush B (``SqlaTable.changed_on``);
|
|
flush B reads children from DB AFTER flush A already pushed UPDATEs,
|
|
capturing post-edit state.
|
|
|
|
Cached because this is called from ``_force_parent_dirty_on_child_change``
|
|
and ``_collect_parents_to_baseline`` on every save flush. The returned
|
|
mapping depends only on the (fixed at import time) child model classes,
|
|
so an unbounded ``functools.cache`` is the right shape — no invalidation
|
|
needed.
|
|
"""
|
|
# Lazy import: ``baseline`` is imported during ``init_versioning``, which
|
|
# runs before all model mappers are configured. Importing model classes
|
|
# at module load would either cycle or hit unresolved mappers.
|
|
# pylint: disable=import-outside-toplevel
|
|
from superset.connectors.sqla.models import SqlaTable, SqlMetric, TableColumn
|
|
|
|
return {
|
|
TableColumn: ("table", SqlaTable),
|
|
SqlMetric: ("table", SqlaTable),
|
|
}
|
|
|
|
|
|
def _version_table_for(obj: Any) -> Any:
|
|
"""Return Continuum's shadow ``Table`` for *obj*'s class, or ``None``
|
|
when the class isn't registered (forks / plugins that subclass without
|
|
``__versioned__``).
|
|
"""
|
|
# pylint: disable=import-outside-toplevel
|
|
from sqlalchemy_continuum import version_class
|
|
from sqlalchemy_continuum.exc import ClassNotVersioned
|
|
|
|
try:
|
|
return version_class(type(obj)).__table__
|
|
except ClassNotVersioned:
|
|
return None
|
|
|
|
|
|
def _shadow_row_count(session: Session, obj: Any, version_table: Any) -> Optional[int]:
|
|
"""Return number of shadow rows for *obj.id* in *version_table*, or
|
|
``None`` when the version table is missing (migration not yet applied)
|
|
or the count query raised unexpectedly.
|
|
"""
|
|
try:
|
|
with session.no_autoflush:
|
|
return (
|
|
session.connection()
|
|
.execute(
|
|
sa.select(sa.func.count())
|
|
.select_from(version_table)
|
|
.where(version_table.c.id == obj.id)
|
|
)
|
|
.scalar()
|
|
)
|
|
except OperationalError:
|
|
return None
|
|
except Exception: # pylint: disable=broad-except
|
|
logger.exception(
|
|
"baseline_listener: count query failed for %s id=%s",
|
|
type(obj).__name__,
|
|
getattr(obj, "id", None),
|
|
)
|
|
return None
|
|
|
|
|
|
def _insert_baseline_and_children(
|
|
session: Session, obj: Any, version_table: Any
|
|
) -> None:
|
|
"""Insert the parent baseline row, then baseline the parent's child
|
|
collections under the same transaction id.
|
|
|
|
Wrapped in ``no_autoflush`` so ``session.connection()`` inside
|
|
``_insert_baseline_row`` does not trigger a flush of Continuum's
|
|
pending Transaction object before our direct-SQL insert claims its
|
|
tx_id.
|
|
"""
|
|
try:
|
|
with session.no_autoflush:
|
|
tx_id = _insert_baseline_row(session, obj, version_table)
|
|
if tx_id is None:
|
|
return
|
|
_baseline_children_for_parent(session, obj, tx_id)
|
|
logger.debug(
|
|
"baseline_listener: inserted baseline tx_id=%s for %s id=%s",
|
|
tx_id,
|
|
type(obj).__name__,
|
|
getattr(obj, "id", None),
|
|
)
|
|
except Exception: # pylint: disable=broad-except
|
|
logger.exception(
|
|
"baseline_listener: failed to insert baseline for %s id=%s",
|
|
type(obj).__name__,
|
|
getattr(obj, "id", None),
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Mid-level builders: parent shadow + child dispatch
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _insert_baseline_row(
|
|
session: Session, obj: Any, version_table: sa.Table
|
|
) -> Optional[int]:
|
|
"""Insert a synthetic baseline row capturing the pre-edit DB state of *obj*.
|
|
|
|
Creates a version_transaction entry and an operation_type=0 version row.
|
|
All writes use the session's existing connection so they share the same
|
|
database transaction as the triggering flush.
|
|
|
|
Returns the allocated ``transaction_id`` so the caller can baseline child
|
|
collections under the same tx (see :func:`_insert_child_baseline_rows`),
|
|
or ``None`` when the entity has no live row.
|
|
"""
|
|
from sqlalchemy_continuum import (
|
|
versioning_manager, # pylint: disable=import-outside-toplevel
|
|
)
|
|
|
|
main_table = type(obj).__table__
|
|
row = read_row_outside_flush(session, main_table, obj.id)
|
|
if row is None:
|
|
return None
|
|
|
|
conn = session.connection()
|
|
|
|
# Insert a version_transaction row for the baseline.
|
|
#
|
|
# ``issued_at`` and ``user_id`` are sourced from the entity's audit fields
|
|
# (``changed_on`` / ``changed_by_fk``, falling back to ``created_on`` /
|
|
# ``created_by_fk`` if the row was never edited), so the baseline reads
|
|
# in the version-history UI as "this is the state at the time of the
|
|
# last pre-versioning edit, by that user." Using ``now()`` and the
|
|
# current user would have made the baseline look chronologically newer
|
|
# than subsequent edits and attributed historical content to the user
|
|
# who happened to trigger the first save under versioning.
|
|
baseline_issued_at = row.get("changed_on") or row.get("created_on") or sa.func.now()
|
|
baseline_user_id = row.get("changed_by_fk") or row.get("created_by_fk")
|
|
tx_table = versioning_manager.transaction_cls.__table__
|
|
result = conn.execute(
|
|
tx_table.insert().values(
|
|
issued_at=baseline_issued_at,
|
|
user_id=baseline_user_id,
|
|
remote_addr=None,
|
|
)
|
|
)
|
|
tx_id = result.inserted_primary_key[0]
|
|
|
|
# Build version row using Column objects as keys to avoid name/key mismatches
|
|
# (string-based values(**dict) raises "Unconsumed column names" when a Column's
|
|
# .key differs from its .name, which can happen with Continuum-generated tables).
|
|
meta_col_names = {"transaction_id", "end_transaction_id", "operation_type"}
|
|
col_values: dict[Any, Any] = {}
|
|
for col in version_table.columns:
|
|
if col.name in meta_col_names:
|
|
continue
|
|
if col.name in row:
|
|
col_values[col] = row[col.name]
|
|
|
|
col_values[version_table.c.transaction_id] = tx_id
|
|
col_values[version_table.c.end_transaction_id] = None
|
|
col_values[version_table.c.operation_type] = 0
|
|
|
|
conn.execute(version_table.insert().values(col_values))
|
|
return tx_id
|
|
|
|
|
|
def _baseline_children_for_parent(
|
|
session: Session, parent_obj: Any, tx_id: int
|
|
) -> None:
|
|
"""Baseline a parent's child collections under the parent's baseline tx.
|
|
|
|
Dispatches via :data:`_CHILD_BASELINE_HANDLERS` to per-entity handlers.
|
|
A handler failure is logged but does not block the parent baseline.
|
|
"""
|
|
parent_name = type(parent_obj).__name__
|
|
handler = _CHILD_BASELINE_HANDLERS.get(parent_name)
|
|
if handler is None:
|
|
return
|
|
try:
|
|
handler(session, parent_obj, tx_id)
|
|
except Exception: # pylint: disable=broad-except
|
|
logger.exception(
|
|
"baseline_listener: failed to baseline children of %s id=%s",
|
|
parent_name,
|
|
getattr(parent_obj, "id", None),
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Per-entity child handlers
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _baseline_dataset_children(session: Session, dataset: Any, tx_id: int) -> None:
|
|
"""Baseline a dataset's ``TableColumn`` and ``SqlMetric`` children
|
|
under the dataset's baseline tx.
|
|
"""
|
|
# pylint: disable=import-outside-toplevel
|
|
from sqlalchemy_continuum import version_class
|
|
|
|
from superset.connectors.sqla.models import SqlMetric, TableColumn
|
|
|
|
for child_cls in (TableColumn, SqlMetric):
|
|
_insert_child_baseline_rows(
|
|
session,
|
|
dataset,
|
|
child_cls.__table__,
|
|
version_class(child_cls).__table__,
|
|
"table_id",
|
|
tx_id,
|
|
)
|
|
|
|
|
|
def _baseline_dashboard_children(session: Session, dashboard: Any, tx_id: int) -> None:
|
|
"""Baseline a dashboard's ``dashboard_slices`` M2M plus synthesize
|
|
``operation_type=0`` rows in ``slices_version`` for attached slices
|
|
with no prior shadow.
|
|
|
|
Continuum's M2M version-side relationship for ``Dashboard.slices``
|
|
joins through both ``dashboard_slices_version`` AND
|
|
``slices_version``: the second exists clause filters slices by
|
|
"latest slices_version row with tx <= dashboard.tx". If a slice
|
|
has no slices_version rows at all, that join produces no match
|
|
and ``version_obj.slices`` returns empty — leaving the dashboard
|
|
restore with no slices to append. The synthetic slice baseline at
|
|
this dashboard's tx gives the M2M query a slice version it can match.
|
|
|
|
Doesn't try to be clever about slices shared across dashboards: a
|
|
slice is baselined at this dashboard's tx_id only when it has no
|
|
shadow rows at all. If a later dashboard baseline references the
|
|
same slice, this baseline (now at lower tx) is still found by
|
|
that dashboard's restore. The reverse — a dashboard baselined
|
|
AFTER the slice was first baselined under another dashboard at
|
|
a higher tx — is a residual gap deferred to a future fix.
|
|
"""
|
|
metadata = type(dashboard).__table__.metadata
|
|
live_tbl = metadata.tables.get("dashboard_slices")
|
|
shadow_tbl = metadata.tables.get("dashboard_slices_version")
|
|
if live_tbl is None or shadow_tbl is None:
|
|
return
|
|
|
|
_insert_child_baseline_rows(
|
|
session, dashboard, live_tbl, shadow_tbl, "dashboard_id", tx_id
|
|
)
|
|
_baseline_attached_slices(session, dashboard, live_tbl, tx_id)
|
|
|
|
|
|
# Dispatch table keyed by parent CLASS NAME rather than class, to avoid
|
|
# the import-cycle between baseline.py (loaded at app init) and the
|
|
# entity modules. The class-name string is set once at app start by
|
|
# the model definitions — typo-prone if extended. Declared after the
|
|
# handlers it references because module-level dict literals evaluate
|
|
# at import time and need the names already bound.
|
|
_ChildBaselineHandler = Callable[[Session, Any, int], None]
|
|
_CHILD_BASELINE_HANDLERS: dict[str, _ChildBaselineHandler] = {
|
|
"SqlaTable": _baseline_dataset_children,
|
|
"Dashboard": _baseline_dashboard_children,
|
|
}
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Leaf builders: child-row insert and synthetic slice baseline
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _insert_child_baseline_rows(
|
|
session: Session,
|
|
parent_obj: Any,
|
|
child_table: sa.Table,
|
|
child_version_table: sa.Table,
|
|
fk_column_name: str,
|
|
tx_id: int,
|
|
) -> None:
|
|
"""Synthesize ``operation_type=0`` shadow rows for every live child of
|
|
*parent_obj* under transaction id *tx_id*.
|
|
|
|
Parallels :func:`_insert_baseline_row` but iterates over child rows. Used
|
|
to give Continuum's ``Reverter`` baseline data for children of pre-existing
|
|
parents (children that predate this commit have no shadow rows otherwise,
|
|
so Reverter would treat them as "deleted at the target tx" and try to
|
|
remove them on revert — the ADR-004 Failure 1 reproduction scenario).
|
|
|
|
:param child_table: the live child SQLAlchemy ``Table`` (e.g.
|
|
``TableColumn.__table__`` or the bare ``dashboard_slices`` association)
|
|
:param child_version_table: the corresponding Continuum shadow ``Table``
|
|
:param fk_column_name: column on *child_table* that points to the parent
|
|
(e.g. ``"table_id"`` for ``TableColumn``, ``"dashboard_id"`` for
|
|
``dashboard_slices``)
|
|
"""
|
|
conn = session.connection()
|
|
fk_col = getattr(child_table.c, fk_column_name)
|
|
|
|
rows = (
|
|
conn.execute(sa.select(child_table).where(fk_col == parent_obj.id))
|
|
.mappings()
|
|
.all()
|
|
)
|
|
if not rows:
|
|
return
|
|
|
|
meta_col_names = {"transaction_id", "end_transaction_id", "operation_type"}
|
|
for row in rows:
|
|
col_values: dict[Any, Any] = {}
|
|
for col in child_version_table.columns:
|
|
if col.name in meta_col_names:
|
|
continue
|
|
if col.name in row:
|
|
col_values[col] = row[col.name]
|
|
col_values[child_version_table.c.transaction_id] = tx_id
|
|
col_values[child_version_table.c.end_transaction_id] = None
|
|
col_values[child_version_table.c.operation_type] = 0
|
|
conn.execute(child_version_table.insert().values(col_values))
|
|
|
|
|
|
def _baseline_attached_slices(
|
|
session: Session, dashboard: Any, live_tbl: sa.Table, tx_id: int
|
|
) -> None:
|
|
"""Insert ``operation_type=0`` rows in ``slices_version`` for each
|
|
slice attached to *dashboard* that has no shadow row yet.
|
|
|
|
Batched: one membership SELECT, one existing-shadow SELECT, one live
|
|
SELECT for the missing slices. Per-slice work happens only on
|
|
``_insert_synthetic_slice_baseline``. The previous per-slice
|
|
``COUNT(*)`` + ``SELECT`` pattern was O(N) round-trips and surfaced
|
|
as a measurable first-save hotspot on dashboards with many charts.
|
|
"""
|
|
# pylint: disable=import-outside-toplevel
|
|
from sqlalchemy_continuum import version_class
|
|
|
|
from superset.models.slice import Slice
|
|
|
|
slice_ver_table = version_class(Slice).__table__
|
|
slice_table = Slice.__table__
|
|
conn = session.connection()
|
|
|
|
attached_slice_ids = [
|
|
r.slice_id
|
|
for r in conn.execute(
|
|
sa.select(live_tbl.c.slice_id).where(
|
|
live_tbl.c.dashboard_id == dashboard.id
|
|
)
|
|
).all()
|
|
]
|
|
if not attached_slice_ids:
|
|
return
|
|
|
|
existing_shadow_ids = {
|
|
row[0]
|
|
for row in conn.execute(
|
|
sa.select(slice_ver_table.c.id.distinct()).where(
|
|
slice_ver_table.c.id.in_(attached_slice_ids)
|
|
)
|
|
).all()
|
|
}
|
|
missing_ids = [
|
|
sid for sid in attached_slice_ids if sid not in existing_shadow_ids
|
|
]
|
|
if not missing_ids:
|
|
return
|
|
|
|
slice_rows = (
|
|
conn.execute(
|
|
sa.select(slice_table).where(slice_table.c.id.in_(missing_ids))
|
|
)
|
|
.mappings()
|
|
.all()
|
|
)
|
|
for slice_row in slice_rows:
|
|
_insert_synthetic_slice_baseline(conn, slice_ver_table, slice_row, tx_id)
|
|
|
|
|
|
def _insert_synthetic_slice_baseline(
|
|
conn: Any, slice_ver_table: sa.Table, slice_row: Any, tx_id: int
|
|
) -> None:
|
|
meta_col_names = {"transaction_id", "end_transaction_id", "operation_type"}
|
|
col_values: dict[Any, Any] = {}
|
|
for col in slice_ver_table.columns:
|
|
if col.name in meta_col_names:
|
|
continue
|
|
if col.name in slice_row:
|
|
col_values[col] = slice_row[col.name]
|
|
col_values[slice_ver_table.c.transaction_id] = tx_id
|
|
col_values[slice_ver_table.c.end_transaction_id] = None
|
|
col_values[slice_ver_table.c.operation_type] = 0
|
|
conn.execute(slice_ver_table.insert().values(col_values))
|