feat: API for semantic layers

This commit is contained in:
Beto Dealmeida
2026-02-11 16:02:37 -05:00
parent 8dc3ad65f5
commit 06722481d9
15 changed files with 1719 additions and 9 deletions

View File

@@ -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"},
}

View File

@@ -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()

View File

@@ -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."

View File

@@ -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",

View File

@@ -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

View File

@@ -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