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

Reply via email to