Files
superset2/superset/daos/dataset.py
Mike Bridge a899e1db41 feat(versioning): entity-version base infrastructure (gated off, dark launch) (#41176)
Co-authored-by: Mike Bridge <michael.bridge@ext.preset.io>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-09 19:57:05 -07:00

676 lines
27 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.
from __future__ import annotations
import logging
from datetime import datetime
from typing import Any, Dict, List
import dateutil.parser
from sqlalchemy import or_, select
from sqlalchemy.exc import SQLAlchemyError
from sqlalchemy.orm import Query
from superset.connectors.sqla.models import (
SqlaTable,
SqlMetric,
TableColumn,
)
from superset.daos.base import BaseDAO, ColumnOperator, ColumnOperatorEnum
from superset.extensions import db
from superset.models.core import Database
from superset.models.dashboard import Dashboard
from superset.models.slice import Slice
from superset.sql.parse import Table
from superset.utils.core import DatasourceType
from superset.views.base import DatasourceFilter
logger = logging.getLogger(__name__)
# Custom filterable fields for datasets (not direct model columns)
DATASET_CUSTOM_FIELDS: dict[str, list[str]] = {
"database_name": ["eq", "like", "ilike"],
"editor": ["eq", "in"],
}
class DatasetDAO(BaseDAO[SqlaTable]):
"""
DAO for datasets. Supports filtering on model fields, hybrid properties, and custom
fields:
- tags: list of tags (eq, in_, like)
- editor: user id (eq, in_)
"""
base_filter = DatasourceFilter
@classmethod
def apply_column_operators(
cls,
query: Query,
column_operators: list[ColumnOperator] | None = None,
) -> Query:
"""Override to handle database_name filter via subquery on Database.
database_name lives on Database, not SqlaTable, so we intercept it
here and use a subquery to avoid duplicate joins with DatasourceFilter.
"""
if not column_operators:
return query
remaining_operators: list[ColumnOperator] = []
for c in column_operators:
if not isinstance(c, ColumnOperator):
c = ColumnOperator.model_validate(c)
if c.col == "database_name":
operator_enum = ColumnOperatorEnum(c.opr)
subq = select(Database.id).where(
operator_enum.apply(Database.database_name, c.value)
)
query = query.filter(SqlaTable.database_id.in_(subq))
elif c.col == "editor":
from superset.subjects.models import sqlatable_editors, Subject
operator_enum = ColumnOperatorEnum(c.opr)
subq = (
select(sqlatable_editors.c.table_id)
.join(
Subject.__table__,
Subject.__table__.c.id == sqlatable_editors.c.subject_id,
)
.where(
Subject.__table__.c.type == 1,
operator_enum.apply(Subject.__table__.c.user_id, c.value),
)
)
query = query.filter(
SqlaTable.id.in_(subq) # type: ignore[attr-defined,unused-ignore]
)
elif c.col == "created_by_fk_or_editor":
if c.opr != "eq":
raise ValueError(
f"created_by_fk_or_editor only supports 'eq'; got '{c.opr}'"
)
from superset.subjects.models import sqlatable_editors, Subject
editor_subq = (
select(sqlatable_editors.c.table_id)
.join(
Subject.__table__,
Subject.__table__.c.id == sqlatable_editors.c.subject_id,
)
.where(
Subject.__table__.c.type == 1,
Subject.__table__.c.user_id == c.value,
)
)
query = query.filter(
or_(
SqlaTable.created_by_fk == c.value, # type: ignore[attr-defined,unused-ignore]
SqlaTable.id.in_(editor_subq), # type: ignore[attr-defined,unused-ignore]
)
)
else:
remaining_operators.append(c)
if remaining_operators:
query = super().apply_column_operators(query, remaining_operators)
return query
@staticmethod
def get_database_by_id(database_id: int) -> Database | None:
try:
return db.session.query(Database).filter_by(id=database_id).one_or_none()
except SQLAlchemyError as ex: # pragma: no cover
logger.error("Could not get database by id: %s", str(ex), exc_info=True)
return None
@staticmethod
def get_related_objects(database_id: int) -> dict[str, Any]:
charts = (
db.session.query(Slice)
.filter(
Slice.datasource_id == database_id,
Slice.datasource_type == DatasourceType.TABLE,
)
.all()
)
chart_ids = [chart.id for chart in charts]
dashboards = (
(
db.session.query(Dashboard)
.join(Dashboard.slices)
.filter(Slice.id.in_(chart_ids))
)
.distinct()
.all()
)
return {"charts": charts, "dashboards": dashboards}
@staticmethod
def validate_table_exists(
database: Database,
table: Table,
) -> bool:
try:
database.get_table(table)
return True
except SQLAlchemyError as ex: # pragma: no cover
logger.warning("Got an error %s validating table: %s", str(ex), table)
return False
@staticmethod
def _catalog_identity_filter(
catalog: str | None,
default_catalog: str | None,
) -> Any:
"""Null-aware catalog predicate for physical-identity matching.
A row stored with ``catalog = NULL`` is semantically the database's
default catalog (older datasets were created before the catalog column
existed, and multi-catalog-disabled databases never set it). So when the
normalized probe catalog resolves to the default, ``catalog = NULL`` rows
must also match — otherwise create/update/restore/import disagree on what
counts as the same physical table and let a default-catalog twin slip
through.
- probe is the default catalog → match ``= default`` OR ``IS NULL``
- probe is ``None`` (no catalog support) → match ``IS NULL``
- probe is a non-default catalog → match ``= probe`` exactly
"""
if catalog is not None and catalog == default_catalog:
return or_(SqlaTable.catalog == catalog, SqlaTable.catalog.is_(None))
if catalog is None:
return SqlaTable.catalog.is_(None)
return SqlaTable.catalog == catalog
@staticmethod
def validate_uniqueness(
database: Database,
table: Table,
dataset_id: int | None = None,
) -> bool:
# The catalog might not be set even if the database supports catalogs, in case
# multi-catalog is disabled.
default_catalog = database.get_default_catalog()
catalog = table.catalog or default_catalog
# Bypass the soft-delete visibility filter so a soft-deleted row with
# the same physical identity still blocks the create. Otherwise a
# delete-then-create-then-restore sequence could produce two live
# datasets pointing at the same physical table.
#
# The bypass MUST be session-scoped, not per-query. The
# ``db.session.query(dataset_query.exists()).scalar()`` pattern below
# builds an OUTER query whose ``execution_options`` are independent
# of the inner ``dataset_query`` — the listener fires on the outer
# execute and would attach ``deleted_at IS NULL`` to all SqlaTable
# references in the statement (including inside the EXISTS
# subquery) via ``with_loader_criteria(include_aliases=True)``.
# A per-query option on the inner query never reaches that listener.
#
# avoid app-init regression: a module-top import from
# ``superset.models.helpers`` eagerly loads ``core.py``, whose model
# classes evaluate ``encrypted_field_factory.create(...)`` at
# class-definition time and raise "App not initialized yet" when no
# Flask app context exists (see PR #40573).
from superset.models.helpers import ( # pylint: disable=import-outside-toplevel
skip_visibility_filter,
)
with skip_visibility_filter(db.session, SqlaTable):
dataset_query = db.session.query(SqlaTable).filter(
SqlaTable.table_name == table.table,
SqlaTable.schema == table.schema,
DatasetDAO._catalog_identity_filter(catalog, default_catalog),
SqlaTable.database_id == database.id,
)
if dataset_id:
# make sure the dataset found is different from the target (if any)
dataset_query = dataset_query.filter(SqlaTable.id != dataset_id)
return not db.session.query(dataset_query.exists()).scalar()
@staticmethod
def validate_update_uniqueness(
database: Database,
table: Table,
dataset_id: int,
) -> bool:
"""Update-time twin of ``validate_uniqueness``.
Delegates outright — the identity rule, catalog normalization, and
the session-scoped visibility bypass (and their rationale) live in
``validate_uniqueness`` so they cannot drift between the create and
update paths.
"""
return DatasetDAO.validate_uniqueness(database, table, dataset_id)
@staticmethod
def has_active_logical_duplicate(
model: SqlaTable,
table: Table | None = None,
) -> bool:
"""Return True iff another *active* dataset shares model's physical table.
``table`` overrides the identity to probe (used by restore-via-import,
where the uploaded config may rename the dataset — the collision that
matters is against the *post-update* identity, not the stored one).
Physical identity is ``(database_id, catalog, schema, table_name)``.
``model``'s catalog is normalized to the database default when unset —
the same rule ``validate_uniqueness``/``validate_update_uniqueness``
apply — so create, update, restore, and re-import agree on what counts
as the same physical table. Catalog matching is null-aware via
``_catalog_identity_filter``: when the normalized catalog is the database
default, both ``catalog = default`` and ``catalog IS NULL`` rows match,
so a default-catalog twin is caught whichever way either row stored its
catalog.
Unlike ``validate_uniqueness``, this does NOT use
``skip_visibility_filter``: it relies on the ``SoftDeleteMixin`` listener
to auto-append ``deleted_at IS NULL`` so only *active* rows match. Do not
add the bypass here — it would broaden the check to soft-deleted rows and
silently refuse legitimate restores. ``id != model.id`` excludes the row
itself.
Assumes an active app context and a session-attached ``model`` so
``db.session`` and the lazy ``model.database`` relationship resolve.
"""
# The catalog might not be set even if the database supports catalogs,
# in case multi-catalog is disabled.
default_catalog = model.database.get_default_catalog()
probe_table_name = table.table if table else model.table_name
probe_schema = table.schema if table else model.schema
probe_catalog = (table.catalog if table else model.catalog) or default_catalog
# This is a best-effort read for a friendly early error; it does not
# lock, so two concurrent creates could both pass it. That race is
# benign: the DB-level UniqueConstraint on
# (database_id, catalog, schema, table_name) (see SqlaTable.__table_args__)
# is the real guard — the losing commit fails with IntegrityError rather
# than landing a second active dataset over the same physical table.
return (
db.session.query(SqlaTable.id)
.filter(
SqlaTable.database_id == model.database_id,
DatasetDAO._catalog_identity_filter(probe_catalog, default_catalog),
SqlaTable.schema == probe_schema,
SqlaTable.table_name == probe_table_name,
SqlaTable.id != model.id,
)
.first()
is not None
)
@staticmethod
def find_soft_deleted_logical_duplicate(
database: Database,
table: Table,
) -> SqlaTable | None:
"""Return a *soft-deleted* dataset sharing table's physical identity.
Used by the YAML importer's create path: ``import_from_dict`` can't see
soft-deleted rows (the visibility filter hides them), so importing a
dataset with a fresh UUID but the same physical table as a soft-deleted
dataset would create an active twin of a hidden row. This bypasses the
visibility filter (``skip_visibility_filter``) and restricts to
``deleted_at IS NOT NULL`` so the caller can block the import and direct
the user to restore the existing dataset instead. Catalog matching is
null-aware via ``_catalog_identity_filter`` so a default-catalog twin is
caught however either row stored its catalog.
"""
# avoid app-init regression: see ``validate_uniqueness`` — a module-top
# import of ``skip_visibility_filter`` eagerly loads model classes that
# raise "App not initialized yet" outside an app context (PR #40573).
from superset.models.helpers import ( # pylint: disable=import-outside-toplevel
skip_visibility_filter,
)
default_catalog = database.get_default_catalog()
catalog = table.catalog or default_catalog
with skip_visibility_filter(db.session, SqlaTable):
return (
db.session.query(SqlaTable)
.filter(
SqlaTable.database_id == database.id,
DatasetDAO._catalog_identity_filter(catalog, default_catalog),
SqlaTable.schema == table.schema,
SqlaTable.table_name == table.table,
SqlaTable.deleted_at.is_not(None),
)
.first()
)
@staticmethod
def validate_columns_exist(dataset_id: int, columns_ids: list[int]) -> bool:
dataset_query = (
db.session.query(TableColumn.id).filter(
TableColumn.table_id == dataset_id, TableColumn.id.in_(columns_ids)
)
).all()
return len(columns_ids) == len(dataset_query)
@staticmethod
def validate_columns_uniqueness(dataset_id: int, columns_names: list[str]) -> bool:
dataset_query = (
db.session.query(TableColumn.id).filter(
TableColumn.table_id == dataset_id,
TableColumn.column_name.in_(columns_names),
)
).all()
return len(dataset_query) == 0
@staticmethod
def validate_metrics_exist(dataset_id: int, metrics_ids: list[int]) -> bool:
dataset_query = (
db.session.query(SqlMetric.id).filter(
SqlMetric.table_id == dataset_id, SqlMetric.id.in_(metrics_ids)
)
).all()
return len(metrics_ids) == len(dataset_query)
@staticmethod
def validate_metrics_uniqueness(dataset_id: int, metrics_names: list[str]) -> bool:
dataset_query = (
db.session.query(SqlMetric.id).filter(
SqlMetric.table_id == dataset_id,
SqlMetric.metric_name.in_(metrics_names),
)
).all()
return len(dataset_query) == 0
@staticmethod
def validate_python_date_format(dt_format: str) -> bool:
if dt_format in ("epoch_s", "epoch_ms"):
return True
try:
dt_str = datetime.now().strftime(dt_format)
dateutil.parser.isoparse(dt_str)
return True
except ValueError:
return False
@classmethod
def update(
cls,
item: SqlaTable | None = None,
attributes: dict[str, Any] | None = None,
) -> SqlaTable:
"""
Updates a Dataset model on the metadata DB
"""
if item and attributes:
force_update: bool = False
if "columns" in attributes:
cls.update_columns(
item,
attributes.pop("columns"),
override_columns=bool(attributes.get("override_columns")),
)
force_update = True
if "metrics" in attributes:
cls.update_metrics(item, attributes.pop("metrics"))
force_update = True
if force_update:
attributes["changed_on"] = datetime.now()
return super().update(item, attributes)
@classmethod
def _validate_column_date_formats(
cls, property_columns: list[dict[str, Any]]
) -> None:
for column in property_columns:
if column.get("python_date_format") is None:
continue
if not DatasetDAO.validate_python_date_format(column["python_date_format"]):
raise ValueError(
"python_date_format is an invalid date/timestamp format."
)
@classmethod
def _override_columns(
cls, model: SqlaTable, property_columns: list[dict[str, Any]]
) -> None:
"""Replace columns by natural key (``column_name``) — update in place
rather than delete-and-reinsert.
SPIKE (full-Continuum): the previous
delete-and-reinsert pattern produced overlapping shadow rows in
``table_columns_version`` (the same ``column_name`` had a DELETE
shadow at tx N alongside an INSERT shadow at tx N for a fresh PK).
Continuum's ``Reverter`` couldn't unwind this on restore: its flush
ordering inserts the historical row before deleting the live one,
hitting the ``UNIQUE (table_id, column_name)`` constraint mid-flush
(ADR-004 Failure 1).
The natural-key upsert keeps PKs stable across metadata refresh.
Continuum captures only real field changes; new columns get plain
INSERT shadows; removed columns get plain DELETE shadows. No
natural-key collisions, so Reverter can restore cleanly.
Behaviour change vs. the previous implementation: PKs of unchanged
columns are preserved. Charts that reference columns by their
``id`` continue to work across a metadata refresh — previously
such references would be invalidated.
"""
existing_by_name = {c.column_name: c for c in model.columns}
incoming_by_name = {p["column_name"]: p for p in property_columns}
# Identity is the natural key here, never the payload's ``id``:
# setattr-ing an incoming ``id`` onto a name-matched row would
# rewrite a live primary key, and a renamed column whose payload
# still carries its old ``id`` would INSERT with a live PK while
# the old-named row is deleted in the same flush — INSERTs flush
# before DELETEs, so that collides on the PK / UNIQUE(table_id,
# column_name) constraints. ``table_id`` is pinned to *model*.
protected_keys = ("id", "table_id")
# Update columns present in both: in-place setattr.
for name, col in existing_by_name.items():
if name in incoming_by_name:
for key, value in incoming_by_name[name].items():
if key not in protected_keys:
setattr(col, key, value)
# Insert columns present only in incoming.
# Delete columns present only in existing, and flush the deletes BEFORE
# inserting new ones. SQLAlchemy's unit of work orders INSERTs before
# DELETEs within a single flush, so without this intermediate flush an
# incoming column whose name case-insensitively matches a removed column
# (a case-only rename under a case-insensitive collation, e.g. MySQL)
# would collide on ``UNIQUE(table_id, column_name)`` mid-flush.
deleted_any = False
for name, col in existing_by_name.items():
if name not in incoming_by_name:
db.session.delete(col)
deleted_any = True
if deleted_any:
db.session.flush()
# Insert columns present only in incoming.
for name, properties in incoming_by_name.items():
if name not in existing_by_name:
cleaned = {
key: value
for key, value in properties.items()
if key not in protected_keys
}
db.session.add(TableColumn(**{**cleaned, "table_id": model.id}))
@classmethod
def _upsert_columns(
cls, model: SqlaTable, property_columns: list[dict[str, Any]]
) -> None:
columns_by_id = {column.id: column for column in model.columns}
property_columns_by_id = {
properties["id"]: properties
for properties in property_columns
if "id" in properties
}
for properties in property_columns:
if "id" not in properties:
db.session.add(TableColumn(**{**properties, "table_id": model.id}))
for properties in property_columns_by_id.values():
col = columns_by_id[properties["id"]]
for key, value in properties.items():
setattr(col, key, value)
ids_to_keep = property_columns_by_id.keys()
for col in model.columns:
if col.id not in ids_to_keep:
db.session.delete(col)
@classmethod
def update_columns(
cls,
model: SqlaTable,
property_columns: list[dict[str, Any]],
override_columns: bool = False,
) -> None:
"""
Creates/updates and/or deletes a list of columns, based on a
list of Dict.
- If a column Dict has an `id` property then we update.
- If a column Dict does not have an `id` then we create a new metric.
- If there are extra columns on the metadata db that are not defined on the List
then we delete.
Uses individual ORM operations (not bulk) so that SQLAlchemy-Continuum
can capture each row change in the version history.
"""
cls._validate_column_date_formats(property_columns)
if override_columns:
cls._override_columns(model, property_columns)
else:
cls._upsert_columns(model, property_columns)
@classmethod
def update_metrics(
cls,
model: SqlaTable,
property_metrics: list[dict[str, Any]],
) -> None:
"""
Creates/updates and/or deletes a list of metrics, based on a
list of Dict.
- If a metric Dict has an `id` property then we update.
- If a metric Dict does not have an `id` then we create a new metric.
- If there are extra metrics on the metadata db that are not defined on the List
then we delete.
Uses individual ORM operations (not bulk) so that SQLAlchemy-Continuum
can capture each row change in the version history.
"""
metrics_by_id = {metric.id: metric for metric in model.metrics}
property_metrics_by_id = {
properties["id"]: properties
for properties in property_metrics
if "id" in properties
}
# Insert new metrics
for properties in property_metrics:
if "id" not in properties:
db.session.add(SqlMetric(**{**properties, "table_id": model.id}))
# Update existing metrics
for properties in property_metrics_by_id.values():
metric = metrics_by_id[properties["id"]]
for key, value in properties.items():
setattr(metric, key, value)
# Delete removed metrics
ids_to_keep = property_metrics_by_id.keys()
for metric in model.metrics:
if metric.id not in ids_to_keep:
db.session.delete(metric)
@classmethod
def find_dataset_column(cls, dataset_id: int, column_id: int) -> TableColumn | None:
# We want to apply base dataset filters
dataset = DatasetDAO.find_by_id(dataset_id)
if not dataset:
return None
return (
db.session.query(TableColumn)
.filter(TableColumn.table_id == dataset_id, TableColumn.id == column_id)
.one_or_none()
)
@classmethod
def find_dataset_metric(cls, dataset_id: int, metric_id: int) -> SqlMetric | None:
# We want to apply base dataset filters
dataset = DatasetDAO.find_by_id(dataset_id)
if not dataset:
return None
return db.session.query(SqlMetric).get(metric_id)
@staticmethod
def get_table_by_name(database_id: int, table_name: str) -> SqlaTable | None:
return (
db.session.query(SqlaTable)
.filter_by(database_id=database_id, table_name=table_name)
.one_or_none()
)
@staticmethod
def get_table_by_catalog_schema_and_name(
database_id: int,
schema: str | None,
table_name: str,
catalog: str | None = None,
) -> SqlaTable | None:
# Filter by the full ``(database_id, catalog, schema, table_name)``
# uniqueness key so callers can disambiguate datasets that share a
# ``table_name`` across schemas or catalogs (#30377).
return (
db.session.query(SqlaTable)
.filter_by(
database_id=database_id,
catalog=catalog,
schema=schema,
table_name=table_name,
)
.one_or_none()
)
@classmethod
def get_filterable_columns_and_operators(cls) -> Dict[str, List[str]]:
filterable = super().get_filterable_columns_and_operators()
# Add custom fields
filterable.update(DATASET_CUSTOM_FIELDS)
return filterable
class DatasetColumnDAO(BaseDAO[TableColumn]):
pass
class DatasetMetricDAO(BaseDAO[SqlMetric]):
pass