This is an automated email from the ASF dual-hosted git repository. beto pushed a commit to branch semantic-layer-api-semantic-layer in repository https://gitbox.apache.org/repos/asf/superset.git
commit b58a2d1f8f10940ef395fcc7ffa4a0f7b36f143c Author: Beto Dealmeida <[email protected]> AuthorDate: Wed Feb 11 16:02:37 2026 -0500 feat: API for semantic layers --- .../semantic_layer/{update.py => create.py} | 39 +- .../semantic_layer/{update.py => delete.py} | 39 +- superset/commands/semantic_layer/exceptions.py | 27 + superset/commands/semantic_layer/update.py | 41 +- superset/daos/semantic_layer.py | 12 + superset/initialization/__init__.py | 6 +- superset/semantic_layers/api.py | 362 +++++++++++++- superset/semantic_layers/schemas.py | 15 + .../commands/semantic_layer/create_test.py | 148 ++++++ .../commands/semantic_layer/delete_test.py | 50 ++ .../commands/semantic_layer/exceptions_test.py | 43 ++ .../commands/semantic_layer/update_test.py | 117 ++++- tests/unit_tests/semantic_layers/api_test.py | 553 +++++++++++++++++++++ tests/unit_tests/semantic_layers/schemas_test.py | 123 ++++- 14 files changed, 1520 insertions(+), 55 deletions(-) diff --git a/superset/commands/semantic_layer/update.py b/superset/commands/semantic_layer/create.py similarity index 59% copy from superset/commands/semantic_layer/update.py copy to superset/commands/semantic_layer/create.py index 4ce8da07646..b0778fe8084 100644 --- a/superset/commands/semantic_layer/update.py +++ b/superset/commands/semantic_layer/create.py @@ -23,45 +23,42 @@ from typing import Any from flask_appbuilder.models.sqla import Model from sqlalchemy.exc import SQLAlchemyError -from superset import security_manager from superset.commands.base import BaseCommand from superset.commands.semantic_layer.exceptions import ( - SemanticViewForbiddenError, - SemanticViewNotFoundError, - SemanticViewUpdateFailedError, + SemanticLayerCreateFailedError, + SemanticLayerInvalidError, ) -from superset.daos.semantic_layer import SemanticViewDAO -from superset.exceptions import SupersetSecurityException -from superset.semantic_layers.models import SemanticView +from superset.daos.semantic_layer import SemanticLayerDAO +from superset.semantic_layers.registry import registry from superset.utils.decorators import on_error, transaction logger = logging.getLogger(__name__) -class UpdateSemanticViewCommand(BaseCommand): - def __init__(self, model_id: int, data: dict[str, Any]): - self._model_id = model_id +class CreateSemanticLayerCommand(BaseCommand): + def __init__(self, data: dict[str, Any]): self._properties = data.copy() - self._model: SemanticView | None = None @transaction( on_error=partial( on_error, catches=(SQLAlchemyError, ValueError), - reraise=SemanticViewUpdateFailedError, + reraise=SemanticLayerCreateFailedError, ) ) def run(self) -> Model: self.validate() - assert self._model - return SemanticViewDAO.update(self._model, attributes=self._properties) + return SemanticLayerDAO.create(attributes=self._properties) def validate(self) -> None: - self._model = SemanticViewDAO.find_by_id(self._model_id) - if not self._model: - raise SemanticViewNotFoundError() + sl_type = self._properties.get("type") + if sl_type not in registry: + raise SemanticLayerInvalidError(f"Unknown type: {sl_type}") - try: - security_manager.raise_for_ownership(self._model) - except SupersetSecurityException as ex: - raise SemanticViewForbiddenError() from ex + name = self._properties.get("name") + if not SemanticLayerDAO.validate_uniqueness(name): + raise SemanticLayerInvalidError(f"Name already exists: {name}") + + # Validate configuration against the plugin + cls = registry[sl_type] + cls.from_configuration(self._properties["configuration"]) diff --git a/superset/commands/semantic_layer/update.py b/superset/commands/semantic_layer/delete.py similarity index 54% copy from superset/commands/semantic_layer/update.py copy to superset/commands/semantic_layer/delete.py index 4ce8da07646..677126221a8 100644 --- a/superset/commands/semantic_layer/update.py +++ b/superset/commands/semantic_layer/delete.py @@ -18,50 +18,39 @@ from __future__ import annotations import logging from functools import partial -from typing import Any -from flask_appbuilder.models.sqla import Model from sqlalchemy.exc import SQLAlchemyError -from superset import security_manager from superset.commands.base import BaseCommand from superset.commands.semantic_layer.exceptions import ( - SemanticViewForbiddenError, - SemanticViewNotFoundError, - SemanticViewUpdateFailedError, + SemanticLayerDeleteFailedError, + SemanticLayerNotFoundError, ) -from superset.daos.semantic_layer import SemanticViewDAO -from superset.exceptions import SupersetSecurityException -from superset.semantic_layers.models import SemanticView +from superset.daos.semantic_layer import SemanticLayerDAO +from superset.semantic_layers.models import SemanticLayer from superset.utils.decorators import on_error, transaction logger = logging.getLogger(__name__) -class UpdateSemanticViewCommand(BaseCommand): - def __init__(self, model_id: int, data: dict[str, Any]): - self._model_id = model_id - self._properties = data.copy() - self._model: SemanticView | None = None +class DeleteSemanticLayerCommand(BaseCommand): + def __init__(self, uuid: str): + self._uuid = uuid + self._model: SemanticLayer | None = None @transaction( on_error=partial( on_error, - catches=(SQLAlchemyError, ValueError), - reraise=SemanticViewUpdateFailedError, + catches=(SQLAlchemyError,), + reraise=SemanticLayerDeleteFailedError, ) ) - def run(self) -> Model: + def run(self) -> None: self.validate() assert self._model - return SemanticViewDAO.update(self._model, attributes=self._properties) + SemanticLayerDAO.delete([self._model]) def validate(self) -> None: - self._model = SemanticViewDAO.find_by_id(self._model_id) + self._model = SemanticLayerDAO.find_by_uuid(self._uuid) if not self._model: - raise SemanticViewNotFoundError() - - try: - security_manager.raise_for_ownership(self._model) - except SupersetSecurityException as ex: - raise SemanticViewForbiddenError() from ex + raise SemanticLayerNotFoundError() diff --git a/superset/commands/semantic_layer/exceptions.py b/superset/commands/semantic_layer/exceptions.py index 3e2fad94752..1b82a65b841 100644 --- a/superset/commands/semantic_layer/exceptions.py +++ b/superset/commands/semantic_layer/exceptions.py @@ -19,6 +19,8 @@ from flask_babel import lazy_gettext as _ from superset.commands.exceptions import ( CommandException, CommandInvalidError, + CreateFailedError, + DeleteFailedError, ForbiddenError, UpdateFailedError, ) @@ -39,3 +41,28 @@ class SemanticViewInvalidError(CommandInvalidError): class SemanticViewUpdateFailedError(UpdateFailedError): message = _("Semantic view could not be updated.") + + +class SemanticLayerNotFoundError(CommandException): + status = 404 + message = _("Semantic layer does not exist") + + +class SemanticLayerForbiddenError(ForbiddenError): + message = _("Changing this semantic layer is forbidden") + + +class SemanticLayerInvalidError(CommandInvalidError): + message = _("Semantic layer parameters are invalid.") + + +class SemanticLayerCreateFailedError(CreateFailedError): + message = _("Semantic layer could not be created.") + + +class SemanticLayerUpdateFailedError(UpdateFailedError): + message = _("Semantic layer could not be updated.") + + +class SemanticLayerDeleteFailedError(DeleteFailedError): + message = _("Semantic layer could not be deleted.") diff --git a/superset/commands/semantic_layer/update.py b/superset/commands/semantic_layer/update.py index 4ce8da07646..5242406af8c 100644 --- a/superset/commands/semantic_layer/update.py +++ b/superset/commands/semantic_layer/update.py @@ -26,13 +26,17 @@ from sqlalchemy.exc import SQLAlchemyError from superset import security_manager from superset.commands.base import BaseCommand from superset.commands.semantic_layer.exceptions import ( + SemanticLayerInvalidError, + SemanticLayerNotFoundError, + SemanticLayerUpdateFailedError, SemanticViewForbiddenError, SemanticViewNotFoundError, SemanticViewUpdateFailedError, ) -from superset.daos.semantic_layer import SemanticViewDAO +from superset.daos.semantic_layer import SemanticLayerDAO, SemanticViewDAO from superset.exceptions import SupersetSecurityException -from superset.semantic_layers.models import SemanticView +from superset.semantic_layers.models import SemanticLayer, SemanticView +from superset.semantic_layers.registry import registry from superset.utils.decorators import on_error, transaction logger = logging.getLogger(__name__) @@ -65,3 +69,36 @@ class UpdateSemanticViewCommand(BaseCommand): security_manager.raise_for_ownership(self._model) except SupersetSecurityException as ex: raise SemanticViewForbiddenError() from ex + + +class UpdateSemanticLayerCommand(BaseCommand): + def __init__(self, uuid: str, data: dict[str, Any]): + self._uuid = uuid + self._properties = data.copy() + self._model: SemanticLayer | None = None + + @transaction( + on_error=partial( + on_error, + catches=(SQLAlchemyError, ValueError), + reraise=SemanticLayerUpdateFailedError, + ) + ) + def run(self) -> Model: + self.validate() + assert self._model + return SemanticLayerDAO.update(self._model, attributes=self._properties) + + def validate(self) -> None: + self._model = SemanticLayerDAO.find_by_uuid(self._uuid) + if not self._model: + raise SemanticLayerNotFoundError() + + name = self._properties.get("name") + if name and not SemanticLayerDAO.validate_update_uniqueness(self._uuid, name): + raise SemanticLayerInvalidError(f"Name already exists: {name}") + + if configuration := self._properties.get("configuration"): + sl_type = self._model.type + cls = registry[sl_type] + cls.from_configuration(configuration) diff --git a/superset/daos/semantic_layer.py b/superset/daos/semantic_layer.py index 9c591e4a7a4..13ce777567a 100644 --- a/superset/daos/semantic_layer.py +++ b/superset/daos/semantic_layer.py @@ -29,6 +29,18 @@ class SemanticLayerDAO(BaseDAO[SemanticLayer]): Data Access Object for SemanticLayer model. """ + @staticmethod + def find_by_uuid(uuid_str: str) -> SemanticLayer | None: + return ( + db.session.query(SemanticLayer) + .filter(SemanticLayer.uuid == uuid_str) + .one_or_none() + ) + + @staticmethod + def find_all() -> list[SemanticLayer]: + return db.session.query(SemanticLayer).all() + @staticmethod def validate_uniqueness(name: str) -> bool: """ diff --git a/superset/initialization/__init__.py b/superset/initialization/__init__.py index 2f90db3d308..44b5c4f0777 100644 --- a/superset/initialization/__init__.py +++ b/superset/initialization/__init__.py @@ -178,12 +178,15 @@ class SupersetAppInitializer: # pylint: disable=too-many-public-methods from superset.reports.api import ReportScheduleRestApi from superset.reports.logs.api import ReportExecutionLogRestApi from superset.row_level_security.api import RLSRestApi - from superset.semantic_layers.api import SemanticViewRestApi from superset.security.api import ( RoleRestAPI, SecurityRestApi, UserRegistrationsRestAPI, ) + from superset.semantic_layers.api import ( + SemanticLayerRestApi, + SemanticViewRestApi, + ) from superset.sqllab.api import SqlLabRestApi from superset.sqllab.permalink.api import SqlLabPermalinkRestApi from superset.tags.api import TagRestApi @@ -266,6 +269,7 @@ class SupersetAppInitializer: # pylint: disable=too-many-public-methods appbuilder.add_api(ReportExecutionLogRestApi) appbuilder.add_api(RLSRestApi) appbuilder.add_api(SavedQueryRestApi) + appbuilder.add_api(SemanticLayerRestApi) appbuilder.add_api(SemanticViewRestApi) appbuilder.add_api(TagRestApi) appbuilder.add_api(SqlLabRestApi) diff --git a/superset/semantic_layers/api.py b/superset/semantic_layers/api.py index 6cf5ea0e5e9..f07054d86f6 100644 --- a/superset/semantic_layers/api.py +++ b/superset/semantic_layers/api.py @@ -17,24 +17,43 @@ from __future__ import annotations import logging +from typing import Any from flask import request, Response -from flask_appbuilder.api import expose, protect +from flask_appbuilder.api import expose, protect, safe from flask_appbuilder.models.sqla.interface import SQLAInterface from marshmallow import ValidationError from superset import event_logger +from superset.commands.semantic_layer.create import CreateSemanticLayerCommand +from superset.commands.semantic_layer.delete import DeleteSemanticLayerCommand from superset.commands.semantic_layer.exceptions import ( + SemanticLayerCreateFailedError, + SemanticLayerDeleteFailedError, + SemanticLayerInvalidError, + SemanticLayerNotFoundError, + SemanticLayerUpdateFailedError, SemanticViewForbiddenError, SemanticViewInvalidError, SemanticViewNotFoundError, SemanticViewUpdateFailedError, ) -from superset.commands.semantic_layer.update import UpdateSemanticViewCommand +from superset.commands.semantic_layer.update import ( + UpdateSemanticLayerCommand, + UpdateSemanticViewCommand, +) from superset.constants import MODEL_API_RW_METHOD_PERMISSION_MAP -from superset.semantic_layers.models import SemanticView -from superset.semantic_layers.schemas import SemanticViewPutSchema +from superset.daos.semantic_layer import SemanticLayerDAO +from superset.semantic_layers.models import SemanticLayer, SemanticView +from superset.semantic_layers.registry import registry +from superset.semantic_layers.schemas import ( + SemanticLayerPostSchema, + SemanticLayerPutSchema, + SemanticViewPutSchema, +) +from superset.superset_typing import FlaskResponse from superset.views.base_api import ( + BaseSupersetApi, BaseSupersetModelRestApi, requires_json, statsd_metrics, @@ -43,6 +62,16 @@ from superset.views.base_api import ( logger = logging.getLogger(__name__) +def _serialize_layer(layer: SemanticLayer) -> dict[str, Any]: + return { + "uuid": str(layer.uuid), + "name": layer.name, + "description": layer.description, + "type": layer.type, + "cache_timeout": layer.cache_timeout, + } + + class SemanticViewRestApi(BaseSupersetModelRestApi): datamodel = SQLAInterface(SemanticView) @@ -126,3 +155,328 @@ class SemanticViewRestApi(BaseSupersetModelRestApi): ) response = self.response_422(message=str(ex)) return response + + +class SemanticLayerRestApi(BaseSupersetApi): + resource_name = "semantic_layer" + allow_browser_login = True + class_permission_name = "SemanticLayer" + method_permission_name = MODEL_API_RW_METHOD_PERMISSION_MAP + openapi_spec_tag = "Semantic Layers" + + @expose("/types", methods=("GET",)) + @protect() + @safe + @statsd_metrics + def types(self) -> FlaskResponse: + """List available semantic layer types. + --- + get: + summary: List available semantic layer types + responses: + 200: + description: A list of semantic layer types + 401: + $ref: '#/components/responses/401' + """ + result = [ + {"id": key, "name": cls.name, "description": cls.description} + for key, cls in registry.items() + ] + return self.response(200, result=result) + + @expose("/schema/configuration", methods=("POST",)) + @protect() + @safe + @statsd_metrics + @requires_json + def configuration_schema(self) -> FlaskResponse: + """Get configuration schema for a semantic layer type. + --- + post: + summary: Get configuration schema for a semantic layer type + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + type: + type: string + configuration: + type: object + responses: + 200: + description: Configuration JSON Schema + 400: + $ref: '#/components/responses/400' + 401: + $ref: '#/components/responses/401' + """ + body = request.json or {} + sl_type = body.get("type") + + cls = registry.get(sl_type) + if not cls: + return self.response_400(message=f"Unknown type: {sl_type}") + + parsed_config = None + if config := body.get("configuration"): + try: + parsed_config = cls.from_configuration(config).configuration + except Exception: # pylint: disable=broad-except + parsed_config = None + + schema = cls.get_configuration_schema(parsed_config) + return self.response(200, result=schema) + + @expose("/<uuid>/schema/runtime", methods=("POST",)) + @protect() + @safe + @statsd_metrics + def runtime_schema(self, uuid: str) -> FlaskResponse: + """Get runtime schema for a stored semantic layer. + --- + post: + summary: Get runtime schema for a semantic layer + parameters: + - in: path + schema: + type: string + name: uuid + requestBody: + content: + application/json: + schema: + type: object + properties: + runtime_data: + type: object + responses: + 200: + description: Runtime JSON Schema + 401: + $ref: '#/components/responses/401' + 404: + $ref: '#/components/responses/404' + """ + layer = SemanticLayerDAO.find_by_uuid(uuid) + if not layer: + return self.response_404() + + body = request.get_json(silent=True) or {} + runtime_data = body.get("runtime_data") + + cls = registry.get(layer.type) + if not cls: + return self.response_400(message=f"Unknown type: {layer.type}") + + try: + schema = cls.get_runtime_schema( + layer.implementation.configuration, runtime_data + ) + except Exception as ex: # pylint: disable=broad-except + return self.response_400(message=str(ex)) + + return self.response(200, result=schema) + + @expose("/", methods=("POST",)) + @protect() + @safe + @statsd_metrics + @requires_json + def post(self) -> FlaskResponse: + """Create a semantic layer. + --- + post: + summary: Create a semantic layer + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + name: + type: string + description: + type: string + type: + type: string + configuration: + type: object + cache_timeout: + type: integer + responses: + 201: + description: Semantic layer created + 400: + $ref: '#/components/responses/400' + 401: + $ref: '#/components/responses/401' + 422: + $ref: '#/components/responses/422' + """ + try: + item = SemanticLayerPostSchema().load(request.json) + except ValidationError as error: + return self.response_400(message=error.messages) + + try: + new_model = CreateSemanticLayerCommand(item).run() + return self.response(201, result={"uuid": str(new_model.uuid)}) + except SemanticLayerInvalidError as ex: + return self.response_422(message=str(ex)) + except SemanticLayerCreateFailedError as ex: + logger.error( + "Error creating semantic layer: %s", + str(ex), + exc_info=True, + ) + return self.response_422(message=str(ex)) + + @expose("/<uuid>", methods=("PUT",)) + @protect() + @safe + @statsd_metrics + @requires_json + def put(self, uuid: str) -> FlaskResponse: + """Update a semantic layer. + --- + put: + summary: Update a semantic layer + parameters: + - in: path + schema: + type: string + name: uuid + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + name: + type: string + description: + type: string + configuration: + type: object + cache_timeout: + type: integer + responses: + 200: + description: Semantic layer updated + 400: + $ref: '#/components/responses/400' + 401: + $ref: '#/components/responses/401' + 404: + $ref: '#/components/responses/404' + 422: + $ref: '#/components/responses/422' + """ + try: + item = SemanticLayerPutSchema().load(request.json) + except ValidationError as error: + return self.response_400(message=error.messages) + + try: + changed_model = UpdateSemanticLayerCommand(uuid, item).run() + return self.response(200, result={"uuid": str(changed_model.uuid)}) + except SemanticLayerNotFoundError: + return self.response_404() + except SemanticLayerInvalidError as ex: + return self.response_422(message=str(ex)) + except SemanticLayerUpdateFailedError as ex: + logger.error( + "Error updating semantic layer: %s", + str(ex), + exc_info=True, + ) + return self.response_422(message=str(ex)) + + @expose("/<uuid>", methods=("DELETE",)) + @protect() + @safe + @statsd_metrics + def delete(self, uuid: str) -> FlaskResponse: + """Delete a semantic layer. + --- + delete: + summary: Delete a semantic layer + parameters: + - in: path + schema: + type: string + name: uuid + responses: + 200: + description: Semantic layer deleted + 401: + $ref: '#/components/responses/401' + 404: + $ref: '#/components/responses/404' + 422: + $ref: '#/components/responses/422' + """ + try: + DeleteSemanticLayerCommand(uuid).run() + return self.response(200, message="OK") + except SemanticLayerNotFoundError: + return self.response_404() + except SemanticLayerDeleteFailedError as ex: + logger.error( + "Error deleting semantic layer: %s", + str(ex), + exc_info=True, + ) + return self.response_422(message=str(ex)) + + @expose("/", methods=("GET",)) + @protect() + @safe + @statsd_metrics + def get_list(self) -> FlaskResponse: + """List all semantic layers. + --- + get: + summary: List all semantic layers + responses: + 200: + description: A list of semantic layers + 401: + $ref: '#/components/responses/401' + """ + layers = SemanticLayerDAO.find_all() + result = [_serialize_layer(layer) for layer in layers] + return self.response(200, result=result) + + @expose("/<uuid>", methods=("GET",)) + @protect() + @safe + @statsd_metrics + def get(self, uuid: str) -> FlaskResponse: + """Get a single semantic layer. + --- + get: + summary: Get a semantic layer by UUID + parameters: + - in: path + schema: + type: string + name: uuid + responses: + 200: + description: A semantic layer + 401: + $ref: '#/components/responses/401' + 404: + $ref: '#/components/responses/404' + """ + layer = SemanticLayerDAO.find_by_uuid(uuid) + if not layer: + return self.response_404() + return self.response(200, result=_serialize_layer(layer)) diff --git a/superset/semantic_layers/schemas.py b/superset/semantic_layers/schemas.py index b86a5398937..d10e0fd28fb 100644 --- a/superset/semantic_layers/schemas.py +++ b/superset/semantic_layers/schemas.py @@ -20,3 +20,18 @@ from marshmallow import fields, Schema class SemanticViewPutSchema(Schema): description = fields.String(allow_none=True) cache_timeout = fields.Integer(allow_none=True) + + +class SemanticLayerPostSchema(Schema): + name = fields.String(required=True) + description = fields.String(allow_none=True) + type = fields.String(required=True) + configuration = fields.Dict(required=True) + cache_timeout = fields.Integer(allow_none=True) + + +class SemanticLayerPutSchema(Schema): + name = fields.String() + description = fields.String(allow_none=True) + configuration = fields.Dict() + cache_timeout = fields.Integer(allow_none=True) diff --git a/tests/unit_tests/commands/semantic_layer/create_test.py b/tests/unit_tests/commands/semantic_layer/create_test.py new file mode 100644 index 00000000000..3beceebc3b8 --- /dev/null +++ b/tests/unit_tests/commands/semantic_layer/create_test.py @@ -0,0 +1,148 @@ +# 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 unittest.mock import MagicMock + +import pytest +from pytest_mock import MockerFixture + +from superset.commands.semantic_layer.create import CreateSemanticLayerCommand +from superset.commands.semantic_layer.exceptions import ( + SemanticLayerCreateFailedError, + SemanticLayerInvalidError, +) + + +def test_create_semantic_layer_success(mocker: MockerFixture) -> None: + """Test successful creation of a semantic layer.""" + new_model = MagicMock() + + dao = mocker.patch( + "superset.commands.semantic_layer.create.SemanticLayerDAO", + ) + dao.validate_uniqueness.return_value = True + dao.create.return_value = new_model + + mock_cls = MagicMock() + mocker.patch.dict( + "superset.commands.semantic_layer.create.registry", + {"snowflake": mock_cls}, + ) + + data = { + "name": "My Layer", + "type": "snowflake", + "configuration": {"account": "test"}, + } + result = CreateSemanticLayerCommand(data).run() + + assert result == new_model + dao.create.assert_called_once_with(attributes=data) + mock_cls.from_configuration.assert_called_once_with({"account": "test"}) + + +def test_create_semantic_layer_unknown_type(mocker: MockerFixture) -> None: + """Test that SemanticLayerInvalidError is raised for unknown type.""" + mocker.patch( + "superset.commands.semantic_layer.create.SemanticLayerDAO", + ) + mocker.patch.dict( + "superset.commands.semantic_layer.create.registry", + {}, + clear=True, + ) + + data = { + "name": "My Layer", + "type": "nonexistent", + "configuration": {}, + } + with pytest.raises(SemanticLayerInvalidError): + CreateSemanticLayerCommand(data).run() + + +def test_create_semantic_layer_duplicate_name(mocker: MockerFixture) -> None: + """Test that SemanticLayerInvalidError is raised for duplicate names.""" + dao = mocker.patch( + "superset.commands.semantic_layer.create.SemanticLayerDAO", + ) + dao.validate_uniqueness.return_value = False + + mocker.patch.dict( + "superset.commands.semantic_layer.create.registry", + {"snowflake": MagicMock()}, + ) + + data = { + "name": "Duplicate", + "type": "snowflake", + "configuration": {}, + } + with pytest.raises(SemanticLayerInvalidError): + CreateSemanticLayerCommand(data).run() + + +def test_create_semantic_layer_invalid_configuration( + mocker: MockerFixture, +) -> None: + """Test that invalid configuration is caught by the @transaction decorator.""" + dao = mocker.patch( + "superset.commands.semantic_layer.create.SemanticLayerDAO", + ) + dao.validate_uniqueness.return_value = True + + mock_cls = MagicMock() + mock_cls.from_configuration.side_effect = ValueError("bad config") + mocker.patch.dict( + "superset.commands.semantic_layer.create.registry", + {"snowflake": mock_cls}, + ) + + data = { + "name": "My Layer", + "type": "snowflake", + "configuration": {"bad": "data"}, + } + with pytest.raises(SemanticLayerCreateFailedError): + CreateSemanticLayerCommand(data).run() + + +def test_create_semantic_layer_copies_data(mocker: MockerFixture) -> None: + """Test that the command copies input data and does not mutate it.""" + dao = mocker.patch( + "superset.commands.semantic_layer.create.SemanticLayerDAO", + ) + dao.validate_uniqueness.return_value = True + dao.create.return_value = MagicMock() + + mocker.patch.dict( + "superset.commands.semantic_layer.create.registry", + {"snowflake": MagicMock()}, + ) + + original_data = { + "name": "Original", + "type": "snowflake", + "configuration": {"account": "test"}, + } + CreateSemanticLayerCommand(original_data).run() + + assert original_data == { + "name": "Original", + "type": "snowflake", + "configuration": {"account": "test"}, + } diff --git a/tests/unit_tests/commands/semantic_layer/delete_test.py b/tests/unit_tests/commands/semantic_layer/delete_test.py new file mode 100644 index 00000000000..456af8e390a --- /dev/null +++ b/tests/unit_tests/commands/semantic_layer/delete_test.py @@ -0,0 +1,50 @@ +# 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 unittest.mock import MagicMock + +import pytest +from pytest_mock import MockerFixture + +from superset.commands.semantic_layer.delete import DeleteSemanticLayerCommand +from superset.commands.semantic_layer.exceptions import SemanticLayerNotFoundError + + +def test_delete_semantic_layer_success(mocker: MockerFixture) -> None: + """Test successful deletion of a semantic layer.""" + mock_model = MagicMock() + + dao = mocker.patch( + "superset.commands.semantic_layer.delete.SemanticLayerDAO", + ) + dao.find_by_uuid.return_value = mock_model + + DeleteSemanticLayerCommand("some-uuid").run() + + dao.find_by_uuid.assert_called_once_with("some-uuid") + dao.delete.assert_called_once_with([mock_model]) + + +def test_delete_semantic_layer_not_found(mocker: MockerFixture) -> None: + """Test that SemanticLayerNotFoundError is raised when model is missing.""" + dao = mocker.patch( + "superset.commands.semantic_layer.delete.SemanticLayerDAO", + ) + dao.find_by_uuid.return_value = None + + with pytest.raises(SemanticLayerNotFoundError): + DeleteSemanticLayerCommand("missing-uuid").run() diff --git a/tests/unit_tests/commands/semantic_layer/exceptions_test.py b/tests/unit_tests/commands/semantic_layer/exceptions_test.py index fb6728a36ab..91aa4329228 100644 --- a/tests/unit_tests/commands/semantic_layer/exceptions_test.py +++ b/tests/unit_tests/commands/semantic_layer/exceptions_test.py @@ -16,6 +16,12 @@ # under the License. from superset.commands.semantic_layer.exceptions import ( + SemanticLayerCreateFailedError, + SemanticLayerDeleteFailedError, + SemanticLayerForbiddenError, + SemanticLayerInvalidError, + SemanticLayerNotFoundError, + SemanticLayerUpdateFailedError, SemanticViewForbiddenError, SemanticViewInvalidError, SemanticViewNotFoundError, @@ -46,3 +52,40 @@ def test_semantic_view_update_failed_error() -> None: """Test SemanticViewUpdateFailedError has correct message.""" error = SemanticViewUpdateFailedError() assert str(error.message) == "Semantic view could not be updated." + + +def test_semantic_layer_not_found_error() -> None: + """Test SemanticLayerNotFoundError has correct status and message.""" + error = SemanticLayerNotFoundError() + assert error.status == 404 + assert str(error.message) == "Semantic layer does not exist" + + +def test_semantic_layer_forbidden_error() -> None: + """Test SemanticLayerForbiddenError has correct message.""" + error = SemanticLayerForbiddenError() + assert str(error.message) == "Changing this semantic layer is forbidden" + + +def test_semantic_layer_invalid_error() -> None: + """Test SemanticLayerInvalidError has correct message.""" + error = SemanticLayerInvalidError() + assert str(error.message) == "Semantic layer parameters are invalid." + + +def test_semantic_layer_create_failed_error() -> None: + """Test SemanticLayerCreateFailedError has correct message.""" + error = SemanticLayerCreateFailedError() + assert str(error.message) == "Semantic layer could not be created." + + +def test_semantic_layer_update_failed_error() -> None: + """Test SemanticLayerUpdateFailedError has correct message.""" + error = SemanticLayerUpdateFailedError() + assert str(error.message) == "Semantic layer could not be updated." + + +def test_semantic_layer_delete_failed_error() -> None: + """Test SemanticLayerDeleteFailedError has correct message.""" + error = SemanticLayerDeleteFailedError() + assert str(error.message) == "Semantic layer could not be deleted." diff --git a/tests/unit_tests/commands/semantic_layer/update_test.py b/tests/unit_tests/commands/semantic_layer/update_test.py index 4c0ad47f8b9..e014ea3dc5a 100644 --- a/tests/unit_tests/commands/semantic_layer/update_test.py +++ b/tests/unit_tests/commands/semantic_layer/update_test.py @@ -21,10 +21,15 @@ import pytest from pytest_mock import MockerFixture from superset.commands.semantic_layer.exceptions import ( + SemanticLayerInvalidError, + SemanticLayerNotFoundError, SemanticViewForbiddenError, SemanticViewNotFoundError, ) -from superset.commands.semantic_layer.update import UpdateSemanticViewCommand +from superset.commands.semantic_layer.update import ( + UpdateSemanticLayerCommand, + UpdateSemanticViewCommand, +) from superset.exceptions import SupersetSecurityException @@ -102,3 +107,113 @@ def test_update_semantic_view_copies_data(mocker: MockerFixture) -> None: # The original dict should not have been modified assert original_data == {"description": "Original"} + + +# ============================================================================= +# UpdateSemanticLayerCommand tests +# ============================================================================= + + +def test_update_semantic_layer_success(mocker: MockerFixture) -> None: + """Test successful update of a semantic layer.""" + mock_model = MagicMock() + mock_model.type = "snowflake" + + dao = mocker.patch( + "superset.commands.semantic_layer.update.SemanticLayerDAO", + ) + dao.find_by_uuid.return_value = mock_model + dao.update.return_value = mock_model + + data = {"name": "Updated", "description": "New desc"} + result = UpdateSemanticLayerCommand("some-uuid", data).run() + + assert result == mock_model + dao.find_by_uuid.assert_called_once_with("some-uuid") + dao.update.assert_called_once_with(mock_model, attributes=data) + + +def test_update_semantic_layer_not_found(mocker: MockerFixture) -> None: + """Test that SemanticLayerNotFoundError is raised when model is missing.""" + dao = mocker.patch( + "superset.commands.semantic_layer.update.SemanticLayerDAO", + ) + dao.find_by_uuid.return_value = None + + with pytest.raises(SemanticLayerNotFoundError): + UpdateSemanticLayerCommand("missing-uuid", {"name": "test"}).run() + + +def test_update_semantic_layer_duplicate_name(mocker: MockerFixture) -> None: + """Test that SemanticLayerInvalidError is raised for duplicate names.""" + mock_model = MagicMock() + mock_model.type = "snowflake" + + dao = mocker.patch( + "superset.commands.semantic_layer.update.SemanticLayerDAO", + ) + dao.find_by_uuid.return_value = mock_model + dao.validate_update_uniqueness.return_value = False + + with pytest.raises(SemanticLayerInvalidError): + UpdateSemanticLayerCommand("some-uuid", {"name": "Duplicate"}).run() + + +def test_update_semantic_layer_validates_configuration( + mocker: MockerFixture, +) -> None: + """Test that configuration is validated against the plugin.""" + mock_model = MagicMock() + mock_model.type = "snowflake" + + dao = mocker.patch( + "superset.commands.semantic_layer.update.SemanticLayerDAO", + ) + dao.find_by_uuid.return_value = mock_model + dao.update.return_value = mock_model + + mock_cls = MagicMock() + mocker.patch.dict( + "superset.commands.semantic_layer.update.registry", + {"snowflake": mock_cls}, + ) + + config = {"account": "test"} + UpdateSemanticLayerCommand("some-uuid", {"configuration": config}).run() + + mock_cls.from_configuration.assert_called_once_with(config) + + +def test_update_semantic_layer_skips_name_check_when_no_name( + mocker: MockerFixture, +) -> None: + """Test that name uniqueness is not checked when name is not provided.""" + mock_model = MagicMock() + mock_model.type = "snowflake" + + dao = mocker.patch( + "superset.commands.semantic_layer.update.SemanticLayerDAO", + ) + dao.find_by_uuid.return_value = mock_model + dao.update.return_value = mock_model + + UpdateSemanticLayerCommand("some-uuid", {"description": "Updated"}).run() + + dao.validate_update_uniqueness.assert_not_called() + + +def test_update_semantic_layer_copies_data(mocker: MockerFixture) -> None: + """Test that the command copies input data and does not mutate it.""" + mock_model = MagicMock() + mock_model.type = "snowflake" + + dao = mocker.patch( + "superset.commands.semantic_layer.update.SemanticLayerDAO", + ) + dao.find_by_uuid.return_value = mock_model + dao.update.return_value = mock_model + + original_data = {"description": "Original"} + UpdateSemanticLayerCommand("some-uuid", original_data).run() + + assert original_data == {"description": "Original"} diff --git a/tests/unit_tests/semantic_layers/api_test.py b/tests/unit_tests/semantic_layers/api_test.py index 67faf78d8de..8f1fb350a70 100644 --- a/tests/unit_tests/semantic_layers/api_test.py +++ b/tests/unit_tests/semantic_layers/api_test.py @@ -15,12 +15,18 @@ # specific language governing permissions and limitations # under the License. +import uuid as uuid_lib from typing import Any from unittest.mock import MagicMock from pytest_mock import MockerFixture from superset.commands.semantic_layer.exceptions import ( + SemanticLayerCreateFailedError, + SemanticLayerDeleteFailedError, + SemanticLayerInvalidError, + SemanticLayerNotFoundError, + SemanticLayerUpdateFailedError, SemanticViewForbiddenError, SemanticViewInvalidError, SemanticViewNotFoundError, @@ -242,3 +248,550 @@ def test_put_semantic_view_empty_payload( ) assert response.status_code == 200 + + +# ============================================================================= +# SemanticLayerRestApi tests +# ============================================================================= + + +def test_get_types( + client: Any, + full_api_access: None, + mocker: MockerFixture, +) -> None: + """Test GET /types returns registered semantic layer types.""" + mock_cls = MagicMock() + mock_cls.name = "Snowflake Semantic Layer" + mock_cls.description = "Connect to Snowflake." + + mocker.patch.dict( + "superset.semantic_layers.api.registry", + {"snowflake": mock_cls}, + clear=True, + ) + + response = client.get("/api/v1/semantic_layer/types") + + assert response.status_code == 200 + result = response.json["result"] + assert len(result) == 1 + assert result[0] == { + "id": "snowflake", + "name": "Snowflake Semantic Layer", + "description": "Connect to Snowflake.", + } + + +def test_get_types_empty( + client: Any, + full_api_access: None, + mocker: MockerFixture, +) -> None: + """Test GET /types returns empty list when no types registered.""" + mocker.patch.dict( + "superset.semantic_layers.api.registry", + {}, + clear=True, + ) + + response = client.get("/api/v1/semantic_layer/types") + + assert response.status_code == 200 + assert response.json["result"] == [] + + +def test_configuration_schema( + client: Any, + full_api_access: None, + mocker: MockerFixture, +) -> None: + """Test POST /schema/configuration returns schema without partial config.""" + mock_cls = MagicMock() + mock_cls.get_configuration_schema.return_value = {"type": "object"} + + mocker.patch.dict( + "superset.semantic_layers.api.registry", + {"snowflake": mock_cls}, + clear=True, + ) + + response = client.post( + "/api/v1/semantic_layer/schema/configuration", + json={"type": "snowflake"}, + ) + + assert response.status_code == 200 + assert response.json["result"] == {"type": "object"} + mock_cls.get_configuration_schema.assert_called_once_with(None) + + +def test_configuration_schema_with_partial_config( + client: Any, + full_api_access: None, + mocker: MockerFixture, +) -> None: + """Test POST /schema/configuration enriches schema with partial config.""" + mock_instance = MagicMock() + mock_instance.configuration = {"account": "test"} + + mock_cls = MagicMock() + mock_cls.from_configuration.return_value = mock_instance + mock_cls.get_configuration_schema.return_value = { + "type": "object", + "properties": {"database": {"enum": ["db1", "db2"]}}, + } + + mocker.patch.dict( + "superset.semantic_layers.api.registry", + {"snowflake": mock_cls}, + clear=True, + ) + + response = client.post( + "/api/v1/semantic_layer/schema/configuration", + json={"type": "snowflake", "configuration": {"account": "test"}}, + ) + + assert response.status_code == 200 + mock_cls.get_configuration_schema.assert_called_once_with({"account": "test"}) + + +def test_configuration_schema_with_invalid_partial_config( + client: Any, + full_api_access: None, + mocker: MockerFixture, +) -> None: + """Test POST /schema/configuration still returns schema when partial config fails.""" + mock_cls = MagicMock() + mock_cls.from_configuration.side_effect = ValueError("bad config") + mock_cls.get_configuration_schema.return_value = {"type": "object"} + + mocker.patch.dict( + "superset.semantic_layers.api.registry", + {"snowflake": mock_cls}, + clear=True, + ) + + response = client.post( + "/api/v1/semantic_layer/schema/configuration", + json={"type": "snowflake", "configuration": {"bad": "data"}}, + ) + + assert response.status_code == 200 + mock_cls.get_configuration_schema.assert_called_once_with(None) + + +def test_configuration_schema_unknown_type( + client: Any, + full_api_access: None, + mocker: MockerFixture, +) -> None: + """Test POST /schema/configuration returns 400 for unknown type.""" + mocker.patch.dict( + "superset.semantic_layers.api.registry", + {}, + clear=True, + ) + + response = client.post( + "/api/v1/semantic_layer/schema/configuration", + json={"type": "nonexistent"}, + ) + + assert response.status_code == 400 + + +def test_runtime_schema( + client: Any, + full_api_access: None, + mocker: MockerFixture, +) -> None: + """Test POST /<uuid>/schema/runtime returns runtime schema.""" + test_uuid = str(uuid_lib.uuid4()) + mock_layer = MagicMock() + mock_layer.type = "snowflake" + mock_layer.implementation.configuration = {"account": "test"} + + mock_dao = mocker.patch("superset.semantic_layers.api.SemanticLayerDAO") + mock_dao.find_by_uuid.return_value = mock_layer + + mock_cls = MagicMock() + mock_cls.get_runtime_schema.return_value = {"type": "object"} + + mocker.patch.dict( + "superset.semantic_layers.api.registry", + {"snowflake": mock_cls}, + clear=True, + ) + + response = client.post( + f"/api/v1/semantic_layer/{test_uuid}/schema/runtime", + json={"runtime_data": {"database": "mydb"}}, + ) + + assert response.status_code == 200 + assert response.json["result"] == {"type": "object"} + mock_cls.get_runtime_schema.assert_called_once_with( + {"account": "test"}, {"database": "mydb"} + ) + + +def test_runtime_schema_no_body( + client: Any, + full_api_access: None, + mocker: MockerFixture, +) -> None: + """Test POST /<uuid>/schema/runtime works without a request body.""" + test_uuid = str(uuid_lib.uuid4()) + mock_layer = MagicMock() + mock_layer.type = "snowflake" + mock_layer.implementation.configuration = {"account": "test"} + + mock_dao = mocker.patch("superset.semantic_layers.api.SemanticLayerDAO") + mock_dao.find_by_uuid.return_value = mock_layer + + mock_cls = MagicMock() + mock_cls.get_runtime_schema.return_value = {"type": "object"} + + mocker.patch.dict( + "superset.semantic_layers.api.registry", + {"snowflake": mock_cls}, + clear=True, + ) + + response = client.post( + f"/api/v1/semantic_layer/{test_uuid}/schema/runtime", + ) + + assert response.status_code == 200 + mock_cls.get_runtime_schema.assert_called_once_with({"account": "test"}, None) + + +def test_runtime_schema_not_found( + client: Any, + full_api_access: None, + mocker: MockerFixture, +) -> None: + """Test POST /<uuid>/schema/runtime returns 404 when layer not found.""" + mock_dao = mocker.patch("superset.semantic_layers.api.SemanticLayerDAO") + mock_dao.find_by_uuid.return_value = None + + response = client.post( + f"/api/v1/semantic_layer/{uuid_lib.uuid4()}/schema/runtime", + ) + + assert response.status_code == 404 + + +def test_post_semantic_layer( + client: Any, + full_api_access: None, + mocker: MockerFixture, +) -> None: + """Test POST / creates a semantic layer.""" + test_uuid = uuid_lib.uuid4() + new_model = MagicMock() + new_model.uuid = test_uuid + + mock_command = mocker.patch( + "superset.semantic_layers.api.CreateSemanticLayerCommand", + ) + mock_command.return_value.run.return_value = new_model + + payload = { + "name": "My Layer", + "type": "snowflake", + "configuration": {"account": "test"}, + } + response = client.post("/api/v1/semantic_layer/", json=payload) + + assert response.status_code == 201 + assert response.json["result"]["uuid"] == str(test_uuid) + mock_command.assert_called_once_with(payload) + + +def test_post_semantic_layer_invalid( + client: Any, + full_api_access: None, + mocker: MockerFixture, +) -> None: + """Test POST / returns 422 when validation fails.""" + mock_command = mocker.patch( + "superset.semantic_layers.api.CreateSemanticLayerCommand", + ) + mock_command.return_value.run.side_effect = SemanticLayerInvalidError( + "Unknown type: bad" + ) + + payload = { + "name": "My Layer", + "type": "bad", + "configuration": {}, + } + response = client.post("/api/v1/semantic_layer/", json=payload) + + assert response.status_code == 422 + + +def test_post_semantic_layer_create_failed( + client: Any, + full_api_access: None, + mocker: MockerFixture, +) -> None: + """Test POST / returns 422 when creation fails.""" + mock_command = mocker.patch( + "superset.semantic_layers.api.CreateSemanticLayerCommand", + ) + mock_command.return_value.run.side_effect = SemanticLayerCreateFailedError() + + payload = { + "name": "My Layer", + "type": "snowflake", + "configuration": {"account": "test"}, + } + response = client.post("/api/v1/semantic_layer/", json=payload) + + assert response.status_code == 422 + + +def test_post_semantic_layer_missing_required_fields( + client: Any, + full_api_access: None, + mocker: MockerFixture, +) -> None: + """Test POST / returns 400 when required fields are missing.""" + mocker.patch( + "superset.semantic_layers.api.CreateSemanticLayerCommand", + ) + + response = client.post( + "/api/v1/semantic_layer/", + json={"name": "Only name"}, + ) + + assert response.status_code == 400 + + +def test_put_semantic_layer( + client: Any, + full_api_access: None, + mocker: MockerFixture, +) -> None: + """Test PUT /<uuid> updates a semantic layer.""" + test_uuid = uuid_lib.uuid4() + changed_model = MagicMock() + changed_model.uuid = test_uuid + + mock_command = mocker.patch( + "superset.semantic_layers.api.UpdateSemanticLayerCommand", + ) + mock_command.return_value.run.return_value = changed_model + + payload = {"name": "Updated Name"} + response = client.put( + f"/api/v1/semantic_layer/{test_uuid}", + json=payload, + ) + + assert response.status_code == 200 + assert response.json["result"]["uuid"] == str(test_uuid) + mock_command.assert_called_once_with(str(test_uuid), payload) + + +def test_put_semantic_layer_not_found( + client: Any, + full_api_access: None, + mocker: MockerFixture, +) -> None: + """Test PUT /<uuid> returns 404 when layer not found.""" + mock_command = mocker.patch( + "superset.semantic_layers.api.UpdateSemanticLayerCommand", + ) + mock_command.return_value.run.side_effect = SemanticLayerNotFoundError() + + response = client.put( + f"/api/v1/semantic_layer/{uuid_lib.uuid4()}", + json={"name": "New"}, + ) + + assert response.status_code == 404 + + +def test_put_semantic_layer_invalid( + client: Any, + full_api_access: None, + mocker: MockerFixture, +) -> None: + """Test PUT /<uuid> returns 422 when validation fails.""" + mock_command = mocker.patch( + "superset.semantic_layers.api.UpdateSemanticLayerCommand", + ) + mock_command.return_value.run.side_effect = SemanticLayerInvalidError( + "Name already exists" + ) + + response = client.put( + f"/api/v1/semantic_layer/{uuid_lib.uuid4()}", + json={"name": "Duplicate"}, + ) + + assert response.status_code == 422 + + +def test_put_semantic_layer_update_failed( + client: Any, + full_api_access: None, + mocker: MockerFixture, +) -> None: + """Test PUT /<uuid> returns 422 when update fails.""" + mock_command = mocker.patch( + "superset.semantic_layers.api.UpdateSemanticLayerCommand", + ) + mock_command.return_value.run.side_effect = SemanticLayerUpdateFailedError() + + response = client.put( + f"/api/v1/semantic_layer/{uuid_lib.uuid4()}", + json={"name": "Test"}, + ) + + assert response.status_code == 422 + + +def test_delete_semantic_layer( + client: Any, + full_api_access: None, + mocker: MockerFixture, +) -> None: + """Test DELETE /<uuid> deletes a semantic layer.""" + test_uuid = str(uuid_lib.uuid4()) + mock_command = mocker.patch( + "superset.semantic_layers.api.DeleteSemanticLayerCommand", + ) + mock_command.return_value.run.return_value = None + + response = client.delete(f"/api/v1/semantic_layer/{test_uuid}") + + assert response.status_code == 200 + mock_command.assert_called_once_with(test_uuid) + + +def test_delete_semantic_layer_not_found( + client: Any, + full_api_access: None, + mocker: MockerFixture, +) -> None: + """Test DELETE /<uuid> returns 404 when layer not found.""" + mock_command = mocker.patch( + "superset.semantic_layers.api.DeleteSemanticLayerCommand", + ) + mock_command.return_value.run.side_effect = SemanticLayerNotFoundError() + + response = client.delete(f"/api/v1/semantic_layer/{uuid_lib.uuid4()}") + + assert response.status_code == 404 + + +def test_delete_semantic_layer_failed( + client: Any, + full_api_access: None, + mocker: MockerFixture, +) -> None: + """Test DELETE /<uuid> returns 422 when deletion fails.""" + mock_command = mocker.patch( + "superset.semantic_layers.api.DeleteSemanticLayerCommand", + ) + mock_command.return_value.run.side_effect = SemanticLayerDeleteFailedError() + + response = client.delete(f"/api/v1/semantic_layer/{uuid_lib.uuid4()}") + + assert response.status_code == 422 + + +def test_get_list_semantic_layers( + client: Any, + full_api_access: None, + mocker: MockerFixture, +) -> None: + """Test GET / returns list of semantic layers.""" + layer1 = MagicMock() + layer1.uuid = uuid_lib.uuid4() + layer1.name = "Layer 1" + layer1.description = "First" + layer1.type = "snowflake" + layer1.cache_timeout = None + + layer2 = MagicMock() + layer2.uuid = uuid_lib.uuid4() + layer2.name = "Layer 2" + layer2.description = None + layer2.type = "snowflake" + layer2.cache_timeout = 300 + + mock_dao = mocker.patch("superset.semantic_layers.api.SemanticLayerDAO") + mock_dao.find_all.return_value = [layer1, layer2] + + response = client.get("/api/v1/semantic_layer/") + + assert response.status_code == 200 + result = response.json["result"] + assert len(result) == 2 + assert result[0]["name"] == "Layer 1" + assert result[1]["name"] == "Layer 2" + assert result[1]["cache_timeout"] == 300 + + +def test_get_list_semantic_layers_empty( + client: Any, + full_api_access: None, + mocker: MockerFixture, +) -> None: + """Test GET / returns empty list when no layers exist.""" + mock_dao = mocker.patch("superset.semantic_layers.api.SemanticLayerDAO") + mock_dao.find_all.return_value = [] + + response = client.get("/api/v1/semantic_layer/") + + assert response.status_code == 200 + assert response.json["result"] == [] + + +def test_get_semantic_layer( + client: Any, + full_api_access: None, + mocker: MockerFixture, +) -> None: + """Test GET /<uuid> returns a single semantic layer.""" + test_uuid = uuid_lib.uuid4() + layer = MagicMock() + layer.uuid = test_uuid + layer.name = "My Layer" + layer.description = "A layer" + layer.type = "snowflake" + layer.cache_timeout = 600 + + mock_dao = mocker.patch("superset.semantic_layers.api.SemanticLayerDAO") + mock_dao.find_by_uuid.return_value = layer + + response = client.get(f"/api/v1/semantic_layer/{test_uuid}") + + assert response.status_code == 200 + result = response.json["result"] + assert result["uuid"] == str(test_uuid) + assert result["name"] == "My Layer" + assert result["type"] == "snowflake" + assert result["cache_timeout"] == 600 + + +def test_get_semantic_layer_not_found( + client: Any, + full_api_access: None, + mocker: MockerFixture, +) -> None: + """Test GET /<uuid> returns 404 when layer not found.""" + mock_dao = mocker.patch("superset.semantic_layers.api.SemanticLayerDAO") + mock_dao.find_by_uuid.return_value = None + + response = client.get(f"/api/v1/semantic_layer/{uuid_lib.uuid4()}") + + assert response.status_code == 404 diff --git a/tests/unit_tests/semantic_layers/schemas_test.py b/tests/unit_tests/semantic_layers/schemas_test.py index 739544cdadc..33873ef5489 100644 --- a/tests/unit_tests/semantic_layers/schemas_test.py +++ b/tests/unit_tests/semantic_layers/schemas_test.py @@ -18,7 +18,11 @@ import pytest from marshmallow import ValidationError -from superset.semantic_layers.schemas import SemanticViewPutSchema +from superset.semantic_layers.schemas import ( + SemanticLayerPostSchema, + SemanticLayerPutSchema, + SemanticViewPutSchema, +) def test_semantic_view_put_schema_both_fields() -> None: @@ -77,3 +81,120 @@ def test_semantic_view_put_schema_unknown_field() -> None: with pytest.raises(ValidationError) as exc_info: schema.load({"unknown_field": "value"}) assert "unknown_field" in exc_info.value.messages + + +# ============================================================================= +# SemanticLayerPostSchema tests +# ============================================================================= + + +def test_post_schema_all_fields() -> None: + """Test loading all fields.""" + schema = SemanticLayerPostSchema() + result = schema.load({ + "name": "My Layer", + "description": "A layer", + "type": "snowflake", + "configuration": {"account": "test"}, + "cache_timeout": 300, + }) + assert result["name"] == "My Layer" + assert result["type"] == "snowflake" + assert result["configuration"] == {"account": "test"} + assert result["cache_timeout"] == 300 + + +def test_post_schema_required_fields_only() -> None: + """Test loading with only required fields.""" + schema = SemanticLayerPostSchema() + result = schema.load({ + "name": "My Layer", + "type": "snowflake", + "configuration": {"account": "test"}, + }) + assert result["name"] == "My Layer" + assert "description" not in result + assert "cache_timeout" not in result + + +def test_post_schema_missing_name() -> None: + """Test that missing name raises ValidationError.""" + schema = SemanticLayerPostSchema() + with pytest.raises(ValidationError) as exc_info: + schema.load({"type": "snowflake", "configuration": {}}) + assert "name" in exc_info.value.messages + + +def test_post_schema_missing_type() -> None: + """Test that missing type raises ValidationError.""" + schema = SemanticLayerPostSchema() + with pytest.raises(ValidationError) as exc_info: + schema.load({"name": "My Layer", "configuration": {}}) + assert "type" in exc_info.value.messages + + +def test_post_schema_missing_configuration() -> None: + """Test that missing configuration raises ValidationError.""" + schema = SemanticLayerPostSchema() + with pytest.raises(ValidationError) as exc_info: + schema.load({"name": "My Layer", "type": "snowflake"}) + assert "configuration" in exc_info.value.messages + + +def test_post_schema_null_description() -> None: + """Test that description accepts None.""" + schema = SemanticLayerPostSchema() + result = schema.load({ + "name": "My Layer", + "type": "snowflake", + "configuration": {}, + "description": None, + }) + assert result["description"] is None + + +# ============================================================================= +# SemanticLayerPutSchema tests +# ============================================================================= + + +def test_put_schema_all_fields() -> None: + """Test loading all fields.""" + schema = SemanticLayerPutSchema() + result = schema.load({ + "name": "Updated", + "description": "New desc", + "configuration": {"account": "new"}, + "cache_timeout": 600, + }) + assert result["name"] == "Updated" + assert result["configuration"] == {"account": "new"} + + +def test_put_schema_empty() -> None: + """Test loading empty payload.""" + schema = SemanticLayerPutSchema() + result = schema.load({}) + assert result == {} + + +def test_put_schema_name_only() -> None: + """Test loading with only name.""" + schema = SemanticLayerPutSchema() + result = schema.load({"name": "New Name"}) + assert result == {"name": "New Name"} + + +def test_put_schema_configuration_only() -> None: + """Test loading with only configuration.""" + schema = SemanticLayerPutSchema() + result = schema.load({"configuration": {"key": "value"}}) + assert result == {"configuration": {"key": "value"}} + + +def test_put_schema_unknown_field() -> None: + """Test that unknown fields raise ValidationError.""" + schema = SemanticLayerPutSchema() + with pytest.raises(ValidationError) as exc_info: + schema.load({"unknown_field": "value"}) + assert "unknown_field" in exc_info.value.messages
