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>
213 lines
8.1 KiB
Python
213 lines
8.1 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.
|
|
"""Per-entity child-baseline handlers.
|
|
|
|
After a parent baseline row lands in :mod:`.insertion`, this module's
|
|
handlers write the parent's child baselines under the same transaction
|
|
id. The dispatch table :data:`CHILD_BASELINE_HANDLERS` is keyed on
|
|
the parent class name (avoids an import-cycle with the entity modules,
|
|
which can't be loaded at app-init time).
|
|
|
|
The dataset handler baselines :class:`TableColumn` and
|
|
:class:`SqlMetric` children. The dashboard handler baselines the
|
|
``dashboard_slices`` M2M membership *and* synthesizes
|
|
``operation_type=0`` rows in ``slices_version`` for attached slices
|
|
that have no prior shadow — without those slice-side baselines,
|
|
Continuum's M2M revert query returns empty.
|
|
|
|
Leaf-level helpers (:func:`_insert_child_baseline_rows`,
|
|
:func:`_baseline_attached_slices`,
|
|
:func:`_insert_synthetic_slice_baseline`) live here too — they're
|
|
shared between the two parent-specific handlers.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from collections.abc import Callable
|
|
from typing import Any
|
|
|
|
import sqlalchemy as sa
|
|
from sqlalchemy.orm import Session
|
|
|
|
from superset.versioning.baseline.shadow import insert_baseline_shadow_row
|
|
|
|
|
|
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,
|
|
}
|
|
|
|
|
|
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:`~superset.versioning.baseline.insertion._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
|
|
|
|
for row in rows:
|
|
insert_baseline_shadow_row(conn, child_version_table, row, tx_id)
|
|
|
|
|
|
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:
|
|
insert_baseline_shadow_row(conn, slice_ver_table, slice_row, tx_id)
|