This is an automated email from the ASF dual-hosted git repository.

aminghadersohi pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/superset.git


The following commit(s) were added to refs/heads/master by this push:
     new aabddf5b3f5 fix(semantic-layers): cap and time-bound bulk semantic 
view creation (#45532)
aabddf5b3f5 is described below

commit aabddf5b3f5d82b1b2598380ce2d3593784a977f
Author: Mike Bridge <[email protected]>
AuthorDate: Thu Oct 22 10:56:49 2026 -0600

    fix(semantic-layers): cap and time-bound bulk semantic view creation 
(#45532)
---
 UPDATING.md                                        |   8 +
 .../using-superset/semantic-layer-connections.mdx  |  11 +
 docs/static/resources/openapi.json                 |   1 +
 superset/commands/semantic_layer/create.py         |  11 +
 superset/config.py                                 |  13 +
 superset/semantic_layers/api.py                    |  63 ++++-
 superset/translations/messages.pot                 |  17 ++
 .../commands/semantic_layer/create_test.py         |  53 ++++-
 tests/unit_tests/semantic_layers/api_test.py       | 262 ++++++++++++++++++++-
 9 files changed, 427 insertions(+), 12 deletions(-)

diff --git a/UPDATING.md b/UPDATING.md
index 0e2c1aa2534..d9819c4720c 100644
--- a/UPDATING.md
+++ b/UPDATING.md
@@ -24,6 +24,14 @@ assists people when migrating to a new version.
 
 ## Next
 
+- **Bulk semantic view creation is capped and time-bounded.** With
+  `SEMANTIC_LAYERS` enabled, `POST /api/v1/semantic_view/` refuses requests 
with
+  more than `SEMANTIC_VIEW_BULK_CREATE_MAX_VIEWS` views (default 100) with a 
400,
+  and views not checked within `SEMANTIC_VIEW_BULK_CREATE_TIMEOUT` seconds
+  (default 30) are reported with `error_type: "TIMEOUT"` instead of being 
looked
+  up. Clients adding more views should split them across requests and retry
+  timed-out views.
+
 - MCP `get_dashboard_data` reports `total_rows=null` and `truncated=true` when
   row-collapsing post-processing leaves input coverage unverified, not only 
when
   truncation is observed. Output row counts do not prove complete input 
coverage.
diff --git a/docs/docs/using-superset/semantic-layer-connections.mdx 
b/docs/docs/using-superset/semantic-layer-connections.mdx
index 7e751b6392b..f7946567fbf 100644
--- a/docs/docs/using-superset/semantic-layer-connections.mdx
+++ b/docs/docs/using-superset/semantic-layer-connections.mdx
@@ -67,3 +67,14 @@ the submitted configuration before saving it. If lookup 
fails or returns no view
 the API reports that the view may not exist or the semantic layer may be
 unreachable, and does not add it. Other valid views in the same request can 
still
 be added. This check does not guarantee that later queries will succeed.
+
+One request can add at most `SEMANTIC_VIEW_BULK_CREATE_MAX_VIEWS` views (100 by
+default); larger requests are refused before any lookup. Lookups for one 
request
+share a time budget, `SEMANTIC_VIEW_BULK_CREATE_TIMEOUT` (30 seconds by 
default).
+The budget is checked before each lookup, so a lookup already running is 
limited
+only by the semantic layer's own timeouts; keep the budget well below the web
+server and proxy timeouts so the response arrives. Views not checked in time 
are reported
+with the `TIMEOUT` error type and can be retried in another request; views 
added
+before the budget ran out stay added. Views that already exist are refused 
before
+their lookup, so resending the whole request spends the budget only on the 
views
+that are still missing.
diff --git a/docs/static/resources/openapi.json 
b/docs/static/resources/openapi.json
index f1bedd1da14..d6ae235ed60 100644
--- a/docs/static/resources/openapi.json
+++ b/docs/static/resources/openapi.json
@@ -34723,6 +34723,7 @@
               "schema": {
                 "properties": {
                   "views": {
+                    "description": "At most 
SEMANTIC_VIEW_BULK_CREATE_MAX_VIEWS views. Views not validated within 
SEMANTIC_VIEW_BULK_CREATE_TIMEOUT seconds are reported with error_type 
TIMEOUT.",
                     "items": {
                       "properties": {
                         "cache_timeout": {
diff --git a/superset/commands/semantic_layer/create.py 
b/superset/commands/semantic_layer/create.py
index b9dec770b07..c32f479912d 100644
--- a/superset/commands/semantic_layer/create.py
+++ b/superset/commands/semantic_layer/create.py
@@ -126,6 +126,9 @@ class CreateSemanticViewCommand(BaseCommand):
             raise SemanticViewInvalidError(
                 _("The view configuration must be a JSON object.")
             )
+        # Refuse known duplicates before spending a provider lookup on them, 
so a
+        # resent batch reaches its remaining views within the bulk time budget.
+        self._validate_uniqueness(name, layer_uuid, configuration)
         try:
             view: SemanticViewABC | None = 
layer.implementation.get_semantic_view(
                 name, configuration
@@ -151,6 +154,14 @@ class CreateSemanticViewCommand(BaseCommand):
                 )
             ) from None
 
+        # Check again after the lookup to keep the check-to-insert window 
short.
+        # Best effort: under REPEATABLE READ this sees the transaction's 
snapshot.
+        self._validate_uniqueness(name, layer_uuid, configuration)
+
+    @staticmethod
+    def _validate_uniqueness(
+        name: str, layer_uuid: str, configuration: dict[str, Any]
+    ) -> None:
         if not SemanticViewDAO.validate_uniqueness(name, layer_uuid, 
configuration):
             raise ValueError(
                 f"Semantic view '{name}' already exists for this layer"
diff --git a/superset/config.py b/superset/config.py
index 723c366e4c7..8a10b09ee65 100644
--- a/superset/config.py
+++ b/superset/config.py
@@ -3711,6 +3711,19 @@ SEMANTIC_CACHE_COORDINATION_WAIT_SECONDS: float = 1.0
 # Expiry for owner-token semantic-cache descriptor leases.
 SEMANTIC_CACHE_COORDINATION_LEASE_SECONDS: int = 30
 
+# Maximum number of views accepted by one bulk semantic view creation request.
+# Each view costs one semantic layer lookup; larger batches are refused with a 
400
+# before any lookup runs.
+SEMANTIC_VIEW_BULK_CREATE_MAX_VIEWS: int = 100
+
+# Time budget, in seconds, for validating the views of one bulk creation 
request.
+# The budget is checked before each view's lookup: views not started in time 
fail
+# with a "TIMEOUT" error and can be retried. A lookup already in progress is 
bounded
+# only by the semantic layer's own timeouts, so keep this well below
+# SUPERSET_WEBSERVER_TIMEOUT and any proxy timeout, leaving room for that 
lookup;
+# otherwise the client receives a gateway timeout instead of the per-view 
results.
+SEMANTIC_VIEW_BULK_CREATE_TIMEOUT: float = 30
+
 # Default lock TTL (time-to-live) in seconds for distributed locks.
 # Can be overridden per-call via the `ttl_seconds` parameter.
 # After TTL expires, the lock is automatically released to prevent deadlocks.
diff --git a/superset/semantic_layers/api.py b/superset/semantic_layers/api.py
index 791cf328963..165bbf4126d 100644
--- a/superset/semantic_layers/api.py
+++ b/superset/semantic_layers/api.py
@@ -17,9 +17,10 @@
 from __future__ import annotations
 
 import logging
+import time
 from typing import Any
 
-from flask import Blueprint, make_response, request, Response
+from flask import Blueprint, current_app, make_response, request, Response
 from flask_appbuilder import AppBuilder
 from flask_appbuilder.api import BaseApi, expose, protect, rison, safe
 from flask_appbuilder.api.schemas import get_list_schema
@@ -88,6 +89,37 @@ from superset.views.base_api import (
 logger = logging.getLogger(__name__)
 
 
+def _bulk_create_refusal(views_data: Any) -> str | None:
+    """Return why a bulk creation batch is refused before any lookup, if it 
is."""
+    if not isinstance(views_data, list) or not all(
+        isinstance(view, dict) for view in views_data
+    ):
+        return t("Views must be a list of objects")
+    if not views_data:
+        return t("No views provided")
+    max_views = current_app.config["SEMANTIC_VIEW_BULK_CREATE_MAX_VIEWS"]
+    if len(views_data) > max_views:
+        return t(
+            "Too many views in one request: %(count)s provided, "
+            "at most %(limit)s allowed.",
+            count=len(views_data),
+            limit=max_views,
+        )
+    return None
+
+
+def _log_bulk_create_timeouts(timed_out: int, total: int) -> None:
+    """Tell operators when a bulk creation request ran out of its time 
budget."""
+    if timed_out:
+        logger.warning(
+            "Semantic view bulk creation ran out of its %ss budget; "
+            "%d of %d views were not checked",
+            current_app.config["SEMANTIC_VIEW_BULK_CREATE_TIMEOUT"],
+            timed_out,
+            total,
+        )
+
+
 def _refuse_disabled_semantic_layers() -> FlaskResponse | None:
     """Refuse semantic API dispatch before authentication or handler side 
effects."""
     if not is_semantic_layers_enabled():
@@ -351,6 +383,10 @@ class SemanticViewRestApi(_RuntimeGuardedApiMixin, 
BaseSupersetModelRestApi):
                   properties:
                     views:
                       type: array
+                      description: >-
+                        At most SEMANTIC_VIEW_BULK_CREATE_MAX_VIEWS views. 
Views
+                        not validated within SEMANTIC_VIEW_BULK_CREATE_TIMEOUT
+                        seconds are reported with error_type TIMEOUT.
                       items:
                         type: object
                         properties:
@@ -379,13 +415,32 @@ class SemanticViewRestApi(_RuntimeGuardedApiMixin, 
BaseSupersetModelRestApi):
 
         body = request.json or {}
         views_data = body.get("views", [])
-        if not views_data:
-            return self.response_400(message="No views provided")
+        if refusal := _bulk_create_refusal(views_data):
+            return self.response_400(message=refusal)
 
         schema = SemanticViewPostSchema()
         created = []
         errors = []
+        # Checked between lookups only: an in-flight provider call is bounded 
by
+        # the semantic layer's own timeouts.
+        deadline = (
+            time.monotonic() + 
current_app.config["SEMANTIC_VIEW_BULK_CREATE_TIMEOUT"]
+        )
+        timed_out = 0
         for view_data in views_data:
+            if time.monotonic() >= deadline:
+                timed_out += 1
+                errors.append(
+                    {
+                        "name": view_data.get("name"),
+                        "error": t(
+                            "Semantic view validation timed out before this 
view "
+                            "was checked. Retry it in a separate request."
+                        ),
+                        "error_type": "TIMEOUT",
+                    }
+                )
+                continue
             try:
                 item = schema.load(view_data)
             except ValidationError as error:
@@ -408,6 +463,8 @@ class SemanticViewRestApi(_RuntimeGuardedApiMixin, 
BaseSupersetModelRestApi):
                 )
                 errors.append({"name": view_data.get("name"), "error": 
str(ex)})
 
+        _log_bulk_create_timeouts(timed_out, len(views_data))
+
         result: dict[str, Any] = {"created": created}
         if errors:
             result["errors"] = errors
diff --git a/superset/translations/messages.pot 
b/superset/translations/messages.pot
index 7b3e7ed7403..fad4710ba14 100644
--- a/superset/translations/messages.pot
+++ b/superset/translations/messages.pot
@@ -18294,3 +18294,20 @@ msgstr ""
 
 msgid "The view configuration must be a JSON object."
 msgstr ""
+
+msgid "Views must be a list of objects"
+msgstr ""
+
+msgid "No views provided"
+msgstr ""
+
+#, python-format
+msgid ""
+"Too many views in one request: %(count)s provided, at most %(limit)s "
+"allowed."
+msgstr ""
+
+msgid ""
+"Semantic view validation timed out before this view was checked. Retry it"
+" in a separate request."
+msgstr ""
diff --git a/tests/unit_tests/commands/semantic_layer/create_test.py 
b/tests/unit_tests/commands/semantic_layer/create_test.py
index ab2636ee9e1..95ac64be889 100644
--- a/tests/unit_tests/commands/semantic_layer/create_test.py
+++ b/tests/unit_tests/commands/semantic_layer/create_test.py
@@ -224,8 +224,9 @@ def test_create_semantic_view_success(mocker: 
MockerFixture) -> None:
     ).run()
 
     assert result == mock_model
-    dao_view.validate_uniqueness.assert_called_once_with(
-        "orders", "layer-uuid", {"db": "prod"}
+    assert (
+        dao_view.validate_uniqueness.call_args_list
+        == [mocker.call("orders", "layer-uuid", {"db": "prod"})] * 2
     )
 
 
@@ -344,6 +345,9 @@ def test_create_semantic_view_duplicate(mocker: 
MockerFixture) -> None:
             }
         ).run()
 
+    mock_layer.implementation.get_semantic_view.assert_not_called()
+    dao_view.create.assert_not_called()
+
 
 def test_create_semantic_view_forbidden(mocker: MockerFixture) -> None:
     """Test CreateSemanticViewCommand raises when the caller may not modify
@@ -456,10 +460,10 @@ def 
test_create_semantic_view_non_creator_non_editor_forbidden(
 
 
 @pytest.mark.parametrize("configuration", [{"db": "prod"}, '{"db": "prod"}', 
{}])
-def test_create_semantic_view_lookup_before_uniqueness(
+def test_create_semantic_view_uniqueness_around_lookup(
     mocker: MockerFixture, configuration: dict[str, Any] | str
 ) -> None:
-    """Use persisted-view lookup semantics before checking uniqueness."""
+    """Check uniqueness before the lookup and again just before the insert."""
     from superset.commands.semantic_layer.create import 
CreateSemanticViewCommand
 
     layer: MagicMock = MagicMock()
@@ -490,13 +494,50 @@ def test_create_semantic_view_lookup_before_uniqueness(
         ).run()
         is dao.create.return_value
     )
-    assert [entry[0] for entry in calls.mock_calls] == ["lookup", "unique", 
"create"]
+    assert [entry[0] for entry in calls.mock_calls] == [
+        "unique",
+        "lookup",
+        "unique",
+        "create",
+    ]
     layer.implementation.get_semantic_view.assert_called_once_with(
         "orders", {} if configuration == {} else {"db": "prod"}
     )
     layer.implementation.get_semantic_views.assert_not_called()
 
 
+def test_create_semantic_view_duplicate_added_during_lookup(
+    mocker: MockerFixture,
+) -> None:
+    """A view added by another request during the lookup is refused, not 
inserted."""
+    from superset.commands.semantic_layer.create import 
CreateSemanticViewCommand
+    from superset.commands.semantic_layer.exceptions import (
+        SemanticViewCreateFailedError,
+    )
+
+    layer: MagicMock = MagicMock()
+    mocker.patch(
+        
"superset.commands.semantic_layer.create.SemanticLayerDAO.find_by_uuid",
+        return_value=layer,
+    )
+    mocker.patch(
+        
"superset.commands.semantic_layer.create.current_user_can_modify_object",
+        return_value=True,
+    )
+    dao: MagicMock = mocker.patch(
+        "superset.commands.semantic_layer.create.SemanticViewDAO"
+    )
+    dao.validate_uniqueness.side_effect = [True, False]
+
+    with pytest.raises(SemanticViewCreateFailedError):
+        CreateSemanticViewCommand(
+            {"name": "orders", "semantic_layer_uuid": "layer-uuid"}
+        ).run()
+
+    layer.implementation.get_semantic_view.assert_called_once_with("orders", 
{})
+    dao.create.assert_not_called()
+
+
 @pytest.mark.parametrize(
     "failure",
     [None, ValueError("secret"), ConnectionError("secret"), 
RuntimeError("secret")],
@@ -548,7 +589,7 @@ def test_create_semantic_view_lookup_failure(
             "Semantic view lookup produced no view for layer layer-uuid, view 
orders"
         ]
         assert caplog.records[0].levelno == logging.INFO
-    dao.validate_uniqueness.assert_not_called()
+    dao.validate_uniqueness.assert_called_once()
     dao.create.assert_not_called()
 
 
diff --git a/tests/unit_tests/semantic_layers/api_test.py 
b/tests/unit_tests/semantic_layers/api_test.py
index f86058071d7..a2389757258 100644
--- a/tests/unit_tests/semantic_layers/api_test.py
+++ b/tests/unit_tests/semantic_layers/api_test.py
@@ -2375,6 +2375,259 @@ def test_delete_semantic_view_forbidden(
     assert response.status_code == 403
 
 
+def _bulk_views(count: int) -> dict[str, list[dict[str, Any]]]:
+    layer_uuid = str(uuid_lib.uuid4())
+    return {
+        "views": [
+            {"name": f"view_{i}", "semantic_layer_uuid": layer_uuid}
+            for i in range(count)
+        ]
+    }
+
+
[email protected](
+    "app",
+    [
+        {
+            "FEATURE_FLAGS": {"SEMANTIC_LAYERS": True},
+            "SEMANTIC_VIEW_BULK_CREATE_MAX_VIEWS": 2,
+        }
+    ],
+    indirect=True,
+)
+def test_post_semantic_view_refuses_oversized_batch(
+    client: FlaskClient,
+    full_api_access: None,
+    mocker: MockerFixture,
+) -> None:
+    """A batch over the configured cap is refused before any provider 
lookup."""
+    mock_command = mocker.patch(
+        "superset.semantic_layers.api.CreateSemanticViewCommand",
+    )
+
+    response: TestResponse = client.post("/api/v1/semantic_view/", 
json=_bulk_views(3))
+
+    assert response.status_code == 400
+    assert response.json is not None
+    assert response.json["message"] == (
+        "Too many views in one request: 3 provided, at most 2 allowed."
+    )
+    mock_command.assert_not_called()
+
+
[email protected](
+    "app",
+    [
+        {
+            "FEATURE_FLAGS": {"SEMANTIC_LAYERS": True},
+            "SEMANTIC_VIEW_BULK_CREATE_MAX_VIEWS": 2,
+        }
+    ],
+    indirect=True,
+)
+def test_post_semantic_view_accepts_batch_at_cap(
+    client: FlaskClient,
+    full_api_access: None,
+    mocker: MockerFixture,
+) -> None:
+    """A batch exactly at the cap is processed."""
+    mock_command = mocker.patch(
+        "superset.semantic_layers.api.CreateSemanticViewCommand",
+    )
+    new_model = MagicMock()
+    new_model.uuid = uuid_lib.uuid4()
+    new_model.name = "view"
+    mock_command.return_value.run.return_value = new_model
+
+    response: TestResponse = client.post("/api/v1/semantic_view/", 
json=_bulk_views(2))
+
+    assert response.status_code == 201
+    assert mock_command.call_count == 2
+
+
+@SEMANTIC_LAYERS_APP
[email protected](
+    "payload",
+    [
+        {"views": {"name": "orders"}},
+        {"views": ["orders"]},
+        {"views": [{"name": "orders"}, 1]},
+    ],
+)
+def test_post_semantic_view_refuses_malformed_views(
+    client: FlaskClient,
+    full_api_access: None,
+    mocker: MockerFixture,
+    payload: Any,
+) -> None:
+    """``views`` must be a list of objects; anything else is refused 
unlooked-up."""
+    mock_command = mocker.patch(
+        "superset.semantic_layers.api.CreateSemanticViewCommand",
+    )
+
+    response: TestResponse = client.post("/api/v1/semantic_view/", 
json=payload)
+
+    assert response.status_code == 400
+    assert response.json is not None
+    assert response.json["message"] == "Views must be a list of objects"
+    mock_command.assert_not_called()
+
+
[email protected](
+    "app",
+    [
+        {
+            "FEATURE_FLAGS": {"SEMANTIC_LAYERS": True},
+            "SEMANTIC_VIEW_BULK_CREATE_TIMEOUT": 10,
+        }
+    ],
+    indirect=True,
+)
+def test_post_semantic_view_fails_remaining_views_after_deadline(
+    client: FlaskClient,
+    full_api_access: None,
+    mocker: MockerFixture,
+) -> None:
+    """Views not started before the deadline fail with a typed timeout 
error."""
+    clock = {"now": 0.0}
+    mock_time = mocker.patch("superset.semantic_layers.api.time")
+    mock_time.monotonic.side_effect = lambda: clock["now"]
+    new_model = MagicMock()
+    new_model.uuid = uuid_lib.uuid4()
+    new_model.name = "view_0"
+
+    def slow_lookup() -> MagicMock:
+        # Land exactly on the deadline: a view starting then is not looked up.
+        clock["now"] += 10.0
+        return new_model
+
+    mock_command = mocker.patch(
+        "superset.semantic_layers.api.CreateSemanticViewCommand",
+    )
+    mock_command.return_value.run.side_effect = slow_lookup
+    log_warning = mocker.patch("superset.semantic_layers.api.logger.warning")
+
+    response: TestResponse = client.post("/api/v1/semantic_view/", 
json=_bulk_views(3))
+
+    assert response.status_code == 201
+    assert response.json is not None
+    result = response.json["result"]
+    assert result["created"] == [{"uuid": str(new_model.uuid), "name": 
"view_0"}]
+    timeout_message = (
+        "Semantic view validation timed out before this view was checked. "
+        "Retry it in a separate request."
+    )
+    assert result["errors"] == [
+        {"name": "view_1", "error": timeout_message, "error_type": "TIMEOUT"},
+        {"name": "view_2", "error": timeout_message, "error_type": "TIMEOUT"},
+    ]
+    assert mock_command.call_count == 1
+    log_warning.assert_called_once()
+    assert log_warning.call_args.args[1:] == (10, 2, 3)
+
+
[email protected](
+    "app",
+    [
+        {
+            "FEATURE_FLAGS": {"SEMANTIC_LAYERS": True},
+            "SEMANTIC_VIEW_BULK_CREATE_TIMEOUT": 0,
+        }
+    ],
+    indirect=True,
+)
+def test_post_semantic_view_all_views_timed_out(
+    client: FlaskClient,
+    full_api_access: None,
+    mocker: MockerFixture,
+) -> None:
+    """With no budget left, no lookup runs and the batch is unprocessable."""
+    mock_command = mocker.patch(
+        "superset.semantic_layers.api.CreateSemanticViewCommand",
+    )
+
+    response: TestResponse = client.post("/api/v1/semantic_view/", 
json=_bulk_views(2))
+
+    assert response.status_code == 422
+    assert response.json is not None
+    errors = response.json["result"]["errors"]
+    assert [error["error_type"] for error in errors] == ["TIMEOUT", "TIMEOUT"]
+    mock_command.assert_not_called()
+
+
[email protected](
+    "app",
+    [
+        {
+            "FEATURE_FLAGS": {"SEMANTIC_LAYERS": True},
+            "SEMANTIC_VIEW_BULK_CREATE_TIMEOUT": 10,
+        }
+    ],
+    indirect=True,
+)
+def test_post_semantic_view_retry_after_timeout_adds_remaining_views(
+    client: FlaskClient,
+    full_api_access: None,
+    mocker: MockerFixture,
+) -> None:
+    """Resending a timed-out batch spends the budget only on views not yet 
added."""
+    clock = {"now": 0.0}
+    mock_time = mocker.patch("superset.semantic_layers.api.time")
+    mock_time.monotonic.side_effect = lambda: clock["now"]
+
+    def slow_lookup(name: str, configuration: dict[str, Any]) -> MagicMock:
+        clock["now"] += 6.0
+        return MagicMock()
+
+    layer: MagicMock = MagicMock()
+    layer.implementation.get_semantic_view.side_effect = slow_lookup
+    mocker.patch(
+        
"superset.commands.semantic_layer.create.SemanticLayerDAO.find_by_uuid",
+        return_value=layer,
+    )
+    mocker.patch(
+        
"superset.commands.semantic_layer.create.current_user_can_modify_object",
+        return_value=True,
+    )
+    existing: set[str] = set()
+    dao: MagicMock = mocker.patch(
+        "superset.commands.semantic_layer.create.SemanticViewDAO"
+    )
+    dao.validate_uniqueness.side_effect = lambda name, *_: name not in existing
+
+    def create(attributes: dict[str, Any]) -> MagicMock:
+        existing.add(attributes["name"])
+        model = MagicMock()
+        model.uuid = uuid_lib.uuid4()
+        model.name = attributes["name"]
+        return model
+
+    dao.create.side_effect = create
+    batch = _bulk_views(3)
+
+    first: TestResponse = client.post("/api/v1/semantic_view/", json=batch)
+
+    assert first.status_code == 201
+    assert first.json is not None
+    assert [view["name"] for view in first.json["result"]["created"]] == [
+        "view_0",
+        "view_1",
+    ]
+    assert [error["name"] for error in first.json["result"]["errors"]] == 
["view_2"]
+
+    retry: TestResponse = client.post("/api/v1/semantic_view/", json=batch)
+
+    assert retry.status_code == 201
+    assert retry.json is not None
+    assert [view["name"] for view in retry.json["result"]["created"]] == 
["view_2"]
+    assert retry.json["result"]["errors"] == [
+        {"name": name, "error": "Semantic view could not be created."}
+        for name in ("view_0", "view_1")
+    ]
+    assert existing == {"view_0", "view_1", "view_2"}
+    assert layer.implementation.get_semantic_view.call_count == 3
+
+
 # =============================================================================
 # SemanticViewRestApi.bulk_delete tests
 # =============================================================================
@@ -3370,6 +3623,9 @@ def test_post_semantic_view_provider_validation(
     assert "secret" not in response.get_data(as_text=True)
     assert "secret" not in caplog.text
     dao.create.assert_not_called()
-    layer.implementation.get_semantic_view.assert_called_once_with(
-        "orders", {"db": "prod"}
-    )
+    if unique:
+        layer.implementation.get_semantic_view.assert_called_once_with(
+            "orders", {"db": "prod"}
+        )
+    else:
+        layer.implementation.get_semantic_view.assert_not_called()

Reply via email to