This is an automated email from the ASF dual-hosted git repository. beto pushed a commit to branch semantic-layer-ui-semantic-view in repository https://gitbox.apache.org/repos/asf/superset.git
commit 4ade90aeb44675ed325a19fd225c190b2d895700 Author: Beto Dealmeida <[email protected]> AuthorDate: Tue Feb 10 19:24:36 2026 -0500 feat: API for semantic views --- superset/commands/semantic_layer/__init__.py | 16 ++ superset/commands/semantic_layer/exceptions.py | 41 ++++ superset/commands/semantic_layer/update.py | 67 ++++++ superset/initialization/__init__.py | 2 + superset/semantic_layers/api.py | 128 +++++++++++ superset/semantic_layers/schemas.py | 22 ++ .../unit_tests/commands/semantic_layer/__init__.py | 16 ++ .../commands/semantic_layer/exceptions_test.py | 48 ++++ .../commands/semantic_layer/update_test.py | 104 +++++++++ tests/unit_tests/semantic_layers/api_test.py | 244 +++++++++++++++++++++ tests/unit_tests/semantic_layers/schemas_test.py | 79 +++++++ 11 files changed, 767 insertions(+) diff --git a/superset/commands/semantic_layer/__init__.py b/superset/commands/semantic_layer/__init__.py new file mode 100644 index 00000000000..13a83393a91 --- /dev/null +++ b/superset/commands/semantic_layer/__init__.py @@ -0,0 +1,16 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. diff --git a/superset/commands/semantic_layer/exceptions.py b/superset/commands/semantic_layer/exceptions.py new file mode 100644 index 00000000000..3e2fad94752 --- /dev/null +++ b/superset/commands/semantic_layer/exceptions.py @@ -0,0 +1,41 @@ +# 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 flask_babel import lazy_gettext as _ + +from superset.commands.exceptions import ( + CommandException, + CommandInvalidError, + ForbiddenError, + UpdateFailedError, +) + + +class SemanticViewNotFoundError(CommandException): + status = 404 + message = _("Semantic view does not exist") + + +class SemanticViewForbiddenError(ForbiddenError): + message = _("Changing this semantic view is forbidden") + + +class SemanticViewInvalidError(CommandInvalidError): + message = _("Semantic view parameters are invalid.") + + +class SemanticViewUpdateFailedError(UpdateFailedError): + message = _("Semantic view could not be updated.") diff --git a/superset/commands/semantic_layer/update.py b/superset/commands/semantic_layer/update.py new file mode 100644 index 00000000000..4ce8da07646 --- /dev/null +++ b/superset/commands/semantic_layer/update.py @@ -0,0 +1,67 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. +from __future__ import annotations + +import logging +from 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, +) +from superset.daos.semantic_layer import SemanticViewDAO +from superset.exceptions import SupersetSecurityException +from superset.semantic_layers.models import SemanticView +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 + + @transaction( + on_error=partial( + on_error, + catches=(SQLAlchemyError, ValueError), + reraise=SemanticViewUpdateFailedError, + ) + ) + def run(self) -> Model: + self.validate() + assert self._model + return SemanticViewDAO.update(self._model, attributes=self._properties) + + def validate(self) -> None: + self._model = SemanticViewDAO.find_by_id(self._model_id) + if not self._model: + raise SemanticViewNotFoundError() + + try: + security_manager.raise_for_ownership(self._model) + except SupersetSecurityException as ex: + raise SemanticViewForbiddenError() from ex diff --git a/superset/initialization/__init__.py b/superset/initialization/__init__.py index 3a34d315bf5..2f90db3d308 100644 --- a/superset/initialization/__init__.py +++ b/superset/initialization/__init__.py @@ -178,6 +178,7 @@ 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, @@ -265,6 +266,7 @@ class SupersetAppInitializer: # pylint: disable=too-many-public-methods appbuilder.add_api(ReportExecutionLogRestApi) appbuilder.add_api(RLSRestApi) appbuilder.add_api(SavedQueryRestApi) + appbuilder.add_api(SemanticViewRestApi) appbuilder.add_api(TagRestApi) appbuilder.add_api(SqlLabRestApi) appbuilder.add_api(SqlLabPermalinkRestApi) diff --git a/superset/semantic_layers/api.py b/superset/semantic_layers/api.py new file mode 100644 index 00000000000..6cf5ea0e5e9 --- /dev/null +++ b/superset/semantic_layers/api.py @@ -0,0 +1,128 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. +from __future__ import annotations + +import logging + +from flask import request, Response +from flask_appbuilder.api import expose, protect +from flask_appbuilder.models.sqla.interface import SQLAInterface +from marshmallow import ValidationError + +from superset import event_logger +from superset.commands.semantic_layer.exceptions import ( + SemanticViewForbiddenError, + SemanticViewInvalidError, + SemanticViewNotFoundError, + SemanticViewUpdateFailedError, +) +from superset.commands.semantic_layer.update import 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.views.base_api import ( + BaseSupersetModelRestApi, + requires_json, + statsd_metrics, +) + +logger = logging.getLogger(__name__) + + +class SemanticViewRestApi(BaseSupersetModelRestApi): + datamodel = SQLAInterface(SemanticView) + + resource_name = "semantic_view" + allow_browser_login = True + class_permission_name = "SemanticView" + method_permission_name = MODEL_API_RW_METHOD_PERMISSION_MAP + include_route_methods = {"put"} + + edit_model_schema = SemanticViewPutSchema() + + @expose("/<pk>", methods=("PUT",)) + @protect() + @statsd_metrics + @event_logger.log_this_with_context( + action=lambda self, *args, **kwargs: f"{self.__class__.__name__}.put", + log_to_statsd=False, + ) + @requires_json + def put(self, pk: int) -> Response: + """Update a semantic view. + --- + put: + summary: Update a semantic view + parameters: + - in: path + schema: + type: integer + name: pk + requestBody: + description: Semantic view schema + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/{{self.__class__.__name__}}.put' + responses: + 200: + description: Semantic view changed + content: + application/json: + schema: + type: object + properties: + id: + type: number + result: + $ref: '#/components/schemas/{{self.__class__.__name__}}.put' + 400: + $ref: '#/components/responses/400' + 401: + $ref: '#/components/responses/401' + 403: + $ref: '#/components/responses/403' + 404: + $ref: '#/components/responses/404' + 422: + $ref: '#/components/responses/422' + 500: + $ref: '#/components/responses/500' + """ + try: + item = self.edit_model_schema.load(request.json) + except ValidationError as error: + return self.response_400(message=error.messages) + try: + changed_model = UpdateSemanticViewCommand(pk, item).run() + response = self.response(200, id=changed_model.id, result=item) + except SemanticViewNotFoundError: + response = self.response_404() + except SemanticViewForbiddenError: + response = self.response_403() + except SemanticViewInvalidError as ex: + response = self.response_422(message=ex.normalized_messages()) + except SemanticViewUpdateFailedError as ex: + logger.error( + "Error updating model %s: %s", + self.__class__.__name__, + str(ex), + exc_info=True, + ) + response = self.response_422(message=str(ex)) + return response diff --git a/superset/semantic_layers/schemas.py b/superset/semantic_layers/schemas.py new file mode 100644 index 00000000000..b86a5398937 --- /dev/null +++ b/superset/semantic_layers/schemas.py @@ -0,0 +1,22 @@ +# 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 marshmallow import fields, Schema + + +class SemanticViewPutSchema(Schema): + description = fields.String(allow_none=True) + cache_timeout = fields.Integer(allow_none=True) diff --git a/tests/unit_tests/commands/semantic_layer/__init__.py b/tests/unit_tests/commands/semantic_layer/__init__.py new file mode 100644 index 00000000000..13a83393a91 --- /dev/null +++ b/tests/unit_tests/commands/semantic_layer/__init__.py @@ -0,0 +1,16 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. diff --git a/tests/unit_tests/commands/semantic_layer/exceptions_test.py b/tests/unit_tests/commands/semantic_layer/exceptions_test.py new file mode 100644 index 00000000000..fb6728a36ab --- /dev/null +++ b/tests/unit_tests/commands/semantic_layer/exceptions_test.py @@ -0,0 +1,48 @@ +# 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 superset.commands.semantic_layer.exceptions import ( + SemanticViewForbiddenError, + SemanticViewInvalidError, + SemanticViewNotFoundError, + SemanticViewUpdateFailedError, +) + + +def test_semantic_view_not_found_error() -> None: + """Test SemanticViewNotFoundError has correct status and message.""" + error = SemanticViewNotFoundError() + assert error.status == 404 + assert str(error.message) == "Semantic view does not exist" + + +def test_semantic_view_forbidden_error() -> None: + """Test SemanticViewForbiddenError has correct message.""" + error = SemanticViewForbiddenError() + assert str(error.message) == "Changing this semantic view is forbidden" + + +def test_semantic_view_invalid_error() -> None: + """Test SemanticViewInvalidError has correct message.""" + error = SemanticViewInvalidError() + assert str(error.message) == "Semantic view parameters are invalid." + + +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." diff --git a/tests/unit_tests/commands/semantic_layer/update_test.py b/tests/unit_tests/commands/semantic_layer/update_test.py new file mode 100644 index 00000000000..4c0ad47f8b9 --- /dev/null +++ b/tests/unit_tests/commands/semantic_layer/update_test.py @@ -0,0 +1,104 @@ +# 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.exceptions import ( + SemanticViewForbiddenError, + SemanticViewNotFoundError, +) +from superset.commands.semantic_layer.update import UpdateSemanticViewCommand +from superset.exceptions import SupersetSecurityException + + +def test_update_semantic_view_success(mocker: MockerFixture) -> None: + """Test successful update of a semantic view.""" + mock_model = MagicMock() + mock_model.id = 1 + + dao = mocker.patch( + "superset.commands.semantic_layer.update.SemanticViewDAO", + ) + dao.find_by_id.return_value = mock_model + dao.update.return_value = mock_model + + mocker.patch( + "superset.commands.semantic_layer.update.security_manager", + ) + + data = {"description": "Updated", "cache_timeout": 300} + result = UpdateSemanticViewCommand(1, data).run() + + assert result == mock_model + dao.find_by_id.assert_called_once_with(1) + dao.update.assert_called_once_with(mock_model, attributes=data) + + +def test_update_semantic_view_not_found(mocker: MockerFixture) -> None: + """Test that SemanticViewNotFoundError is raised when model is missing.""" + dao = mocker.patch( + "superset.commands.semantic_layer.update.SemanticViewDAO", + ) + dao.find_by_id.return_value = None + + with pytest.raises(SemanticViewNotFoundError): + UpdateSemanticViewCommand(999, {"description": "test"}).run() + + +def test_update_semantic_view_forbidden(mocker: MockerFixture) -> None: + """Test that SemanticViewForbiddenError is raised on ownership failure.""" + mock_model = MagicMock() + + dao = mocker.patch( + "superset.commands.semantic_layer.update.SemanticViewDAO", + ) + dao.find_by_id.return_value = mock_model + + sm = mocker.patch( + "superset.commands.semantic_layer.update.security_manager", + ) + # Use a regular MagicMock for raise_for_ownership to avoid AsyncMock issues + sm.raise_for_ownership = MagicMock( + side_effect=SupersetSecurityException(MagicMock()), + ) + + with pytest.raises(SemanticViewForbiddenError): + UpdateSemanticViewCommand(1, {"description": "test"}).run() + + +def test_update_semantic_view_copies_data(mocker: MockerFixture) -> None: + """Test that the command copies input data and does not mutate it.""" + mock_model = MagicMock() + + dao = mocker.patch( + "superset.commands.semantic_layer.update.SemanticViewDAO", + ) + dao.find_by_id.return_value = mock_model + dao.update.return_value = mock_model + + mocker.patch( + "superset.commands.semantic_layer.update.security_manager", + ) + + original_data = {"description": "Original"} + UpdateSemanticViewCommand(1, original_data).run() + + # The original dict should not have been modified + 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 new file mode 100644 index 00000000000..67faf78d8de --- /dev/null +++ b/tests/unit_tests/semantic_layers/api_test.py @@ -0,0 +1,244 @@ +# 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 unittest.mock import MagicMock + +from pytest_mock import MockerFixture + +from superset.commands.semantic_layer.exceptions import ( + SemanticViewForbiddenError, + SemanticViewInvalidError, + SemanticViewNotFoundError, + SemanticViewUpdateFailedError, +) + + +def test_put_semantic_view( + client: Any, + full_api_access: None, + mocker: MockerFixture, +) -> None: + """Test successful PUT updates a semantic view.""" + changed_model = MagicMock() + changed_model.id = 1 + + mock_command = mocker.patch( + "superset.semantic_layers.api.UpdateSemanticViewCommand", + ) + mock_command.return_value.run.return_value = changed_model + + payload = {"description": "Updated description", "cache_timeout": 300} + response = client.put( + "/api/v1/semantic_view/1", + json=payload, + ) + + assert response.status_code == 200 + assert response.json["id"] == 1 + assert response.json["result"] == payload + mock_command.assert_called_once_with("1", payload) + + +def test_put_semantic_view_not_found( + client: Any, + full_api_access: None, + mocker: MockerFixture, +) -> None: + """Test PUT returns 404 when semantic view does not exist.""" + mock_command = mocker.patch( + "superset.semantic_layers.api.UpdateSemanticViewCommand", + ) + mock_command.return_value.run.side_effect = SemanticViewNotFoundError() + + response = client.put( + "/api/v1/semantic_view/999", + json={"description": "Updated"}, + ) + + assert response.status_code == 404 + + +def test_put_semantic_view_forbidden( + client: Any, + full_api_access: None, + mocker: MockerFixture, +) -> None: + """Test PUT returns 403 when user lacks ownership.""" + mock_command = mocker.patch( + "superset.semantic_layers.api.UpdateSemanticViewCommand", + ) + mock_command.return_value.run.side_effect = SemanticViewForbiddenError() + + response = client.put( + "/api/v1/semantic_view/1", + json={"description": "Updated"}, + ) + + assert response.status_code == 403 + + +def test_put_semantic_view_invalid( + client: Any, + full_api_access: None, + mocker: MockerFixture, +) -> None: + """Test PUT returns 422 when validation fails.""" + mock_command = mocker.patch( + "superset.semantic_layers.api.UpdateSemanticViewCommand", + ) + mock_command.return_value.run.side_effect = SemanticViewInvalidError() + + response = client.put( + "/api/v1/semantic_view/1", + json={"description": "Updated"}, + ) + + assert response.status_code == 422 + + +def test_put_semantic_view_update_failed( + client: Any, + full_api_access: None, + mocker: MockerFixture, +) -> None: + """Test PUT returns 422 when the update operation fails.""" + mock_command = mocker.patch( + "superset.semantic_layers.api.UpdateSemanticViewCommand", + ) + mock_command.return_value.run.side_effect = SemanticViewUpdateFailedError() + + response = client.put( + "/api/v1/semantic_view/1", + json={"description": "Updated"}, + ) + + assert response.status_code == 422 + + +def test_put_semantic_view_bad_request( + client: Any, + full_api_access: None, + mocker: MockerFixture, +) -> None: + """Test PUT returns 400 when the request payload has invalid fields.""" + # Marshmallow raises ValidationError for unknown fields + mocker.patch( + "superset.semantic_layers.api.UpdateSemanticViewCommand", + ) + + response = client.put( + "/api/v1/semantic_view/1", + json={"invalid_field": "value"}, + ) + + assert response.status_code == 400 + + +def test_put_semantic_view_description_only( + client: Any, + full_api_access: None, + mocker: MockerFixture, +) -> None: + """Test PUT with only description field.""" + changed_model = MagicMock() + changed_model.id = 1 + + mock_command = mocker.patch( + "superset.semantic_layers.api.UpdateSemanticViewCommand", + ) + mock_command.return_value.run.return_value = changed_model + + payload = {"description": "New description"} + response = client.put( + "/api/v1/semantic_view/1", + json=payload, + ) + + assert response.status_code == 200 + assert response.json["result"] == payload + + +def test_put_semantic_view_cache_timeout_only( + client: Any, + full_api_access: None, + mocker: MockerFixture, +) -> None: + """Test PUT with only cache_timeout field.""" + changed_model = MagicMock() + changed_model.id = 2 + + mock_command = mocker.patch( + "superset.semantic_layers.api.UpdateSemanticViewCommand", + ) + mock_command.return_value.run.return_value = changed_model + + payload = {"cache_timeout": 600} + response = client.put( + "/api/v1/semantic_view/2", + json=payload, + ) + + assert response.status_code == 200 + assert response.json["id"] == 2 + assert response.json["result"] == payload + + +def test_put_semantic_view_null_values( + client: Any, + full_api_access: None, + mocker: MockerFixture, +) -> None: + """Test PUT with null values for both fields.""" + changed_model = MagicMock() + changed_model.id = 1 + + mock_command = mocker.patch( + "superset.semantic_layers.api.UpdateSemanticViewCommand", + ) + mock_command.return_value.run.return_value = changed_model + + payload = {"description": None, "cache_timeout": None} + response = client.put( + "/api/v1/semantic_view/1", + json=payload, + ) + + assert response.status_code == 200 + assert response.json["result"] == payload + + +def test_put_semantic_view_empty_payload( + client: Any, + full_api_access: None, + mocker: MockerFixture, +) -> None: + """Test PUT with empty payload.""" + changed_model = MagicMock() + changed_model.id = 1 + + mock_command = mocker.patch( + "superset.semantic_layers.api.UpdateSemanticViewCommand", + ) + mock_command.return_value.run.return_value = changed_model + + response = client.put( + "/api/v1/semantic_view/1", + json={}, + ) + + assert response.status_code == 200 diff --git a/tests/unit_tests/semantic_layers/schemas_test.py b/tests/unit_tests/semantic_layers/schemas_test.py new file mode 100644 index 00000000000..739544cdadc --- /dev/null +++ b/tests/unit_tests/semantic_layers/schemas_test.py @@ -0,0 +1,79 @@ +# 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. + +import pytest +from marshmallow import ValidationError + +from superset.semantic_layers.schemas import SemanticViewPutSchema + + +def test_semantic_view_put_schema_both_fields() -> None: + """Test loading both description and cache_timeout.""" + schema = SemanticViewPutSchema() + result = schema.load({"description": "A description", "cache_timeout": 300}) + assert result == {"description": "A description", "cache_timeout": 300} + + +def test_semantic_view_put_schema_description_only() -> None: + """Test loading with only description.""" + schema = SemanticViewPutSchema() + result = schema.load({"description": "Just a description"}) + assert result == {"description": "Just a description"} + + +def test_semantic_view_put_schema_cache_timeout_only() -> None: + """Test loading with only cache_timeout.""" + schema = SemanticViewPutSchema() + result = schema.load({"cache_timeout": 600}) + assert result == {"cache_timeout": 600} + + +def test_semantic_view_put_schema_empty() -> None: + """Test loading empty payload.""" + schema = SemanticViewPutSchema() + result = schema.load({}) + assert result == {} + + +def test_semantic_view_put_schema_null_description() -> None: + """Test that description accepts None.""" + schema = SemanticViewPutSchema() + result = schema.load({"description": None}) + assert result == {"description": None} + + +def test_semantic_view_put_schema_null_cache_timeout() -> None: + """Test that cache_timeout accepts None.""" + schema = SemanticViewPutSchema() + result = schema.load({"cache_timeout": None}) + assert result == {"cache_timeout": None} + + +def test_semantic_view_put_schema_invalid_cache_timeout() -> None: + """Test that non-integer cache_timeout raises ValidationError.""" + schema = SemanticViewPutSchema() + with pytest.raises(ValidationError) as exc_info: + schema.load({"cache_timeout": "not_a_number"}) + assert "cache_timeout" in exc_info.value.messages + + +def test_semantic_view_put_schema_unknown_field() -> None: + """Test that unknown fields raise ValidationError.""" + schema = SemanticViewPutSchema() + with pytest.raises(ValidationError) as exc_info: + schema.load({"unknown_field": "value"}) + assert "unknown_field" in exc_info.value.messages
