diff --git a/.gitignore b/.gitignore index 67693a7d262..e43658f39a1 100644 --- a/.gitignore +++ b/.gitignore @@ -33,6 +33,7 @@ cover .env .envrc .idea +.roo .mypy_cache .python-version .tox diff --git a/docs/developer_portal/extensions/interacting-with-host.md b/docs/developer_portal/extensions/interacting-with-host.md index 6b51a98b2d2..c277af54326 100644 --- a/docs/developer_portal/extensions/interacting-with-host.md +++ b/docs/developer_portal/extensions/interacting-with-host.md @@ -26,6 +26,8 @@ under the License. Extensions interact with Superset through well-defined, versioned APIs provided by the `@apache-superset/core` (frontend) and `apache-superset-core` (backend) packages. These APIs are designed to be stable, discoverable, and consistent for both built-in and external extensions. +**Note**: The `superset_core.api` module provides abstract classes that are replaced with concrete implementations via dependency injection when Superset initializes. This allows extensions to use the same interfaces as the host application. + **Frontend APIs** (via `@apache-superset/core)`: The frontend extension APIs in Superset are organized into logical namespaces such as `authentication`, `commands`, `extensions`, `sqlLab`, and others. Each namespace groups related functionality, making it easy for extension authors to discover and use the APIs relevant to their needs. For example, the `sqlLab` namespace provides events and methods specific to SQL Lab, allowing extensions to react to user actions and interact with the SQL Lab environment: @@ -90,31 +92,38 @@ Backend APIs follow a similar pattern, providing access to Superset's models, se Extension endpoints are registered under a dedicated `/extensions` namespace to avoid conflicting with built-in endpoints and also because they don't share the same version constraints. By grouping all extension endpoints under `/extensions`, Superset establishes a clear boundary between core and extension functionality, making it easier to manage, document, and secure both types of APIs. ``` python -from superset_core.api import rest_api, models, query +from superset_core.api.models import Database, get_session +from superset_core.api.daos import DatabaseDAO +from superset_core.api.rest_api import add_extension_api from .api import DatasetReferencesAPI # Register a new extension REST API -rest_api.add_extension_api(DatasetReferencesAPI) +add_extension_api(DatasetReferencesAPI) -# Access Superset models with simple queries that filter out entities that -# the user doesn't have access to -databases = models.get_databases(id=database_id) +# Fetch Superset entities via the DAO to apply base filters that filter out entities +# that the user doesn't have access to +databases = DatabaseDAO.find_all() + +# ..or apply simple filters on top of base filters +databases = DatabaseDAO.filter_by(uuid=database.uuid) if not databases: - return self.response_404() + raise Exception("Database not found") -database = databases[0] +return databases[0] -# Perform complex queries using SQLAlchemy BaseQuery, also filtering -# out inaccessible entities -session = models.get_session() -db_model = models.get_database_model()) -database_query = session.query(db_model.database_name.ilike("%abc%") -databases_containing_abc = models.get_databases(query) +# Perform complex queries using SQLAlchemy Query, also filtering out +# inaccessible entities +session = get_session() +databases_query = session.query(Database).filter( + Database.database_name.ilike("%abc%") +) +return DatabaseDAO.query(databases_query) # Bypass security model for highly custom use cases -session = models.get_session() -db_model = models.get_database_model()) -all_databases_containg_abc = session.query(db_model.database_name.ilike("%abc%").all() +session = get_session() +all_databases_containing_abc = session.query(Database).filter( + Database.database_name.ilike("%abc%") +).all() ``` In the future, we plan to expand the backend APIs to support configuring security models, database engines, SQL Alchemy dialects, etc. diff --git a/docs/developer_portal/extensions/quick-start.md b/docs/developer_portal/extensions/quick-start.md index 6e89dd32094..f12dd20a167 100644 --- a/docs/developer_portal/extensions/quick-start.md +++ b/docs/developer_portal/extensions/quick-start.md @@ -128,7 +128,7 @@ The CLI generated a basic `backend/src/hello_world/entrypoint.py`. We'll create ```python from flask import Response from flask_appbuilder.api import expose, protect, safe -from superset_core.api.types.rest_api import RestApi +from superset_core.api.rest_api import RestApi class HelloWorldAPI(RestApi): diff --git a/superset-core/README.md b/superset-core/README.md index 53587e2148b..fa8e8791124 100644 --- a/superset-core/README.md +++ b/superset-core/README.md @@ -49,7 +49,7 @@ The package is organized into logical modules, each providing specific functiona from flask import request, Response from flask_appbuilder.api import expose, permission_name, protect, safe from superset_core.api import models, query, rest_api -from superset_core.api.types.rest_api import RestApi +from superset_core.api.rest_api import RestApi class DatasetReferencesAPI(RestApi): """Example extension API demonstrating core functionality.""" diff --git a/superset-core/pyproject.toml b/superset-core/pyproject.toml index 88b21657bd8..6ac389904d2 100644 --- a/superset-core/pyproject.toml +++ b/superset-core/pyproject.toml @@ -43,6 +43,10 @@ classifiers = [ ] dependencies = [ "flask-appbuilder>=5.0.2,<6", + "sqlalchemy>=1.4.0,<2.0", + "sqlalchemy-utils>=0.38.0", + "sqlglot>=27.15.2, <28", + "typing-extensions>=4.0.0", ] [project.urls] diff --git a/superset-core/src/superset_core/__init__.py b/superset-core/src/superset_core/__init__.py index 13a83393a91..58439993a74 100644 --- a/superset-core/src/superset_core/__init__.py +++ b/superset-core/src/superset_core/__init__.py @@ -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 with core functions of Superset +""" diff --git a/superset-core/src/superset_core/api/__init__.py b/superset-core/src/superset_core/api/__init__.py index 70a9d4080ea..13a83393a91 100644 --- a/superset-core/src/superset_core/api/__init__.py +++ b/superset-core/src/superset_core/api/__init__.py @@ -14,11 +14,3 @@ # KIND, either express or implied. See the License for the # specific language governing permissions and limitations # under the License. - -from .types.models import CoreModelsApi -from .types.query import CoreQueryApi -from .types.rest_api import CoreRestApi - -models: CoreModelsApi -rest_api: CoreRestApi -query: CoreQueryApi diff --git a/superset-core/src/superset_core/api/daos.py b/superset-core/src/superset_core/api/daos.py new file mode 100644 index 00000000000..3dc4cf0a7de --- /dev/null +++ b/superset-core/src/superset_core/api/daos.py @@ -0,0 +1,262 @@ +# 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. + +""" +Data Access Object API for superset-core. + +Provides dependency-injected DAO classes that will be replaced by +host implementations during initialization. + +Usage: + from superset_core.api.daos import DatasetDAO, DatabaseDAO + + # Use standard BaseDAO methods + datasets = DatasetDAO.find_all() + dataset = DatasetDAO.find_one_or_none(id=123) + DatasetDAO.create(attributes={"name": "New Dataset"}) +""" + +from abc import ABC, abstractmethod +from typing import Any, ClassVar, Generic, TypeVar + +from flask_appbuilder.models.filters import BaseFilter +from sqlalchemy.orm import Query as SQLAQuery + +from superset_core.api.models import ( + Chart, + CoreModel, + Dashboard, + Database, + Dataset, + KeyValue, + Query, + SavedQuery, + Tag, + User, +) + +# Type variable bound to our CoreModel +T = TypeVar("T", bound=CoreModel) + + +class BaseDAO(Generic[T], ABC): + """ + Abstract base class for DAOs. + + This ABC defines the base that all DAOs should implement, + providing consistent CRUD operations across Superset and extensions. + """ + + # Due to mypy limitations, we can't have `type[T]` here + model_cls: ClassVar[type[Any] | None] + base_filter: ClassVar[BaseFilter | None] + id_column_name: ClassVar[str] + uuid_column_name: ClassVar[str] + + @classmethod + @abstractmethod + def find_all(cls) -> list[T]: + """Get all entities that fit the base_filter.""" + ... + + @classmethod + @abstractmethod + def find_one_or_none(cls, **filter_by: Any) -> T | None: + """Get the first entity that fits the base_filter.""" + ... + + @classmethod + @abstractmethod + def create( + cls, + item: T | None = None, + attributes: dict[str, Any] | None = None, + ) -> T: + """Create an object from the specified item and/or attributes.""" + ... + + @classmethod + @abstractmethod + def update( + cls, + item: T | None = None, + attributes: dict[str, Any] | None = None, + ) -> T: + """Update an object from the specified item and/or attributes.""" + ... + + @classmethod + @abstractmethod + def delete(cls, items: list[T]) -> None: + """Delete the specified items.""" + ... + + @classmethod + @abstractmethod + def query(cls, query: SQLAQuery) -> list[T]: + """Execute query with base_filter applied.""" + ... + + @classmethod + @abstractmethod + def filter_by(cls, **filter_by: Any) -> list[T]: + """Get all entries that fit the base_filter.""" + ... + + +class DatasetDAO(BaseDAO[Dataset]): + """ + Abstract Dataset DAO interface. + + Host implementations will replace this class during initialization + with a concrete implementation providing actual functionality. + """ + + # Class variables that will be set by host implementation + model_cls = None + base_filter = None + id_column_name = "id" + uuid_column_name = "uuid" + + +class DatabaseDAO(BaseDAO[Database]): + """ + Abstract Database DAO interface. + + Host implementations will replace this class during initialization + with a concrete implementation providing actual functionality. + """ + + # Class variables that will be set by host implementation + model_cls = None + base_filter = None + id_column_name = "id" + uuid_column_name = "uuid" + + +class ChartDAO(BaseDAO[Chart]): + """ + Abstract Chart DAO interface. + + Host implementations will replace this class during initialization + with a concrete implementation providing actual functionality. + """ + + # Class variables that will be set by host implementation + model_cls = None + base_filter = None + id_column_name = "id" + uuid_column_name = "uuid" + + +class DashboardDAO(BaseDAO[Dashboard]): + """ + Abstract Dashboard DAO interface. + + Host implementations will replace this class during initialization + with a concrete implementation providing actual functionality. + """ + + # Class variables that will be set by host implementation + model_cls = None + base_filter = None + id_column_name = "id" + uuid_column_name = "uuid" + + +class UserDAO(BaseDAO[User]): + """ + Abstract User DAO interface. + + Host implementations will replace this class during initialization + with a concrete implementation providing actual functionality. + """ + + # Class variables that will be set by host implementation + model_cls = None + base_filter = None + id_column_name = "id" + + +class QueryDAO(BaseDAO[Query]): + """ + Abstract Query DAO interface. + + Host implementations will replace this class during initialization + with a concrete implementation providing actual functionality. + """ + + # Class variables that will be set by host implementation + model_cls = None + base_filter = None + id_column_name = "id" + + +class SavedQueryDAO(BaseDAO[SavedQuery]): + """ + Abstract SavedQuery DAO interface. + + Host implementations will replace this class during initialization + with a concrete implementation providing actual functionality. + """ + + # Class variables that will be set by host implementation + model_cls = None + base_filter = None + id_column_name = "id" + + +class TagDAO(BaseDAO[Tag]): + """ + Abstract Tag DAO interface. + + Host implementations will replace this class during initialization + with a concrete implementation providing actual functionality. + """ + + # Class variables that will be set by host implementation + model_cls = None + base_filter = None + id_column_name = "id" + + +class KeyValueDAO(BaseDAO[KeyValue]): + """ + Abstract KeyValue DAO interface. + + Host implementations will replace this class during initialization + with a concrete implementation providing actual functionality. + """ + + # Class variables that will be set by host implementation + model_cls = None + base_filter = None + id_column_name = "id" + + +__all__ = [ + "BaseDAO", + "DatasetDAO", + "DatabaseDAO", + "ChartDAO", + "DashboardDAO", + "UserDAO", + "QueryDAO", + "SavedQueryDAO", + "TagDAO", + "KeyValueDAO", +] diff --git a/superset-core/src/superset_core/api/models.py b/superset-core/src/superset_core/api/models.py new file mode 100644 index 00000000000..2b78665a655 --- /dev/null +++ b/superset-core/src/superset_core/api/models.py @@ -0,0 +1,295 @@ +# 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. + +""" +Model API for superset-core. + +Provides model classes that will be replaced by host implementations +during initialization for extension developers to use. + +Usage: + from superset_core.api.models import Dataset, Database, get_session + + # Use as regular model classes + dataset = Dataset(name="My Dataset") + db = Database(database_name="My DB") + session = get_session() +""" + +from datetime import datetime +from typing import Any +from uuid import UUID + +from flask_appbuilder import Model +from sqlalchemy.orm import scoped_session + + +class CoreModel(Model): + """ + Abstract base class that extends Flask-AppBuilder's Model. + + This base class provides the interface contract for all Superset models. + The host package provides concrete implementations. + """ + + __abstract__ = True + + +class Database(CoreModel): + """ + Abstract class for Database models. + + This abstract class defines the contract that database models should implement, + providing consistent database connectivity and metadata operations. + """ + + __abstract__ = True + + id: int + verbose_name: str + database_name: 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): + """ + Abstract class for Dataset models. + + This abstract class 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 + + # Type hints for expected attributes (no actual field definitions) + id: int + uuid: UUID | None + table_name: str | None + main_dttm_col: str | None + database_id: int | None + schema: str | None + catalog: str | None + sql: str | None # For virtual datasets + description: str | None + default_endpoint: str | None + is_featured: bool + filter_select_enabled: bool + offset: int + cache_timeout: int + params: str + perm: str | None + schema_perm: str | None + catalog_perm: str | None + is_managed_externally: bool + external_url: str | None + fetch_values_predicate: str | None + is_sqllab_view: bool + template_params: str | None + extra: str | None # JSON string + normalize_columns: bool + always_filter_main_dttm: bool + folders: str | None # JSON string + + +class Chart(CoreModel): + """ + Abstract Chart/Slice model interface. + + Host implementations will replace this class during initialization + with concrete implementation providing actual functionality. + """ + + __abstract__ = True + + # Type hints for expected attributes (no actual field definitions) + id: int + uuid: UUID | None + slice_name: str | None + datasource_id: int | None + datasource_type: str | None + datasource_name: str | None + viz_type: str | None + params: str | None + query_context: str | None + description: str | None + cache_timeout: int + certified_by: str | None + certification_details: str | None + is_managed_externally: bool + external_url: str | None + + +class Dashboard(CoreModel): + """ + Abstract Dashboard model interface. + + Host implementations will replace this class during initialization + with concrete implementation providing actual functionality. + """ + + __abstract__ = True + + # Type hints for expected attributes (no actual field definitions) + id: int + uuid: UUID | None + dashboard_title: str | None + position_json: str | None + description: str | None + css: str | None + json_metadata: str | None + slug: str | None + published: bool + certified_by: str | None + certification_details: str | None + is_managed_externally: bool + external_url: str | None + + +class User(CoreModel): + """ + Abstract User model interface. + + Host implementations will replace this class during initialization + with concrete implementation providing actual functionality. + """ + + __abstract__ = True + + # Type hints for expected attributes (no actual field definitions) + id: int + username: str | None + email: str | None + first_name: str | None + last_name: str | None + active: bool + + +class Query(CoreModel): + """ + Abstract Query model interface. + + Host implementations will replace this class during initialization + with concrete implementation providing actual functionality. + """ + + __abstract__ = True + + # Type hints for expected attributes (no actual field definitions) + id: int + client_id: str | None + database_id: int | None + sql: str | None + status: str | None + user_id: int | None + progress: int + error_message: str | None + + +class SavedQuery(CoreModel): + """ + Abstract SavedQuery model interface. + + Host implementations will replace this class during initialization + with concrete implementation providing actual functionality. + """ + + __abstract__ = True + + # Type hints for expected attributes (no actual field definitions) + id: int + uuid: UUID | None + label: str | None + sql: str | None + database_id: int | None + description: str | None + user_id: int | None + + +class Tag(CoreModel): + """ + Abstract Tag model interface. + + Host implementations will replace this class during initialization + with concrete implementation providing actual functionality. + """ + + __abstract__ = True + + # Type hints for expected attributes (no actual field definitions) + id: int + name: str | None + type: str | None + + +class KeyValue(CoreModel): + """ + Abstract KeyValue model interface. + + Host implementations will replace this class during initialization + with concrete implementation providing actual functionality. + """ + + __abstract__ = True + + id: int + uuid: UUID | None + resource: str | None + value: str | None # Encoded value + expires_on: datetime | None + created_by_fk: int | None + changed_by_fk: int | None + + +def get_session() -> scoped_session: + """ + Retrieve the SQLAlchemy session to directly interface with the + Superset models. + + Host implementations will replace this function during initialization + with a concrete implementation providing actual functionality. + + :returns: The SQLAlchemy scoped session instance. + """ + raise NotImplementedError("Function will be replaced during initialization") + + +__all__ = [ + "Dataset", + "Database", + "Chart", + "Dashboard", + "User", + "Query", + "SavedQuery", + "Tag", + "KeyValue", + "CoreModel", + "get_session", +] diff --git a/superset-core/src/superset_core/api/query.py b/superset-core/src/superset_core/api/query.py new file mode 100644 index 00000000000..050067e9e3b --- /dev/null +++ b/superset-core/src/superset_core/api/query.py @@ -0,0 +1,51 @@ +# 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. + +""" +Query API for superset-core. + +Provides dependency-injected query utility functions that will be replaced by +host implementations during initialization. + +Usage: + from superset_core.api.query import get_sqlglot_dialect + + dialect = get_sqlglot_dialect(database) +""" + +from typing import TYPE_CHECKING + +from sqlglot import Dialects + +if TYPE_CHECKING: + from superset_core.api.models import Database + + +def get_sqlglot_dialect(database: "Database") -> Dialects: + """ + Get the SQLGlot dialect for the specified database. + + Host implementations will replace this function during initialization + with a concrete implementation providing actual functionality. + + :param database: The database instance to get the dialect for. + :returns: The SQLGlot dialect enum corresponding to the database. + """ + raise NotImplementedError("Function will be replaced during initialization") + + +__all__ = ["get_sqlglot_dialect"] diff --git a/superset-core/src/superset_core/api/rest_api.py b/superset-core/src/superset_core/api/rest_api.py new file mode 100644 index 00000000000..05ead50a906 --- /dev/null +++ b/superset-core/src/superset_core/api/rest_api.py @@ -0,0 +1,72 @@ +# 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. + +""" +REST API functions for superset-core. + +Provides dependency-injected REST API utility functions that will be replaced by +host implementations during initialization. + +Usage: + from superset_core.api.rest_api import add_api, add_extension_api + + add_api(MyCustomAPI) + add_extension_api(MyExtensionAPI) +""" + +from flask_appbuilder.api import BaseApi + + +class RestApi(BaseApi): + """ + Base REST API class for Superset with browser login support. + + This class extends Flask-AppBuilder's BaseApi and enables browser-based + authentication by default. + """ + + allow_browser_login = True + + +def add_api(api: type[RestApi]) -> None: + """ + Add a REST API to the Superset API. + + Host implementations will replace this function during initialization + with a concrete implementation providing actual functionality. + + :param api: A REST API instance. + :returns: None. + """ + raise NotImplementedError("Function will be replaced during initialization") + + +def add_extension_api(api: type[RestApi]) -> None: + """ + Add an extension REST API to the Superset API. + + Host implementations will replace this function during initialization + with a concrete implementation providing actual functionality. + + :param api: An extension REST API instance. These are placed under + the /extensions resource. + :returns: None. + """ + raise NotImplementedError("Function will be replaced during initialization") + + +__all__ = ["RestApi", "add_api", "add_extension_api"] diff --git a/superset-core/src/superset_core/api/types/__init__.py b/superset-core/src/superset_core/api/types/__init__.py deleted file mode 100644 index 13a83393a91..00000000000 --- a/superset-core/src/superset_core/api/types/__init__.py +++ /dev/null @@ -1,16 +0,0 @@ -# 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. diff --git a/superset-core/src/superset_core/api/types/models.py b/superset-core/src/superset_core/api/types/models.py deleted file mode 100644 index 2adbddf3499..00000000000 --- a/superset-core/src/superset_core/api/types/models.py +++ /dev/null @@ -1,90 +0,0 @@ -# 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 abc import ABC, abstractmethod -from typing import Any, Type - -from flask_sqlalchemy import BaseQuery -from sqlalchemy.orm import scoped_session - - -class CoreModelsApi(ABC): - """ - Abstract interface for accessing Superset data models. - - This class defines the contract for retrieving SQLAlchemy sessions - and model instances for datasets and databases within Superset. - """ - - @staticmethod - @abstractmethod - def get_session() -> scoped_session: - """ - Retrieve the SQLAlchemy session to directly interface with the - Superset models. - - :returns: The SQLAlchemy scoped session instance. - """ - ... - - @staticmethod - @abstractmethod - def get_dataset_model() -> Type[Any]: - """ - Retrieve the Dataset (SqlaTable) SQLAlchemy model. - - :returns: The Dataset SQLAlchemy model class. - """ - ... - - @staticmethod - @abstractmethod - def get_database_model() -> Type[Any]: - """ - Retrieve the Database SQLAlchemy model. - - :returns: The Database SQLAlchemy model class. - """ - ... - - @staticmethod - @abstractmethod - def get_datasets(query: BaseQuery | None = None, **kwargs: Any) -> list[Any]: - """ - Retrieve Dataset (SqlaTable) entities. - - :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. - """ - ... - - @staticmethod - @abstractmethod - def get_databases(query: BaseQuery | None = None, **kwargs: Any) -> list[Any]: - """ - Retrieve Database entities. - - :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. - """ - ... diff --git a/superset-core/src/superset_core/api/types/query.py b/superset-core/src/superset_core/api/types/query.py deleted file mode 100644 index 28b78a7352a..00000000000 --- a/superset-core/src/superset_core/api/types/query.py +++ /dev/null @@ -1,41 +0,0 @@ -# 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 abc import ABC, abstractmethod -from typing import Any - -from sqlglot import Dialects - - -class CoreQueryApi(ABC): - """ - Abstract interface for query-related operations. - - This class defines the contract for database query operations, - including dialect handling and query processing. - """ - - @staticmethod - @abstractmethod - def get_sqlglot_dialect(database: Any) -> Dialects: - """ - Get the SQLGlot dialect for the specified database. - - :param database: The database instance to get the dialect for. - :returns: The SQLGlot dialect enum corresponding to the database. - """ - ... diff --git a/superset-core/src/superset_core/api/types/rest_api.py b/superset-core/src/superset_core/api/types/rest_api.py deleted file mode 100644 index a451c02c3ca..00000000000 --- a/superset-core/src/superset_core/api/types/rest_api.py +++ /dev/null @@ -1,64 +0,0 @@ -# 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 abc import ABC, abstractmethod -from typing import Type - -from flask_appbuilder.api import BaseApi - - -class RestApi(BaseApi): - """ - Base REST API class for Superset with browser login support. - - This class extends Flask-AppBuilder's BaseApi and enables browser-based - authentication by default. - """ - - allow_browser_login = True - - -class CoreRestApi(ABC): - """ - Abstract interface for managing REST APIs in Superset. - - This class defines the contract for adding and managing REST APIs, - including both core APIs and extension APIs. - """ - - @staticmethod - @abstractmethod - def add_api(api: Type[RestApi]) -> None: - """ - Add a REST API to the Superset API. - - :param api: A REST API instance. - :returns: None. - """ - ... - - @staticmethod - @abstractmethod - def add_extension_api(api: Type[RestApi]) -> None: - """ - Add an extension REST API to the Superset API. - - :param api: An extension REST API instance. These are placed under - the /extensions resource. - :returns: None. - """ - ... diff --git a/superset/commands/security/create.py b/superset/commands/security/create.py index 0288cf4d0b9..65bbf28900f 100644 --- a/superset/commands/security/create.py +++ b/superset/commands/security/create.py @@ -44,7 +44,7 @@ class CreateRLSRuleCommand(BaseCommand): def validate(self) -> None: roles = populate_roles(self._roles) tables = ( - db.session.query(SqlaTable).filter(SqlaTable.id.in_(self._tables)).all() + db.session.query(SqlaTable).filter(SqlaTable.id.in_(self._tables)).all() # type: ignore[attr-defined] ) if len(tables) != len(self._tables): raise DatasourceNotFoundValidationError() diff --git a/superset/commands/security/update.py b/superset/commands/security/update.py index fa17b249b47..54b1dce8d07 100644 --- a/superset/commands/security/update.py +++ b/superset/commands/security/update.py @@ -51,7 +51,7 @@ class UpdateRLSRuleCommand(BaseCommand): raise RLSRuleNotFoundError() roles = populate_roles(self._roles) tables = ( - db.session.query(SqlaTable).filter(SqlaTable.id.in_(self._tables)).all() + db.session.query(SqlaTable).filter(SqlaTable.id.in_(self._tables)).all() # type: ignore[attr-defined] ) if len(tables) != len(self._tables): raise DatasourceNotFoundValidationError() diff --git a/superset/connectors/sqla/models.py b/superset/connectors/sqla/models.py index 31eadbd8d8f..e0fbfbd665c 100644 --- a/superset/connectors/sqla/models.py +++ b/superset/connectors/sqla/models.py @@ -67,6 +67,7 @@ from sqlalchemy.sql.elements import ColumnClause, TextClause from sqlalchemy.sql.expression import Label from sqlalchemy.sql.selectable import Alias, TableClause from sqlalchemy.types import JSON +from superset_core.api.models import Dataset as CoreDataset from superset import db, is_feature_enabled, security_manager from superset.commands.dataset.exceptions import DatasetNotFoundError @@ -1090,7 +1091,7 @@ sqlatable_user = DBTable( class SqlaTable( - Model, + CoreDataset, BaseDatasource, ExploreMixin, ): # pylint: disable=too-many-public-methods @@ -1200,7 +1201,7 @@ class SqlaTable( @property def description_markeddown(self) -> str: - return utils.markdown(self.description) + return utils.markdown(self.description or "") @property def datasource_name(self) -> str: @@ -1819,7 +1820,7 @@ class SqlaTable( # # table.column IN (SELECT 1 FROM (SELECT 1) WHERE 1!=1) filters = [ - method.in_(perms) + method.in_(perms) # type: ignore[union-attr] for method, perms in zip( (SqlaTable.perm, SqlaTable.schema_perm, SqlaTable.catalog_perm), (permissions, schema_perms, catalog_perms), diff --git a/superset/core/api/core_api_injection.py b/superset/core/api/core_api_injection.py new file mode 100644 index 00000000000..28e3c6be319 --- /dev/null +++ b/superset/core/api/core_api_injection.py @@ -0,0 +1,180 @@ +# 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 API Dependency Injection + +This module handles the injection of concrete Superset implementations +into the abstract superset-core API modules. This allows the core API +to be used with direct imports while maintaining loose coupling. +""" + +from typing import Any, TYPE_CHECKING + +from sqlalchemy.orm import scoped_session + +if TYPE_CHECKING: + from superset_core.api.models import Database + from superset_core.api.rest_api import RestApi + + +__all__ = ["initialize_core_api_dependencies"] + + +def inject_dao_implementations() -> None: + """ + Replace abstract DAO classes in superset_core.api.daos with concrete + implementations from Superset. + """ + import superset_core.api.daos as core_dao_module + + from superset.daos.chart import ChartDAO as HostChartDAO + from superset.daos.dashboard import DashboardDAO as HostDashboardDAO + from superset.daos.database import DatabaseDAO as HostDatabaseDAO + from superset.daos.dataset import DatasetDAO as HostDatasetDAO + from superset.daos.key_value import KeyValueDAO as HostKeyValueDAO + from superset.daos.query import ( + QueryDAO as HostQueryDAO, + SavedQueryDAO as HostSavedQueryDAO, + ) + from superset.daos.tag import TagDAO as HostTagDAO + from superset.daos.user import UserDAO as HostUserDAO + + # Replace abstract classes with concrete implementations + core_dao_module.DatasetDAO = HostDatasetDAO # type: ignore[assignment,misc] + core_dao_module.DatabaseDAO = HostDatabaseDAO # type: ignore[assignment,misc] + core_dao_module.ChartDAO = HostChartDAO # type: ignore[assignment,misc] + core_dao_module.DashboardDAO = HostDashboardDAO # type: ignore[assignment,misc] + core_dao_module.UserDAO = HostUserDAO # type: ignore[assignment,misc] + core_dao_module.QueryDAO = HostQueryDAO # type: ignore[assignment,misc] + core_dao_module.SavedQueryDAO = HostSavedQueryDAO # type: ignore[assignment,misc] + core_dao_module.TagDAO = HostTagDAO # type: ignore[assignment,misc] + core_dao_module.KeyValueDAO = HostKeyValueDAO # type: ignore[assignment,misc] + + core_dao_module.__all__ = [ + "DatasetDAO", + "DatabaseDAO", + "ChartDAO", + "DashboardDAO", + "UserDAO", + "QueryDAO", + "SavedQueryDAO", + "TagDAO", + "KeyValueDAO", + ] + + +def inject_model_implementations() -> None: + """ + Replace abstract model classes in superset_core.api.models with concrete + implementations from Superset. + + Uses in-place replacement to maintain single import location for extensions. + """ + import superset_core.api.models as core_models_module + from flask_appbuilder.security.sqla.models import User as HostUser + + from superset.connectors.sqla.models import SqlaTable as HostDataset + from superset.key_value.models import KeyValueEntry as HostKeyValue + from superset.models.core import Database as HostDatabase + from superset.models.dashboard import Dashboard as HostDashboard + from superset.models.slice import Slice as HostChart + from superset.models.sql_lab import Query as HostQuery, SavedQuery as HostSavedQuery + from superset.tags.models import Tag as HostTag + + # In-place replacement - extensions will import concrete implementations + core_models_module.Database = HostDatabase # type: ignore[misc] + core_models_module.Dataset = HostDataset # type: ignore[misc] + core_models_module.Chart = HostChart # type: ignore[misc] + core_models_module.Dashboard = HostDashboard # type: ignore[misc] + core_models_module.User = HostUser # type: ignore[misc] + core_models_module.Query = HostQuery # type: ignore[misc] + core_models_module.SavedQuery = HostSavedQuery # type: ignore[misc] + core_models_module.Tag = HostTag # type: ignore[misc] + core_models_module.KeyValue = HostKeyValue # type: ignore[misc] + + +def inject_query_implementations() -> None: + """ + Replace abstract query functions in superset_core.api.query with concrete + implementations from Superset. + """ + import superset_core.api.query as core_query_module + + from superset.sql.parse import SQLGLOT_DIALECTS + + def get_sqlglot_dialect(database: "Database") -> Any: + return ( + SQLGLOT_DIALECTS.get(database.backend) + or __import__("sqlglot").Dialects.DIALECT + ) + + core_query_module.get_sqlglot_dialect = get_sqlglot_dialect + core_query_module.__all__ = ["get_sqlglot_dialect"] + + +def inject_rest_api_implementations() -> None: + """ + Replace abstract REST API functions in superset_core.api.rest_api with concrete + implementations from Superset. + """ + import superset_core.api.rest_api as core_rest_api_module + + from superset.extensions import appbuilder + + def add_api(api: "type[RestApi]") -> None: + view = appbuilder.add_api(api) + appbuilder._add_permission(view, True) + + def add_extension_api(api: "type[RestApi]") -> None: + api.route_base = "/extensions/" + (api.resource_name or "") + view = appbuilder.add_api(api) + appbuilder._add_permission(view, True) + + core_rest_api_module.add_api = add_api + core_rest_api_module.add_extension_api = add_extension_api + core_rest_api_module.__all__ = ["RestApi", "add_api", "add_extension_api"] + + +def inject_model_session_implementation() -> None: + """ + Replace abstract get_session function in superset_core.api.models with concrete + implementation from Superset. + """ + import superset_core.api.models as core_models_module + + def get_session() -> scoped_session: + from superset import db + + return db.session + + core_models_module.get_session = get_session + # Update __all__ to include get_session (already done in the module) + + +def initialize_core_api_dependencies() -> None: + """ + Initialize all dependency injections for the superset-core API. + + This should be called during Superset initialization to replace + abstract classes and functions with concrete implementations. + """ + inject_dao_implementations() + inject_model_implementations() + inject_model_session_implementation() + inject_query_implementations() + inject_rest_api_implementations() diff --git a/superset/core/api/types/__init__.py b/superset/core/api/types/__init__.py deleted file mode 100644 index 13a83393a91..00000000000 --- a/superset/core/api/types/__init__.py +++ /dev/null @@ -1,16 +0,0 @@ -# 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. diff --git a/superset/core/api/types/models.py b/superset/core/api/types/models.py deleted file mode 100644 index 001678987fd..00000000000 --- a/superset/core/api/types/models.py +++ /dev/null @@ -1,72 +0,0 @@ -# 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 typing import Any, Type - -from flask_sqlalchemy import BaseQuery -from sqlalchemy.orm import scoped_session -from superset_core.api.types.models import CoreModelsApi - - -class HostModelsApi(CoreModelsApi): - @staticmethod - def get_session() -> scoped_session: - from superset import db - - return db.session - - @staticmethod - def get_dataset_model() -> Type[Any]: - """ - Retrieve the Dataset (SqlaTable) SQLAlchemy model. - """ - from superset.connectors.sqla.models import SqlaTable - - return SqlaTable - - @staticmethod - def get_database_model() -> Type[Any]: - """ - Retrieve the Database SQLAlchemy model. - """ - from superset.models.core import Database - - return Database - - @staticmethod - def get_datasets(query: BaseQuery | None = None, **kwargs: Any) -> list[Any]: - """ - Retrieve Dataset (SqlaTable) entities. - - :param query: A query with the Dataset model as the primary entity. - :returns: SqlaTable entities. - """ - from superset.daos.dataset import DatasetDAO - - if query: - return DatasetDAO.query(query) - - return DatasetDAO.filter_by(**kwargs) - - @staticmethod - def get_databases(query: BaseQuery | None = None, **kwargs: Any) -> list[Any]: - from superset.daos.database import DatabaseDAO - - if query: - return DatabaseDAO.query(query) - - return DatabaseDAO.filter_by(**kwargs) diff --git a/superset/core/api/types/query.py b/superset/core/api/types/query.py deleted file mode 100644 index 0a0156153ec..00000000000 --- a/superset/core/api/types/query.py +++ /dev/null @@ -1,29 +0,0 @@ -# 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 typing import Any - -from sqlglot import Dialects # pylint: disable=disallowed-sql-import -from superset_core.api.types.query import CoreQueryApi - -from superset.sql.parse import SQLGLOT_DIALECTS - - -class HostQueryApi(CoreQueryApi): - @staticmethod - def get_sqlglot_dialect(database: Any) -> Dialects: - return SQLGLOT_DIALECTS.get(database.backend) or Dialects.DIALECT diff --git a/superset/core/api/types/rest_api.py b/superset/core/api/types/rest_api.py deleted file mode 100644 index 2d45556f6a3..00000000000 --- a/superset/core/api/types/rest_api.py +++ /dev/null @@ -1,35 +0,0 @@ -# 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 typing import Type - -from superset_core.api.types.rest_api import CoreRestApi, RestApi - -from superset.extensions import appbuilder - - -class HostRestApi(CoreRestApi): - @staticmethod - def add_api(api: Type[RestApi]) -> None: - view = appbuilder.add_api(api) - appbuilder._add_permission(view, True) - - @staticmethod - def add_extension_api(api: Type[RestApi]) -> None: - api.route_base = "/extensions/" + (api.resource_name or "") - view = appbuilder.add_api(api) - appbuilder._add_permission(view, True) diff --git a/superset/daos/base.py b/superset/daos/base.py index 4c7c90f8c79..557bae54e10 100644 --- a/superset/daos/base.py +++ b/superset/daos/base.py @@ -21,6 +21,7 @@ import uuid as uuid_lib from enum import Enum from typing import ( Any, + ClassVar, Dict, Generic, get_args, @@ -33,24 +34,25 @@ from typing import ( import sqlalchemy as sa from flask_appbuilder.models.filters import BaseFilter -from flask_appbuilder.models.sqla import Model from flask_appbuilder.models.sqla.interface import SQLAInterface -from flask_sqlalchemy import BaseQuery from pydantic import BaseModel, Field from sqlalchemy import asc, cast, desc, or_, Text from sqlalchemy.exc import SQLAlchemyError, StatementError from sqlalchemy.ext.hybrid import hybrid_property from sqlalchemy.inspection import inspect -from sqlalchemy.orm import ColumnProperty, joinedload, RelationshipProperty +from sqlalchemy.orm import ColumnProperty, joinedload, Query, RelationshipProperty +from superset_core.api.daos import BaseDAO as CoreBaseDAO +from superset_core.api.models import CoreModel from superset.daos.exceptions import ( DAOFindFailedError, ) from superset.extensions import db -logger = logging.getLogger(__name__) +T = TypeVar("T", bound=CoreModel) -T = TypeVar("T", bound=Model) + +logger = logging.getLogger(__name__) class ColumnOperatorEnum(str, Enum): @@ -151,22 +153,23 @@ class ColumnOperator(BaseModel): value: Any = Field(None, description="Value for the filter") -class BaseDAO(Generic[T]): +class BaseDAO(CoreBaseDAO[T], Generic[T]): """ Base DAO, implement base CRUD sqlalchemy operations """ - model_cls: type[Model] | None = None + # Due to mypy limitations, we can't have `type[T]` here + model_cls: ClassVar[type[Any] | None] = None """ Child classes need to state the Model class so they don't need to implement basic create, update and delete methods """ - base_filter: BaseFilter | None = None + base_filter: ClassVar[BaseFilter | None] = None """ Child classes can register base filtering to be applied to all filter methods """ - id_column_name = "id" - uuid_column_name = "uuid" + id_column_name: ClassVar[str] = "id" + uuid_column_name: ClassVar[str] = "uuid" def __init_subclass__(cls) -> None: cls.model_cls = get_args( @@ -437,7 +440,7 @@ class BaseDAO(Generic[T]): db.session.delete(item) @classmethod - def query(cls, query: BaseQuery) -> list[T]: + def query(cls, query: Query) -> list[T]: """ Get all that fit the `base_filter` based on a BaseQuery object """ diff --git a/superset/initialization/__init__.py b/superset/initialization/__init__.py index 133456f35a6..88545739769 100644 --- a/superset/initialization/__init__.py +++ b/superset/initialization/__init__.py @@ -35,13 +35,9 @@ from flask_appbuilder.utils.base import get_safe_redirect from flask_babel import lazy_gettext as _, refresh from flask_compress import Compress from flask_session import Session -from superset_core import api as core_api from werkzeug.middleware.proxy_fix import ProxyFix from superset.constants import CHANGE_ME_SECRET_KEY -from superset.core.api.types.models import HostModelsApi -from superset.core.api.types.query import HostQueryApi -from superset.core.api.types.rest_api import HostRestApi from superset.databases.utils import make_url_safe from superset.extensions import ( _event_logger, @@ -522,12 +518,13 @@ class SupersetAppInitializer: # pylint: disable=too-many-public-methods icon="fa-lock", ) - def init_core_api(self) -> None: - global core_api + def init_core_api_dependencies(self) -> None: + """Initialize core API dependency injection for direct import patterns.""" + from superset.core.api.core_api_injection import ( + initialize_core_api_dependencies, + ) - core_api.models = HostModelsApi() - core_api.rest_api = HostRestApi() - core_api.query = HostQueryApi() + initialize_core_api_dependencies() def init_extensions(self) -> None: from superset.extensions.utils import ( @@ -582,7 +579,7 @@ class SupersetAppInitializer: # pylint: disable=too-many-public-methods self.init_views() if feature_flag_manager.is_feature_enabled("ENABLE_EXTENSIONS"): - self.init_core_api() + self.init_core_api_dependencies() self.init_extensions() def check_secret_key(self) -> None: diff --git a/superset/key_value/models.py b/superset/key_value/models.py index 2c3c0a21a53..cddf09c70bb 100644 --- a/superset/key_value/models.py +++ b/superset/key_value/models.py @@ -16,9 +16,9 @@ # under the License. from datetime import datetime -from flask_appbuilder import Model from sqlalchemy import Column, DateTime, ForeignKey, Integer, LargeBinary, String from sqlalchemy.orm import relationship +from superset_core.api.models import KeyValue as CoreKeyValue from superset import security_manager from superset.models.helpers import AuditMixinNullable, ImportExportMixin @@ -26,7 +26,7 @@ from superset.models.helpers import AuditMixinNullable, ImportExportMixin VALUE_MAX_SIZE = 2**24 - 1 -class KeyValueEntry(AuditMixinNullable, ImportExportMixin, Model): +class KeyValueEntry(CoreKeyValue, AuditMixinNullable, ImportExportMixin): """Key value store entity""" __tablename__ = "key_value" diff --git a/superset/models/core.py b/superset/models/core.py index f6643d18ff2..b567d574c33 100755 --- a/superset/models/core.py +++ b/superset/models/core.py @@ -60,6 +60,7 @@ from sqlalchemy.orm import relationship from sqlalchemy.pool import NullPool from sqlalchemy.schema import UniqueConstraint from sqlalchemy.sql import ColumnElement, expression, Select +from superset_core.api.models import Database as CoreDatabase from superset import db, db_engine_specs, is_feature_enabled from superset.commands.database.exceptions import DatabaseInvalidError @@ -139,7 +140,7 @@ class ConfigurationMethod(StrEnum): DYNAMIC_FORM = "dynamic_form" -class Database(Model, AuditMixinNullable, ImportExportMixin): # pylint: disable=too-many-public-methods +class Database(CoreDatabase, AuditMixinNullable, ImportExportMixin): # pylint: disable=too-many-public-methods """An ORM object that stores Database related information""" __tablename__ = "dbs" diff --git a/superset/models/dashboard.py b/superset/models/dashboard.py index b6c10df310a..359d07bf609 100644 --- a/superset/models/dashboard.py +++ b/superset/models/dashboard.py @@ -41,6 +41,7 @@ from sqlalchemy.engine.base import Connection from sqlalchemy.orm import relationship, subqueryload from sqlalchemy.orm.mapper import Mapper from sqlalchemy.sql.elements import BinaryExpression +from superset_core.api.models import Dashboard as CoreDashboard from superset import db, is_feature_enabled, security_manager from superset.connectors.sqla.models import BaseDatasource, SqlaTable @@ -127,7 +128,7 @@ DashboardRoles = Table( ) -class Dashboard(AuditMixinNullable, ImportExportMixin, Model): +class Dashboard(CoreDashboard, AuditMixinNullable, ImportExportMixin): """The dashboard object!""" __tablename__ = "dashboards" diff --git a/superset/models/slice.py b/superset/models/slice.py index ca1a1597514..db58c6c8e50 100644 --- a/superset/models/slice.py +++ b/superset/models/slice.py @@ -38,6 +38,7 @@ from sqlalchemy.engine.base import Connection from sqlalchemy.orm import relationship from sqlalchemy.orm.mapper import Mapper from sqlalchemy.sql.elements import BinaryExpression +from superset_core.api.models import Chart as CoreChart from superset import db, is_feature_enabled, security_manager from superset.legacy import update_time_range @@ -65,7 +66,7 @@ logger = logging.getLogger(__name__) class Slice( # pylint: disable=too-many-public-methods - Model, AuditMixinNullable, ImportExportMixin + CoreChart, AuditMixinNullable, ImportExportMixin ): """A slice is essentially a report or a view on data""" @@ -213,7 +214,7 @@ class Slice( # pylint: disable=too-many-public-methods @property def description_markeddown(self) -> str: - return utils.markdown(self.description) + return utils.markdown(self.description or "") @property def data(self) -> dict[str, Any]: diff --git a/superset/models/sql_lab.py b/superset/models/sql_lab.py index 53224f24721..f83ec05be53 100644 --- a/superset/models/sql_lab.py +++ b/superset/models/sql_lab.py @@ -46,6 +46,7 @@ from sqlalchemy import ( from sqlalchemy.engine.url import URL from sqlalchemy.orm import backref, relationship from sqlalchemy.sql.elements import ColumnElement, literal_column +from superset_core.api.models import Query as CoreQuery, SavedQuery as CoreSavedQuery from superset import security_manager from superset.exceptions import SupersetParseError, SupersetSecurityException @@ -94,10 +95,10 @@ class SqlTablesMixin: # pylint: disable=too-few-public-methods class Query( + CoreQuery, SqlTablesMixin, ExtraJSONMixin, ExploreMixin, - Model, ): # pylint: disable=abstract-method,too-many-public-methods """ORM model for SQL query @@ -387,11 +388,11 @@ class Query( class SavedQuery( + CoreSavedQuery, SqlTablesMixin, AuditMixinNullable, ExtraJSONMixin, ImportExportMixin, - Model, ): """ORM model for SQL query""" diff --git a/superset/security/manager.py b/superset/security/manager.py index 4ed4ba08fa2..fd758d235b1 100644 --- a/superset/security/manager.py +++ b/superset/security/manager.py @@ -951,7 +951,7 @@ class SupersetSecurityManager( # pylint: disable=too-many-public-methods tables = ( self.session.query(SqlaTable.schema) .filter(SqlaTable.database_id == database.id) - .filter(or_(SqlaTable.perm.in_(perms))) + .filter(or_(SqlaTable.perm.in_(perms))) # type: ignore[union-attr] .distinct() ) accessible_schemas.update( @@ -1011,7 +1011,7 @@ class SupersetSecurityManager( # pylint: disable=too-many-public-methods tables = ( self.session.query(SqlaTable.schema) .filter(SqlaTable.database_id == database.id) - .filter(or_(SqlaTable.perm.in_(perms))) + .filter(or_(SqlaTable.perm.in_(perms))) # type: ignore[union-attr] .distinct() ) accessible_catalogs.update( @@ -2369,7 +2369,7 @@ class SupersetSecurityManager( # pylint: disable=too-many-public-methods continue schema_perm = self.get_schema_perm( - database, + database.database_name, table_.catalog, table_.schema, ) @@ -2385,7 +2385,7 @@ class SupersetSecurityManager( # pylint: disable=too-many-public-methods for datasource_ in datasources: if self.can_access( "datasource_access", - datasource_.perm, + datasource_.perm or "", ) or self.is_owner(datasource_): # access to any datasource is sufficient break diff --git a/superset/tags/filters.py b/superset/tags/filters.py index 81df9fd7b93..e1e62cf6fdf 100644 --- a/superset/tags/filters.py +++ b/superset/tags/filters.py @@ -68,7 +68,7 @@ class BaseTagNameFilter(BaseFilter): # pylint: disable=too-few-public-methods .join(self.model.tags) .filter(Tag.name.ilike(ilike_value)) ) - return query.filter(self.model.id.in_(tags_query)) + return query.filter(self.model.id.in_(tags_query)) # type: ignore[union-attr] class BaseTagIdFilter(BaseFilter): # pylint: disable=too-few-public-methods @@ -90,4 +90,4 @@ class BaseTagIdFilter(BaseFilter): # pylint: disable=too-few-public-methods .join(self.model.tags) .filter(Tag.id == value) ) - return query.filter(self.model.id.in_(tags_query)) + return query.filter(self.model.id.in_(tags_query)) # type: ignore[union-attr] diff --git a/superset/tags/models.py b/superset/tags/models.py index 9223e4ad277..a6e82f59994 100644 --- a/superset/tags/models.py +++ b/superset/tags/models.py @@ -37,6 +37,7 @@ from sqlalchemy.engine.base import Connection from sqlalchemy.orm import relationship, sessionmaker from sqlalchemy.orm.mapper import Mapper from sqlalchemy.schema import UniqueConstraint +from superset_core.api.models import Tag as CoreTag from superset import security_manager from superset.models.helpers import AuditMixinNullable @@ -87,7 +88,7 @@ class ObjectType(enum.Enum): dataset = 4 -class Tag(Model, AuditMixinNullable): +class Tag(CoreTag, AuditMixinNullable): """A tag attached to an object (query, chart, dashboard, or dataset).""" __tablename__ = "tag" diff --git a/tests/unit_tests/dao/base_dao_test.py b/tests/unit_tests/dao/base_dao_test.py index 3351051bac7..eb7d20adfd0 100644 --- a/tests/unit_tests/dao/base_dao_test.py +++ b/tests/unit_tests/dao/base_dao_test.py @@ -25,12 +25,15 @@ import pytest from sqlalchemy import Boolean, Column, Integer, String from sqlalchemy.exc import SQLAlchemyError from sqlalchemy.ext.declarative import declarative_base +from superset_core.api.models import CoreModel from superset.daos.base import BaseDAO, ColumnOperatorEnum from superset.daos.exceptions import DAOFindFailedError -class MockModel: +class MockModel(CoreModel): + __abstract__ = True # Prevent SQLAlchemy from trying to create a table + def __init__(self, id=1, name="test"): self.id = id self.name = name diff --git a/tests/unit_tests/dao/key_value_test.py b/tests/unit_tests/dao/key_value_test.py index 41b8c3e590b..5a473f15160 100644 --- a/tests/unit_tests/dao/key_value_test.py +++ b/tests/unit_tests/dao/key_value_test.py @@ -181,6 +181,7 @@ def test_get_uuid_entry( ) -> None: from superset.daos.key_value import KeyValueDAO + assert key_value_entry.uuid is not None found_entry = KeyValueDAO.get_entry(resource=RESOURCE, key=key_value_entry.uuid) assert found_entry is not None assert JSON_CODEC.decode(found_entry.value) == JSON_VALUE