Files
superset/tests/unit_tests/semantic_layers/selection_identity_test.py

294 lines
11 KiB
Python

# 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
from typing import Any
from unittest.mock import MagicMock, patch, PropertyMock
import pyarrow as pa
import pytest
from superset_core.semantic_layers.types import (
Dimension,
Filter,
Metric,
SemanticQuery,
SemanticResult,
)
from superset_core.semantic_layers.view import SemanticView, SemanticViewFeature
from superset.charts.schemas import ChartDataExtrasSchema
from superset.common.query_context_processor import QueryContextProcessor
from superset.common.query_object import QueryObject
from superset.exceptions import QueryObjectValidationError
from superset.semantic_layers.mapper import map_query_object, validate_query_object
from superset.utils import json
class IdentityView(SemanticView):
name: str = "Orders"
features: frozenset[SemanticViewFeature] = frozenset()
selection_identity_version: str | None = "cube-member-id-v1"
def __init__(self) -> None:
self.metrics: set[Metric] = {
Metric(
id="Orders.b",
name="Orders.b",
type=pa.float64(),
definition="Orders.b",
verbose_name="Total",
)
}
self.dimensions: set[Dimension] = set()
def get_compatible_metrics(
self, selected_metrics: set[Metric], selected_dimensions: set[Dimension]
) -> set[Metric]:
return self.metrics
def get_compatible_dimensions(
self, selected_metrics: set[Metric], selected_dimensions: set[Dimension]
) -> set[Dimension]:
return self.dimensions
def uid(self) -> str:
return "identity-test"
def get_metrics(self) -> set[Metric]:
return self.metrics
def get_dimensions(self) -> set[Dimension]:
return self.dimensions
def get_values(
self, dimension: Dimension, filters: set[Filter] | None = None
) -> SemanticResult:
raise AssertionError("legacy request must not execute")
def get_table(self, query: SemanticQuery) -> SemanticResult:
raise AssertionError("legacy request must not execute")
def get_row_count(self, query: SemanticQuery) -> SemanticResult:
raise AssertionError("legacy request must not execute")
@pytest.mark.parametrize("version", [None, "old", True, 1])
def test_legacy_id_looking_title_rejected_before_cache_and_mapper(
version: object,
) -> None:
datasource: MagicMock = MagicMock(
type="semantic_view", implementation=IdentityView()
)
query: QueryObject = QueryObject(
datasource=datasource,
metrics=["Orders.b"],
extras={"semantic_selection_version": version},
)
processor: QueryContextProcessor = QueryContextProcessor(MagicMock())
cache: MagicMock
with patch(
"superset.common.query_context_processor.QueryCacheManager.get"
) as cache:
with pytest.raises(QueryObjectValidationError, match="explicitly reselect"):
processor.get_df_payload_result(query, force_cached=True)
cache.assert_not_called()
with pytest.raises(ValueError, match="explicitly reselect"):
validate_query_object(query)
def test_current_version_reaches_semantic_query_and_other_providers_opt_out() -> None:
implementation: IdentityView = IdentityView()
datasource: MagicMock = MagicMock(
type="semantic_view", implementation=implementation, fetch_values_predicate=None
)
query: QueryObject = QueryObject(
datasource=datasource,
metrics=["Orders.b"],
extras={"semantic_selection_version": "cube-member-id-v1"},
series_limit=0,
)
assert query.validate() is None
assert validate_query_object(query)
mapped: list[SemanticQuery] = map_query_object(query)
assert mapped[0].metrics[0].id == "Orders.b"
assert mapped[0].selection_identity_version == "cube-member-id-v1"
implementation.selection_identity_version = None
query.extras = {}
assert query.validate() is None
assert validate_query_object(query)
def test_query_extras_schema_preserves_saved_version_without_upgrading_legacy() -> None:
"""Keep the explicit marker across JSON and schema deserialization."""
schema: ChartDataExtrasSchema = ChartDataExtrasSchema()
saved: str = json.dumps({"semantic_selection_version": "cube-member-id-v1"})
assert schema.load(json.loads(saved))["semantic_selection_version"] == (
"cube-member-id-v1"
)
assert "semantic_selection_version" not in schema.load({})
def test_incompatible_overlay_reports_filter_scope_remediation() -> None:
"""A chart reset alone cannot repair an incompatible dashboard overlay."""
query: QueryObject = QueryObject(
datasource=MagicMock(type="semantic_view", implementation=IdentityView()),
metrics=["Orders.b"],
extras={"semantic_selection_version": "unverified-external-selections"},
)
with pytest.raises(
QueryObjectValidationError,
match="Dynamic group-by is unsupported.*remove this chart from its scope",
):
query.validate()
@pytest.mark.parametrize("entrypoint", ["rest", "mcp"])
@pytest.mark.parametrize("version", [None, "cube-member-id-v1"])
def test_name_based_entrypoints_preserve_explicit_identity_version(
entrypoint: str, version: str | None
) -> None:
"""Exercise each wire schema and builder through the real validation/mapper."""
from superset.datasource.api import DatasourceRestApi
from superset.datasource.schemas import DatasourceQuerySchema
from superset.mcp_service.semantic_layer.schemas import GetTableRequest
from superset.mcp_service.semantic_layer.tool.get_table import _build_query_dict
datasource: MagicMock = MagicMock(
type="semantic_view", implementation=IdentityView(), fetch_values_predicate=None
)
wire: dict[str, Any] = {
"metrics": ["Orders.b"],
"semantic_selection_version": version,
}
query_dict: dict[str, Any]
if entrypoint == "rest":
payload: dict[str, Any] = DatasourceQuerySchema().load(wire)
resolved: MagicMock = MagicMock(explorable=datasource, time_column=None)
query_dict = DatasourceRestApi._build_query_dict(resolved, payload, None)
else:
query_dict = _build_query_dict(GetTableRequest(view_id=7, **wire), None)
query: QueryObject = QueryObject(
datasource=datasource, series_limit=0, **query_dict
)
if version is None:
assert "semantic_selection_version" not in query_dict.get("extras", {})
processor: QueryContextProcessor = QueryContextProcessor(MagicMock())
cache: MagicMock
with patch(
"superset.common.query_context_processor.QueryCacheManager.get"
) as cache:
with pytest.raises(QueryObjectValidationError, match="explicitly reselect"):
processor.get_df_payload_result(query, force_cached=True)
cache.assert_not_called()
else:
assert query.validate() is None
assert validate_query_object(query)
mapped: list[SemanticQuery] = map_query_object(query)
assert mapped[0].selection_identity_version == version
assert mapped[0].metrics[0].id == "Orders.b"
def test_name_based_version_coexists_with_time_grain_without_auto_upgrade() -> None:
from superset.common.tabular_query import build_query_dict
query: dict[str, Any] = build_query_dict(
metrics=["Orders.b"],
time_grain="P1D",
semantic_selection_version="cube-member-id-v1",
)
assert query["extras"] == {
"time_grain_sqla": "P1D",
"semantic_selection_version": "cube-member-id-v1",
}
assert build_query_dict(metrics=["Orders.b"], time_grain="P1D")["extras"] == {
"time_grain_sqla": "P1D"
}
def test_malformed_configuration_is_not_a_selection_version_error() -> None:
datasource: MagicMock = MagicMock(type="semantic_view")
type(datasource).implementation = PropertyMock(
side_effect=json.JSONDecodeError("private configuration", "{", 1)
)
query: QueryObject = QueryObject(
datasource=datasource,
metrics=["Orders.b"],
extras={"semantic_selection_version": "cube-member-id-v1"},
)
with pytest.raises(json.JSONDecodeError):
query.validate()
@pytest.mark.parametrize(
"datasource_type,version",
[
("semantic_view", "cube-member-id-v1"),
("semantic_view", None),
("table", None),
],
)
def test_column_suggestions_version_gate_precedes_cache(
datasource_type: str,
version: str | None,
) -> None:
from inspect import unwrap
from types import MethodType
from flask import Flask
from superset.datasource.api import DatasourceRestApi
app: Flask = Flask(__name__)
app.config["FILTER_SELECT_ROW_LIMIT"] = 100
app.config["SQL_MAX_ROW"] = 1000
datasource: MagicMock = MagicMock(
type=datasource_type, uid="1", normalize_columns=False, changed_on="fixed"
)
datasource.implementation.selection_identity_version = version
api: MagicMock = MagicMock()
api._column_values_response = MethodType(
DatasourceRestApi._column_values_response, api
)
cache: MagicMock
with (
app.test_request_context(),
patch(
"superset.datasource.api.DatasourceDAO.get_datasource",
return_value=datasource,
),
patch("superset.datasource.api.cache_manager") as cache,
patch(
"superset.datasource.api.security_manager.get_rls_cache_key",
return_value=[],
),
):
cache.data_cache.get.return_value = ["cached"]
unwrap(DatasourceRestApi.get_column_values)(api, datasource_type, 1, "Orders.b")
datasource.raise_for_access.assert_called_once()
if version:
cache.data_cache.get.assert_not_called()
datasource.values_for_column.assert_not_called()
api.response.assert_called_once_with(
200,
result=[],
suggestions_status="unavailable_versioned_view",
)
else:
cache.data_cache.get.assert_called_once()
assert api.response.call_args.kwargs["result"] == ["cached"]