mirror of
https://github.com/apache/superset.git
synced 2026-07-26 00:22:31 +00:00
Co-authored-by: Mike Bridge <michael.bridge@ext.preset.io> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
211 lines
8.5 KiB
Python
211 lines
8.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.
|
|
"""Kind translation tables, shared types, and the shadow-model loader.
|
|
|
|
The activity-view module operates in two "kind" namespaces — the
|
|
table-stored value (``"chart"`` / ``"dashboard"`` / ``"dataset"``) that
|
|
``version_changes.entity_kind`` carries, and the Python class-name
|
|
form (``"Slice"`` / ``"Dashboard"`` / ``"SqlaTable"``) used internally
|
|
for dispatch and returned in the DTO's ``entity_kind`` field. The four
|
|
mappings here translate between them. Adjacent kind-keyed dicts live
|
|
here too: the per-kind human-readable label, the user-facing
|
|
lowercase form, and the 404 exception class.
|
|
|
|
The :func:`load_live_model` helper exists in the same module
|
|
because each lookup is keyed on the same set of class names — keeping
|
|
it adjacent to the mappings makes the kind-translation surface
|
|
discoverable at a glance.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from collections.abc import Iterable, Iterator
|
|
from dataclasses import dataclass
|
|
|
|
from flask_appbuilder import Model
|
|
|
|
from superset.commands.chart.exceptions import ChartNotFoundError
|
|
from superset.commands.dashboard.exceptions import DashboardNotFoundError
|
|
from superset.commands.dataset.exceptions import DatasetNotFoundError
|
|
from superset.versioning.changes import ENTITY_KIND_BY_CLASS_NAME
|
|
|
|
# Max entity ids per ``IN (...)`` clause across the activity-view queries.
|
|
# Bounds the bind-parameter count under SQLite's ``SQLITE_MAX_VARIABLE_NUMBER``
|
|
# floor (default 999 in many builds); Postgres/MySQL accept the full list but
|
|
# the chunk is dialect-agnostic. 500 leaves headroom for the other bound
|
|
# params on each statement.
|
|
ENTITY_ID_CHUNK_SIZE = 500
|
|
|
|
|
|
def chunked_ids(
|
|
ids: Iterable[int], size: int = ENTITY_ID_CHUNK_SIZE
|
|
) -> Iterator[list[int]]:
|
|
"""Yield *ids* in fixed-size lists (final chunk may be smaller). Shared by
|
|
the activity-view query helpers so every ``IN (...)`` stays under the
|
|
SQLite bind-variable floor."""
|
|
items = list(ids)
|
|
for i in range(0, len(items), size):
|
|
yield items[i : i + size]
|
|
|
|
|
|
# ---- Kind translation -----------------------------------------------------
|
|
|
|
# ``version_changes.entity_kind`` stores the friendly downstream-tooling
|
|
# value (``"chart"``, ``"dashboard"``, ``"dataset"``) per the
|
|
# ``ENTITY_KIND_BY_CLASS_NAME``. The activity-view DTO returns the
|
|
# Python class name instead (``"Slice"``, ``"Dashboard"``,
|
|
# ``"SqlaTable"``) so the contract aligns with ``__class__.__name__``
|
|
# (the ``ActivityRecord`` DTO). Translate at the boundary.
|
|
TABLE_KIND_TO_API: dict[str, str] = {
|
|
table_kind: class_name
|
|
for class_name, table_kind in ENTITY_KIND_BY_CLASS_NAME.items()
|
|
}
|
|
API_KIND_TO_TABLE: dict[str, str] = dict(ENTITY_KIND_BY_CLASS_NAME)
|
|
|
|
# Human-readable label for summary headlines
|
|
# ("Dataset updated: Sales Transactions"). Keyed by the internal API kind
|
|
# (Python class name; matches ``model_cls.__name__``).
|
|
API_KIND_LABEL: dict[str, str] = {
|
|
"Dashboard": "Dashboard",
|
|
"Slice": "Chart",
|
|
"SqlaTable": "Dataset",
|
|
}
|
|
|
|
# User-facing lowercase rendering of the kind. This is what appears in
|
|
# the JSON response's ``entity_kind`` field and the
|
|
# ``ActivityRecordSchema.entity_kind`` enum. Internal code keeps the
|
|
# Python class-name form because it matches ``model_cls.__name__`` and is
|
|
# convenient for dispatch — translation happens at serialization time
|
|
# only, in :func:`render.apply_record_decoration`.
|
|
USER_FACING_KIND: dict[str, str] = {
|
|
"Dashboard": "dashboard",
|
|
"Slice": "chart",
|
|
"SqlaTable": "dataset",
|
|
}
|
|
|
|
# 404 exception class per API kind. Each accepts a string positional arg
|
|
# (the path-entity UUID) that gets formatted into the exception message.
|
|
NOT_FOUND_EXC: dict[str, type[Exception]] = {
|
|
"Dashboard": DashboardNotFoundError,
|
|
"Slice": ChartNotFoundError,
|
|
"SqlaTable": DatasetNotFoundError,
|
|
}
|
|
|
|
# Per-API-kind (model class name, display column) used by
|
|
# ``_resolve_names_for_kind`` to read the user-facing entity name from
|
|
# the shadow table valid at a given transaction.
|
|
NAME_COLUMN: dict[str, tuple[str, str]] = {
|
|
"Dashboard": ("Dashboard", "dashboard_title"),
|
|
"Slice": ("Slice", "slice_name"),
|
|
"SqlaTable": ("SqlaTable", "table_name"),
|
|
}
|
|
|
|
|
|
# ---- Types ----------------------------------------------------------------
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class Window:
|
|
"""A validity window in Continuum transaction-id space, half-open as
|
|
``[start_tx, end_tx)``.
|
|
|
|
Immutable and equal-by-attributes — two windows with the same
|
|
``start_tx`` / ``end_tx`` are interchangeable. Constructor rejects
|
|
``end_tx <= start_tx``. ``end_tx = None`` means "open ended
|
|
(current)" and acts as positive infinity throughout the helpers.
|
|
|
|
Helper methods (``contains`` / ``intersect`` / ``merges_with``)
|
|
live on the type so callers don't re-implement the half-open
|
|
predicate inline. Previously a ``tuple[int, int | None]`` alias;
|
|
promoted to a dataclass so a function accepting a ``Window`` can't
|
|
silently accept any other 2-tuple and so the constructor enforces
|
|
the half-open invariant.
|
|
"""
|
|
|
|
start_tx: int
|
|
end_tx: int | None
|
|
|
|
def __post_init__(self) -> None:
|
|
if self.end_tx is not None and self.end_tx <= self.start_tx:
|
|
raise ValueError(
|
|
f"Window end_tx must be > start_tx; "
|
|
f"got [{self.start_tx}, {self.end_tx})"
|
|
)
|
|
|
|
def contains(self, tx_id: int) -> bool:
|
|
"""``True`` iff *tx_id* falls inside this half-open interval."""
|
|
return self.start_tx <= tx_id and (self.end_tx is None or tx_id < self.end_tx)
|
|
|
|
def intersect(self, other: Window) -> Window | None:
|
|
"""Return the clipped overlap of this window with *other*, or
|
|
``None`` when they are disjoint. ``end_tx = None`` acts as
|
|
positive infinity on either side."""
|
|
start = max(self.start_tx, other.start_tx)
|
|
end: int | None
|
|
if self.end_tx is None:
|
|
end = other.end_tx
|
|
elif other.end_tx is None:
|
|
end = self.end_tx
|
|
else:
|
|
end = min(self.end_tx, other.end_tx)
|
|
if end is not None and end <= start:
|
|
return None
|
|
return Window(start, end)
|
|
|
|
def merges_with(self, other: Window) -> bool:
|
|
"""``True`` iff *self* and *other* overlap or touch (so their
|
|
union is one contiguous window). Assumes the caller has placed
|
|
them in start-ascending order."""
|
|
if self.end_tx is None:
|
|
# self extends to +∞; everything past it merges in.
|
|
return True
|
|
return other.start_tx <= self.end_tx
|
|
|
|
|
|
#: A related-entity scope row: ``(api_kind, entity_id, [windows])``.
|
|
#: ``api_kind`` is the DTO-facing kind (``"Slice"``, etc.), not the
|
|
#: table-stored kind. Modelled as a tuple alias — the
|
|
#: ``(api_kind, entity_id)`` pair is logically a key with the window
|
|
#: list as its value, so a dict shape may fit better than a flat
|
|
#: dataclass.
|
|
EntityWindows = tuple[str, int, list[Window]]
|
|
|
|
|
|
def load_live_model(model_name: str) -> type[Model]:
|
|
"""Inline-import a live model class by name. Deferred until call
|
|
time because the versioning package is initialised before all model
|
|
mappers are configured (same idiom used throughout
|
|
:mod:`superset.versioning.changes`).
|
|
|
|
Returns the LIVE model class (``Dashboard`` / ``Slice`` /
|
|
``SqlaTable``), not a Continuum shadow class — callers derive the
|
|
shadow table separately via ``version_class(...)`` where needed."""
|
|
# pylint: disable=import-outside-toplevel
|
|
if model_name == "Dashboard":
|
|
from superset.models.dashboard import Dashboard
|
|
|
|
return Dashboard
|
|
if model_name == "Slice":
|
|
from superset.models.slice import Slice
|
|
|
|
return Slice
|
|
if model_name == "SqlaTable":
|
|
from superset.connectors.sqla.models import SqlaTable
|
|
|
|
return SqlaTable
|
|
raise LookupError(f"No live model registered for {model_name!r}")
|