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>
676 lines
27 KiB
Python
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
|