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>
241 lines
9.5 KiB
Python
241 lines
9.5 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.
|
|
"""Pre/post-state reading and the per-entity diff dispatch.
|
|
|
|
Three concerns live here:
|
|
|
|
1. **JSON-safety coercion** — raw column values (``datetime``, ``UUID``,
|
|
``bytes``, ``Decimal``) get converted to strings before they land in
|
|
the ``version_changes.from_value`` / ``to_value`` JSON columns.
|
|
2. **State capture** — :func:`_orm_to_post_state` serialises the
|
|
in-memory ORM object; :func:`_read_pre_state` reads the corresponding
|
|
pre-flush row directly from the DB inside ``session.no_autoflush``.
|
|
3. **Diff dispatch** — :func:`compute_records_for_entity` routes to the
|
|
right :mod:`superset.versioning.diff` helper based on the model
|
|
class name (string dispatch keeps this module free of hard imports
|
|
on the three entity classes, which avoids import-order coupling at
|
|
app-init time).
|
|
|
|
Bulk insert of the computed records into the ``version_changes`` table
|
|
lives here too — it's the tail of the per-entity compute pipeline.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
from datetime import date, datetime
|
|
from decimal import Decimal
|
|
from typing import Any
|
|
from uuid import UUID
|
|
|
|
import sqlalchemy as sa
|
|
from flask_appbuilder import Model
|
|
from sqlalchemy.orm import Session
|
|
|
|
from superset.versioning.changes.table import version_changes_table
|
|
from superset.versioning.diff import (
|
|
cap_records,
|
|
ChangeRecord,
|
|
diff_dashboard,
|
|
diff_dataset,
|
|
diff_slice,
|
|
scalar_fields_for,
|
|
)
|
|
from superset.versioning.utils import read_row_outside_flush
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
# Per-model-class cache of the scalar-field set. Populated lazily on
|
|
# first save of a model. Reading from ``__table__.columns`` is cheap
|
|
# but not free; memoising keeps the save-path overhead budget
|
|
# from slowly growing with the set of distinct model classes seen.
|
|
_SCALAR_FIELDS_CACHE: dict[type, frozenset[str]] = {}
|
|
|
|
|
|
def _cached_scalar_fields(model_cls: type[Model]) -> frozenset[str]:
|
|
"""Cached wrapper around :func:`scalar_fields_for`."""
|
|
if model_cls not in _SCALAR_FIELDS_CACHE:
|
|
# ``Slice.params`` is walked by ``diff_slice_params`` for kind
|
|
# promotion; emitting it as one opaque ``field`` change would
|
|
# defeat that and flood the log with meaningless records.
|
|
# ``last_saved_at`` / ``last_saved_by_fk`` are stamped by
|
|
# ``UpdateChartCommand`` on every chart save; they're audit
|
|
# noise (same shape as ``changed_on`` / ``changed_by_fk``) and
|
|
# don't carry user-authored signal.
|
|
# ``Dashboard.json_metadata`` and ``position_json`` are JSON
|
|
# blobs walked structurally by ``diff_json_field`` (one record
|
|
# per changed top-level key); the raw scalar diff would emit
|
|
# one giant multi-KB record per save and swamp the response.
|
|
special: frozenset[str] = frozenset()
|
|
audit: frozenset[str] = frozenset()
|
|
if model_cls.__name__ == "Slice":
|
|
special = frozenset({"params"})
|
|
audit = frozenset({"last_saved_at", "last_saved_by_fk"})
|
|
elif model_cls.__name__ == "Dashboard":
|
|
special = frozenset({"json_metadata", "position_json"})
|
|
_SCALAR_FIELDS_CACHE[model_cls] = scalar_fields_for(
|
|
model_cls, special=special, audit=audit
|
|
)
|
|
return _SCALAR_FIELDS_CACHE[model_cls]
|
|
|
|
|
|
def jsonable(value: Any) -> Any:
|
|
"""Convert a column value into a JSON-serialisable form.
|
|
|
|
Slice has ``last_saved_at`` (datetime), datasets have datetime
|
|
columns, and any of these fields can land in ``from_value`` /
|
|
``to_value`` of a ``version_changes`` row, which is a JSON column.
|
|
Python's default JSON encoder rejects ``datetime`` / ``UUID`` /
|
|
``bytes`` / ``Decimal``, so the whole bulk insert fails if a single
|
|
record carries one. Convert to ISO / hex / str at record-construction
|
|
time.
|
|
"""
|
|
if isinstance(value, (datetime, date)):
|
|
return value.isoformat()
|
|
if isinstance(value, UUID):
|
|
return str(value)
|
|
if isinstance(value, bytes):
|
|
return value.hex()
|
|
if isinstance(value, Decimal):
|
|
# Stringify rather than ``float()`` to preserve precision; the
|
|
# diff engine compares string equality on ``from_value`` /
|
|
# ``to_value``, so coercing both sides to the same form is what
|
|
# matters.
|
|
return str(value)
|
|
return value
|
|
|
|
|
|
def _orm_to_post_state(obj: Any) -> dict[str, Any]:
|
|
"""Serialise an ORM object's column attributes to a plain dict.
|
|
|
|
We only read declared column attributes — not relationships or
|
|
hybrid properties — because the diff engine operates on scalar
|
|
values per its documented API. Values are passed through
|
|
:func:`jsonable` so the dict is JSON-safe end-to-end.
|
|
"""
|
|
state = sa.inspect(obj)
|
|
return {
|
|
col.key: jsonable(getattr(obj, col.key)) for col in state.mapper.column_attrs
|
|
}
|
|
|
|
|
|
def _read_pre_state(
|
|
session: Session, model_cls: type[Model], entity_id: int
|
|
) -> dict[str, Any] | None:
|
|
"""Read the entity's pre-flush row directly from the DB and convert
|
|
non-JSON-safe types to strings so both sides of the diff compare on
|
|
the same form. Delegates the autoflush-suppressed read itself to
|
|
:func:`superset.versioning.utils.read_row_outside_flush`.
|
|
|
|
Returns ``None`` if the row is missing (shouldn't happen for a dirty
|
|
existing object, but defensive against race conditions).
|
|
"""
|
|
table = model_cls.__table__
|
|
result = read_row_outside_flush(session, table, entity_id)
|
|
if result is None:
|
|
return None
|
|
# Convert non-JSON-safe types (datetime, UUID, bytes, Decimal) to
|
|
# strings so both sides of the diff compare on the same form and
|
|
# any value that ends up in ``from_value`` / ``to_value`` is
|
|
# acceptable to the JSON column on insert.
|
|
return {key: jsonable(value) for key, value in result.items()}
|
|
|
|
|
|
def compute_records_for_entity(session: Session, obj: Any) -> list[ChangeRecord]:
|
|
"""Diff the pre-state (from DB) against the post-state (in memory).
|
|
|
|
Dispatches to :func:`diff_slice` / :func:`diff_dashboard` /
|
|
:func:`diff_dataset` based on the model class name — string-based
|
|
dispatch is used to keep this module free of hard imports on the
|
|
three entity classes, which in turn avoids import-order coupling
|
|
at app-init time.
|
|
"""
|
|
model_cls = type(obj)
|
|
entity_id = getattr(obj, "id", None)
|
|
if entity_id is None:
|
|
return []
|
|
|
|
try:
|
|
pre_state = _read_pre_state(session, model_cls, entity_id)
|
|
except Exception: # pylint: disable=broad-except
|
|
logger.exception(
|
|
"version_changes: pre-state read failed for %s id=%s",
|
|
model_cls.__name__,
|
|
entity_id,
|
|
)
|
|
return []
|
|
|
|
if pre_state is None:
|
|
return []
|
|
|
|
post_state = _orm_to_post_state(obj)
|
|
fields = _cached_scalar_fields(model_cls)
|
|
|
|
name = model_cls.__name__
|
|
if name == "Slice":
|
|
return diff_slice(pre_state, post_state, fields=fields)
|
|
if name == "Dashboard":
|
|
return diff_dashboard(pre_state, post_state, fields=fields)
|
|
if name == "SqlaTable":
|
|
return diff_dataset(pre_state, post_state, fields=fields)
|
|
return []
|
|
|
|
|
|
def bulk_insert_records(
|
|
session: Session,
|
|
transaction_id: int,
|
|
buffered: dict[tuple[str, int], list[ChangeRecord]],
|
|
) -> None:
|
|
"""Insert ``version_changes`` rows for one transaction via raw SQL.
|
|
|
|
Uses the module-level :data:`version_changes_table` Table object
|
|
(which carries JSON column types, unlike ``sa.table(...)``) so the
|
|
connection marshals ``path`` / ``from_value`` / ``to_value`` Python
|
|
structures into JSON on insert. Skips the ORM flush round that
|
|
``session.bulk_insert_mappings`` would cost inside an already-
|
|
active flush.
|
|
|
|
``buffered`` is a dict keyed on ``(entity_kind, entity_id)`` so
|
|
records for one entity — scalars from ``before_flush`` plus
|
|
children collected in ``after_flush`` — merge naturally under the
|
|
same key. ``sequence`` resets per entity so each entity's records
|
|
form a self-contained replay sequence.
|
|
"""
|
|
if not buffered:
|
|
return
|
|
rows = []
|
|
for (entity_kind, entity_id), records in buffered.items():
|
|
# Bound a single save's output: collapse field-level record explosions
|
|
# and truncate over-large values before they hit version_changes.
|
|
for seq, r in enumerate(cap_records(records)):
|
|
rows.append(
|
|
{
|
|
"transaction_id": transaction_id,
|
|
"entity_kind": entity_kind,
|
|
"entity_id": entity_id,
|
|
"sequence": seq,
|
|
"kind": r.kind,
|
|
"operation": r.operation,
|
|
"path": r.path,
|
|
"from_value": r.from_value,
|
|
"to_value": r.to_value,
|
|
}
|
|
)
|
|
if rows:
|
|
session.connection().execute(version_changes_table.insert(), rows)
|