Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions superset-core/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,9 @@ classifiers = [
]
dependencies = [
"flask-appbuilder>=5.0.0,<6",
"sqlalchemy>=1.4.0,<2.0",
"sqlalchemy-utils>=0.38.0",
"typing-extensions>=4.0.0",
]

[project.urls]
Expand Down
4 changes: 4 additions & 0 deletions superset-core/src/superset_core/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,3 +14,7 @@
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.

"""
Apache Superset Core - Public API for Extension Development
"""
26 changes: 14 additions & 12 deletions superset-core/src/superset_core/api/types/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@
from flask_sqlalchemy import BaseQuery
from sqlalchemy.orm import scoped_session

from superset_core.models.base import Database, Dataset


class CoreModelsApi(ABC):
"""
Expand All @@ -43,48 +45,48 @@ def get_session() -> scoped_session:

@staticmethod
@abstractmethod
def get_dataset_model() -> Type[Any]:
def get_dataset_model() -> Type[Dataset]:
"""
Retrieve the Dataset (SqlaTable) SQLAlchemy model.
Retrieve the Dataset (SqlaTable) implementation.

:returns: The Dataset SQLAlchemy model class.
:returns: The Dataset implementation class.
"""
...

@staticmethod
@abstractmethod
def get_database_model() -> Type[Any]:
def get_database_model() -> Type[Database]:
"""
Retrieve the Database SQLAlchemy model.
Retrieve the Database implementation.

:returns: The Database SQLAlchemy model class.
:returns: The Database implementation class.
"""
...

@staticmethod
@abstractmethod
def get_datasets(query: BaseQuery | None = None, **kwargs: Any) -> list[Any]:
def get_datasets(query: BaseQuery | None = None, **kwargs: Any) -> list[Dataset]:
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: We can use Query from SQLAlchemy

"""
Retrieve Dataset (SqlaTable) entities.
Retrieve Dataset implementations.

:param query: A query with the Dataset model as the primary entity for complex
queries.
:param kwargs: Optional keyword arguments to filter datasets using SQLAlchemy's
`filter_by()`.
:returns: SqlaTable entities.
:returns: Dataset implementations.
"""
...

@staticmethod
@abstractmethod
def get_databases(query: BaseQuery | None = None, **kwargs: Any) -> list[Any]:
def get_databases(query: BaseQuery | None = None, **kwargs: Any) -> list[Database]:
"""
Retrieve Database entities.
Retrieve Database implementations.

