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