You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@superset.apache.org by be...@apache.org on 2026/04/23 17:37:24 UTC
(superset) 01/33: feat: API for semantic layers
This is an automated email from the ASF dual-hosted git repository.
betodealmeida pushed a commit to branch backup/semantic-layer-dynamic-columns-before-rebase-20260423-133321
in repository https://gitbox.apache.org/repos/asf/superset.git
commit d6d8e094453aa655062fe64d6daca2c122f3cbfc
Author: Beto Dealmeida <ro...@dealmeida.net>
AuthorDate: Wed Feb 11 16:02:37 2026 -0500
feat: API for semantic layers
---
pyproject.toml | 1 +
superset/commands/semantic_layer/create.py | 64 ++
superset/commands/semantic_layer/delete.py | 56 ++
superset/commands/semantic_layer/exceptions.py | 27 +
superset/commands/semantic_layer/update.py | 41 +-
superset/daos/semantic_layer.py | 15 +
superset/initialization/__init__.py | 6 +-
superset/semantic_layers/api.py | 363 +++++++++++-
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 | 651 +++++++++++++++++++++
tests/unit_tests/semantic_layers/schemas_test.py | 131 ++++-
15 files changed, 1719 insertions(+), 9 deletions(-)
diff --git a/pyproject.toml b/pyproject.toml
index 70feeeb4d3e..d222e3ccece 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -285,6 +285,7 @@ module = [
"superset.tags.filters",
"superset.commands.security.update",
"superset.commands.security.create",
+ "superset.semantic_layers.api",
]
warn_unused_ignores = false
diff --git a/superset/commands/semantic_layer/create.py b/superset/commands/semantic_layer/create.py
new file mode 100644
index 00000000000..48ec0603c2a
--- /dev/null
+++ b/superset/commands/semantic_layer/create.py
@@ -0,0 +1,64 @@
+# 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.commands.base import BaseCommand
+from superset.commands.semantic_layer.exceptions import (
+ SemanticLayerCreateFailedError,
+ SemanticLayerInvalidError,
+)
+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 CreateSemanticLayerCommand(BaseCommand):
+ def __init__(self, data: dict[str, Any]):
+ self._properties = data.copy()
+
+ @transaction(
+ on_error=partial(
+ on_error,
+ catches=(SQLAlchemyError, ValueError),
+ reraise=SemanticLayerCreateFailedError,
+ )
+ )
+ def run(self) -> Model:
+ self.validate()
+ return SemanticLayerDAO.create(attributes=self._properties)
+
+ def validate(self) -> None:
+ sl_type = self._properties.get("type")
+ if sl_type not in registry:
+ raise SemanticLayerInvalidError(f"Unknown type: {sl_type}")
+
+ name: str = 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/delete.py b/superset/commands/semantic_layer/delete.py
new file mode 100644
index 00000000000..677126221a8
--- /dev/null
+++ b/superset/commands/semantic_layer/delete.py
@@ -0,0 +1,56 @@
+# 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 sqlalchemy.exc import SQLAlchemyError
+
+from superset.commands.base import BaseCommand
+from superset.commands.semantic_layer.exceptions import (
+ SemanticLayerDeleteFailedError,
+ SemanticLayerNotFoundError,
+)
+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 DeleteSemanticLayerCommand(BaseCommand):
+ def __init__(self, uuid: str):
+ self._uuid = uuid
+ self._model: SemanticLayer | None = None
+
+ @transaction(
+ on_error=partial(
+ on_error,
+ catches=(SQLAlchemyError,),
+ reraise=SemanticLayerDeleteFailedError,
+ )
+ )
+ def run(self) -> None:
+ self.validate()
+ assert self._model
+ SemanticLayerDAO.delete([self._model])
+
+ def validate(self) -> None:
+ self._model = SemanticLayerDAO.find_by_uuid(self._uuid)
+ if not self._model:
+ 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 55fbff34125..7487e17996a 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 import json
from superset.utils.decorators import on_error, transaction
@@ -83,3 +87,36 @@ class UpdateSemanticViewCommand(BaseCommand):
f"A semantic view with name '{name}' and the same "
"configuration already exists in this semantic layer."
)
+
+
+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 3ae4d97c431..8782973561c 100644
--- a/superset/daos/semantic_layer.py
+++ b/superset/daos/semantic_layer.py
@@ -38,6 +38,21 @@ class SemanticLayerDAO(AbstractSemanticLayerDAO):
model_cls = SemanticLayer
+ @staticmethod
+ def find_by_uuid(uuid_str: str) -> SemanticLayer | None:
+ try:
+ return (
+ db.session.query(SemanticLayer)
+ .filter(SemanticLayer.uuid == uuid_str)
+ .one_or_none()
+ )
+ except ValueError:
+ return None
+
+ @classmethod
+ def find_all(cls, skip_base_filter: bool = False) -> list[SemanticLayer]:
+ return db.session.query(SemanticLayer).all()
+
@classmethod
def validate_uniqueness(cls, name: str) -> bool:
"""
diff --git a/superset/initialization/__init__.py b/superset/initialization/__init__.py
index 62075dac5bc..c8ec9bbe516 100644
--- a/superset/initialization/__init__.py
+++ b/superset/initialization/__init__.py
@@ -269,8 +269,12 @@ class SupersetAppInitializer: # pylint: disable=too-many-public-methods
appbuilder.add_api(RLSRestApi)
appbuilder.add_api(SavedQueryRestApi)
if feature_flag_manager.is_feature_enabled("SEMANTIC_LAYERS"):
- from superset.semantic_layers.api import SemanticViewRestApi
+ from superset.semantic_layers.api import (
+ SemanticLayerRestApi,
+ SemanticViewRestApi,
+ )
+ 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..eb10205106a 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,329 @@ 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} # type: ignore[attr-defined]
+ 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) # type: ignore[arg-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 # type: ignore[attr-defined]
+ 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, # type: ignore[attr-defined]
+ 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 467926fc2c5..1b144428e89 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
@@ -106,6 +111,116 @@ def test_update_semantic_view_copies_data(mocker: MockerFixture) -> None:
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"}
+
+
def _make_view_model(
uuid: str = "view-uuid-1",
name: str = "my_view",
diff --git a/tests/unit_tests/semantic_layers/api_test.py b/tests/unit_tests/semantic_layers/api_test.py
index db77ec9670f..08e1a1ab796 100644
--- a/tests/unit_tests/semantic_layers/api_test.py
+++ b/tests/unit_tests/semantic_layers/api_test.py
@@ -15,6 +15,7 @@
# specific language governing permissions and limitations
# under the License.
+import uuid as uuid_lib
from typing import Any
from unittest.mock import MagicMock
@@ -22,6 +23,11 @@ import pytest
from pytest_mock import MockerFixture
from superset.commands.semantic_layer.exceptions import (
+ SemanticLayerCreateFailedError,
+ SemanticLayerDeleteFailedError,
+ SemanticLayerInvalidError,
+ SemanticLayerNotFoundError,
+ SemanticLayerUpdateFailedError,
SemanticViewForbiddenError,
SemanticViewInvalidError,
SemanticViewNotFoundError,
@@ -259,3 +265,648 @@ def test_put_semantic_view_empty_payload(
)
assert response.status_code == 200
+
+
+# =============================================================================
+# SemanticLayerRestApi tests
+# =============================================================================
+
+
+@SEMANTIC_LAYERS_APP
+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.",
+ }
+
+
+@SEMANTIC_LAYERS_APP
+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"] == []
+
+
+@SEMANTIC_LAYERS_APP
+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)
+
+
+@SEMANTIC_LAYERS_APP
+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"})
+
+
+@SEMANTIC_LAYERS_APP
+def test_configuration_schema_with_invalid_partial_config(
+ client: Any,
+ full_api_access: None,
+ mocker: MockerFixture,
+) -> None:
+ """Test /schema/configuration 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)
+
+
+@SEMANTIC_LAYERS_APP
+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
+
+
+@SEMANTIC_LAYERS_APP
+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"}
+ )
+
+
+@SEMANTIC_LAYERS_APP
+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)
+
+
+@SEMANTIC_LAYERS_APP
+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
+
+
+@SEMANTIC_LAYERS_APP
+def test_runtime_schema_unknown_type(
+ client: Any,
+ full_api_access: None,
+ mocker: MockerFixture,
+) -> None:
+ """Test POST /<uuid>/schema/runtime returns 400 for unknown type."""
+ test_uuid = str(uuid_lib.uuid4())
+ mock_layer = MagicMock()
+ mock_layer.type = "unknown_type"
+
+ mock_dao = mocker.patch("superset.semantic_layers.api.SemanticLayerDAO")
+ mock_dao.find_by_uuid.return_value = mock_layer
+
+ mocker.patch.dict(
+ "superset.semantic_layers.api.registry",
+ {},
+ clear=True,
+ )
+
+ response = client.post(
+ f"/api/v1/semantic_layer/{test_uuid}/schema/runtime",
+ )
+
+ assert response.status_code == 400
+ assert "Unknown type" in response.json["message"]
+
+
+@SEMANTIC_LAYERS_APP
+def test_runtime_schema_exception(
+ client: Any,
+ full_api_access: None,
+ mocker: MockerFixture,
+) -> None:
+ """Test POST /<uuid>/schema/runtime returns 400 when schema raises."""
+ 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.side_effect = ValueError("Bad config")
+
+ 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 == 400
+ assert "Bad config" in response.json["message"]
+
+
+@SEMANTIC_LAYERS_APP
+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)
+
+
+@SEMANTIC_LAYERS_APP
+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
+
+
+@SEMANTIC_LAYERS_APP
+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
+
+
+@SEMANTIC_LAYERS_APP
+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
+
+
+@SEMANTIC_LAYERS_APP
+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)
+
+
+@SEMANTIC_LAYERS_APP
+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
+
+
+@SEMANTIC_LAYERS_APP
+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
+
+
+@SEMANTIC_LAYERS_APP
+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
+
+
+@SEMANTIC_LAYERS_APP
+def test_put_semantic_layer_validation_error(
+ client: Any,
+ full_api_access: None,
+) -> None:
+ """Test PUT /<uuid> returns 400 when payload fails schema validation."""
+ response = client.put(
+ f"/api/v1/semantic_layer/{uuid_lib.uuid4()}",
+ json={"cache_timeout": "not_a_number"},
+ )
+
+ assert response.status_code == 400
+
+
+@SEMANTIC_LAYERS_APP
+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)
+
+
+@SEMANTIC_LAYERS_APP
+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
+
+
+@SEMANTIC_LAYERS_APP
+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
+
+
+@SEMANTIC_LAYERS_APP
+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
+
+
+@SEMANTIC_LAYERS_APP
+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"] == []
+
+
+@SEMANTIC_LAYERS_APP
+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
+
+
+@SEMANTIC_LAYERS_APP
+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..76529621042 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,128 @@ 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