:param query: A query with the Database model as the primary entity for complex
queries.
:param kwargs: Optional keyword arguments to filter databases using SQLAlchemy's
`filter_by()`.
:returns: Database entities.
:returns: Database implementations.
"""
...
16 changes: 16 additions & 0 deletions superset-core/src/superset_core/dao/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
# 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.
127 changes: 127 additions & 0 deletions superset-core/src/superset_core/dao/types.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
# 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.

"""Protocol interfaces for Data Access Objects."""

from abc import ABC, abstractmethod
from typing import Any, Generic, Optional, TypeVar, Union

from flask_appbuilder.models.filters import BaseFilter
from flask_sqlalchemy import BaseQuery

from superset_core.models.base import CoreModel

# Type variable bound to our CoreModel
T_Model = TypeVar("T_Model", bound=CoreModel)


class BaseDAO(Generic[T_Model], ABC):
"""
Interface for Data Access Objects.
This interface defines the base that all DAOs should implement,
providing consistent CRUD operations across Superset and extensions.
Extension developers should implement this protocol:
```python
from superset_core.dao import BaseDAO
from superset_core.models import CoreModel
class MyDAO(BaseDAO[MyCustomModel]):
model_cls = MyCustomModel
@classmethod
def find_by_id(cls, model_id: str | int) -> MyCustomModel | None:
# Implementation here
pass
```
"""

# Class attributes that implementations should define
model_cls: Optional[type[T_Model]]
base_filter: Optional[BaseFilter]
Copy link
Member

@betodealmeida betodealmeida Sep 24, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we use something more generic here instead of BaseFilter, which is tightly coupled with FAB? Maybe we could rething how base filter works — it could be something passed to the instance, eg:

dao = DatasetDAO()
datasets = dao.find_by_ids([1, 2])  # equivalent to `skip_base_filter=True`

dao_for_user = dao.filtered(User.id == current_user.id)
datasets = dao_for_user.find_by_ids([1, 2])  # equivalent to `skip_base_filter=False`

In general I think the less dependencies superset-core has, the better.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fluent Interfaces are cool! but like that makes me think why not just use Query from SQLAlchemy?
Have a somewhat static base filter is a plus for security, we enforce that all DAO operations take into account a certain filter that enforces a security constraint

id_column_name: str
uuid_column_name: str

@abstractmethod
def find_by_id(
self, model_id: Union[str, int], skip_base_filter: bool = False
) -> Optional[T_Model]:
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: In the future we could have models with composite keys

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Regarding Optional[T_Model], consider: many ORMs provide get() (raises) and get_or_none() (returns None) to handle different use cases. Do we want/need both of these or are we good with what is here?

When debugging missing entities, would we rather see AttributeError: 'NoneType' object has no attribute 'name' on line 47, or EntityNotFoundError: Dashboard with id=123 not found on line 23?

"""Find a model by ID."""
...

@abstractmethod
def find_by_id_or_uuid(
self,
model_id_or_uuid: str,
skip_base_filter: bool = False,
) -> Optional[T_Model]:
"""Find a model by ID or UUID."""
...

@abstractmethod
def find_by_ids(
self,
model_ids: Union[list[str], list[int]],
Copy link
Member

@betodealmeida betodealmeida Sep 24, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we use Collection instead of list? Here and in other places where order doesn't matter.

Also, can we use this opportunity to just use UUIDs everywhere, instead of having int IDs, string IDs, and UUIDs?

skip_base_filter: bool = False,
) -> list[T_Model]:
"""Find models by list of IDs."""
...

@abstractmethod
def find_all(self) -> list[T_Model]:
"""Get all entities that fit the base_filter."""
...

@abstractmethod
def find_one_or_none(self, **filter_by: Any) -> Optional[T_Model]:
"""Get the first entity that fits the base_filter."""
...

@abstractmethod
def create(
self,
item: Optional[T_Model] = None,
attributes: Optional[dict[str, Any]] = None,
) -> T_Model:
"""Create an object from the specified item and/or attributes."""
...

@abstractmethod
def update(
self,
item: Optional[T_Model] = None,
attributes: Optional[dict[str, Any]] = None,
) -> T_Model:
"""Update an object from the specified item and/or attributes."""
...

@abstractmethod
def delete(self, items: list[T_Model]) -> None:
"""Delete the specified items."""
...

@abstractmethod
def query(self, query: BaseQuery) -> list[T_Model]:
"""Execute query with base_filter applied."""
...

@abstractmethod
def filter_by(self, **filter_by: Any) -> list[T_Model]:
"""Get all entries that fit the base_filter."""
...
16 changes: 16 additions & 0 deletions superset-core/src/superset_core/models/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
# 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.
74 changes: 74 additions & 0 deletions superset-core/src/superset_core/models/base.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
# 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.

"""Core model base classes."""

from typing import Any

from flask_appbuilder import Model
from sqlalchemy.orm import Mapped


class CoreModel(Model):
"""
Abstract base class that extends Flask-AppBuilder's Model.
This class provides the interface contract for all Superset models.
The host package provides concrete implementations.
"""

__abstract__ = True


class Database(CoreModel):
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we call this AbstractDatabase or BaseDatabase ?

"""
Interface for Database models.
This interface defines the contract that database models should implement,
providing consistent database connectivity and metadata operations.
"""

__abstract__ = True

id = Mapped[int]
verbose_name = Mapped[str]
database_name = Mapped[str | None]

@property
def name(self) -> str:
raise NotImplementedError

@property
def backend(self) -> str:
raise NotImplementedError

@property
def data(self) -> dict[str, Any]:
raise NotImplementedError


class Dataset(CoreModel):
"""
Interface for Dataset models.
This Interface defines the contract that dataset models should implement,
providing consistent data source operations and metadata.
It provides the public API for Datasets implemented by the host application.
"""

__abstract__ = True
6 changes: 3 additions & 3 deletions superset/commands/importers/v1/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,7 @@ def _import(

@classmethod
def _get_uuids(cls) -> set[str]:
return {str(model.uuid) for model in db.session.query(cls.dao.model_cls).all()}
return {str(model.uuid) for model in db.session.query(cls.dao.model_cls).all()} # type: ignore[misc]

@transaction()
def run(self) -> None:
Expand All @@ -99,8 +99,8 @@ def validate(self) -> None: # noqa: F811
except ValidationError as exc:
exceptions.append(exc)
metadata = None
if self.dao.model_cls:
validate_metadata_type(metadata, self.dao.model_cls.__name__, exceptions)
if self.dao.model_cls: # type: ignore[misc]
validate_metadata_type(metadata, self.dao.model_cls.__name__, exceptions) # type: ignore[misc]

# load the configs and make sure we have confirmation to overwrite existing models # noqa: E501
self._configs = load_configs(
Expand Down
3 changes: 2 additions & 1 deletion superset/connectors/sqla/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@
from sqlalchemy.sql.expression import Label
from sqlalchemy.sql.selectable import Alias, TableClause
from sqlalchemy.types import JSON
from superset_core.models.base import Dataset as CoreDataset

from superset import db, is_feature_enabled, security_manager
from superset.commands.dataset.exceptions import DatasetNotFoundError
Expand Down Expand Up @@ -1090,7 +1091,7 @@ def data(self) -> dict[str, Any]:


class SqlaTable(
Model,
CoreDataset,
BaseDatasource,
ExploreMixin,
): # pylint: disable=too-many-public-methods
Expand Down
Loading
Loading