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 a55b236a5be fix(semantic-layer): fetch view metadata once per query 
(#45104)
a55b236a5be is described below

commit a55b236a5be4fa4c4472afd964961aa44e8c7716
Author: Mike Bridge <[email protected]>
AuthorDate: Sat Oct 10 16:19:40 2026 -0600

    fix(semantic-layer): fetch view metadata once per query (#45104)
---
 .../src/superset_core/semantic_layers/view.py      |   7 +-
 superset-core/tests/semantic_layers/test_view.py   |  23 ++++
 superset/semantic_layers/mapper.py                 | 114 ++++++++++------
 tests/unit_tests/semantic_layers/mapper_test.py    | 144 ++++++++++++++++++++-
 4 files changed, 243 insertions(+), 45 deletions(-)

diff --git a/superset-core/src/superset_core/semantic_layers/view.py 
b/superset-core/src/superset_core/semantic_layers/view.py
index 3b8cc306e2e..71ab7890e1a 100644
--- a/superset-core/src/superset_core/semantic_layers/view.py
+++ b/superset-core/src/superset_core/semantic_layers/view.py
@@ -68,10 +68,9 @@ class SemanticView(ABC):
                 "automatically mapped to member IDs."
             )
 
-    # Implementations must expose a display name for the view.
-    # Declared here as a type annotation (not abstract) so that existing
-    # implementations are not required to add a formal @abstractmethod.
-    name: str
+    # Optional display name. Hosts may supply their own label when it is empty;
+    # identity always comes from uid(), never from this presentation field.
+    name: str = ""
 
     @property
     def metadata_cache_token(self) -> str | None:
diff --git a/superset-core/tests/semantic_layers/test_view.py 
b/superset-core/tests/semantic_layers/test_view.py
new file mode 100644
index 00000000000..abaf03015e2
--- /dev/null
+++ b/superset-core/tests/semantic_layers/test_view.py
@@ -0,0 +1,23 @@
+# 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 superset_core.semantic_layers.view import SemanticView
+
+
+def test_view_metadata_defaults_are_conservative() -> None:
+    assert SemanticView.features == frozenset()
+    assert SemanticView.name == ""
diff --git a/superset/semantic_layers/mapper.py 
b/superset/semantic_layers/mapper.py
index a1df1dea838..1c055630bb8 100644
--- a/superset/semantic_layers/mapper.py
+++ b/superset/semantic_layers/mapper.py
@@ -25,6 +25,7 @@ single dataframe.
 """
 
 import logging
+from dataclasses import dataclass
 from datetime import date, datetime, time, timedelta, tzinfo
 from time import time as current_time
 from typing import Any, cast, Sequence, TypeGuard
@@ -49,7 +50,7 @@ from superset_core.semantic_layers.types import (
     SemanticQuery,
     SemanticResult,
 )
-from superset_core.semantic_layers.view import SemanticViewFeature
+from superset_core.semantic_layers.view import SemanticView, 
SemanticViewFeature
 
 from superset.common.db_query_status import QueryStatus
 from superset.common.query_object import QueryObject
@@ -126,6 +127,18 @@ class ValidatedQueryObject(QueryObject):
     series_limit_metric: str | None
 
 
+@dataclass(frozen=True)
+class _ViewMetadata:
+    """One query's member observation, preserving provider iteration order."""
+
+    metrics: tuple[Metric, ...]
+    dimensions: tuple[Dimension, ...]
+
+    @classmethod
+    def from_view(cls, view: SemanticView) -> "_ViewMetadata":
+        return cls(tuple(view.get_metrics()), tuple(view.get_dimensions()))
+
+
 def get_results(query_object: QueryObject) -> QueryResult:
     """
     Run 1+ queries based on `QueryObject` and return the results.
@@ -133,8 +146,11 @@ def get_results(query_object: QueryObject) -> QueryResult:
     :param query_object: The QueryObject containing query specifications
     :return: QueryResult compatible with Superset's query interface
     """
-    if not validate_query_object(query_object):
+    if not query_object.datasource:
         raise ValueError("QueryObject must have a datasource defined.")
+    query_object = cast(ValidatedQueryObject, query_object)
+    metadata: _ViewMetadata = _validation_metadata(query_object)
+    _validate_query_object(query_object, metadata)
 
     # Track execution time
     start_time = current_time()
@@ -148,7 +164,7 @@ def get_results(query_object: QueryObject) -> QueryResult:
 
     # Step 1: Convert QueryObject to list of SemanticQuery objects
     # The first query is the main query, subsequent queries are for time 
offsets
-    queries = map_query_object(query_object)
+    queries: list[SemanticQuery] = _map_query_object(query_object, metadata)
 
     # Step 2: Execute the main query (first in the list)
     main_query = queries[0]
@@ -346,17 +362,19 @@ def _normalize_column(column: str | AdhocColumn, 
dimension_names: set[str]) -> s
 
 
 def map_query_object(query_object: ValidatedQueryObject) -> 
list[SemanticQuery]:
-    """
-    Convert a `QueryObject` into a list of `SemanticQuery`.
+    """Map a query using a fresh observation of the provider's metadata."""
+    metadata: _ViewMetadata = _ViewMetadata.from_view(
+        query_object.datasource.implementation
+    )
+    return _map_query_object(query_object, metadata)
 
-    This function maps the `QueryObject` into query objects that focus less on
-    visualization and more on semantics.
-    """
-    semantic_view = query_object.datasource.implementation
-    # The SemanticView ABC contract is the get_metrics()/get_dimensions()
-    # methods; never read undeclared attributes off the provider view.
-    view_metrics = semantic_view.get_metrics()
-    view_dimensions = semantic_view.get_dimensions()
+
+def _map_query_object(
+    query_object: ValidatedQueryObject, metadata: _ViewMetadata
+) -> list[SemanticQuery]:
+    """Use the same members for the main query and every time comparison."""
+    view_metrics: tuple[Metric, ...] = metadata.metrics
+    view_dimensions: tuple[Dimension, ...] = metadata.dimensions
 
     all_metrics = {metric.name: metric for metric in view_metrics}
     all_dimensions = {dimension.name: dimension for dimension in 
view_dimensions}
@@ -1127,41 +1145,54 @@ def validate_query_object(
     if not query_object.datasource:
         return False
 
-    query_object = cast(ValidatedQueryObject, query_object)
+    validated: ValidatedQueryObject = cast(ValidatedQueryObject, query_object)
+    metadata: _ViewMetadata = _validation_metadata(validated)
+    _validate_query_object(validated, metadata)
+    return True
+
 
-    query_object.datasource.implementation.validate_selection_version(
+def _validation_metadata(query_object: ValidatedQueryObject) -> _ViewMetadata:
+    """Check member identity before fetching metadata for validation."""
+    view: SemanticView = query_object.datasource.implementation
+    view.validate_selection_version(
         query_object.extras.get("semantic_selection_version")
     )
-    _validate_metrics(query_object)
-    _validate_dimensions(query_object)
-    _validate_filters(query_object)
-    _validate_granularity(query_object)
-    _validate_group_limit(query_object)
-    _validate_orderby(query_object)
+    return _ViewMetadata.from_view(view)
 
-    return True
 
+def _validate_query_object(
+    query_object: ValidatedQueryObject, metadata: _ViewMetadata
+) -> None:
+    _validate_metrics(query_object, metadata)
+    _validate_dimensions(query_object, metadata)
+    _validate_filters(query_object)
+    _validate_granularity(query_object, metadata)
+    _validate_group_limit(query_object, metadata)
+    _validate_orderby(query_object, metadata)
 
-def _validate_metrics(query_object: ValidatedQueryObject) -> None:
+
+def _validate_metrics(
+    query_object: ValidatedQueryObject, metadata: _ViewMetadata
+) -> None:
     """
     Make sure metrics are defined in the semantic view.
     """
-    semantic_view = query_object.datasource.implementation
 
     if any(not isinstance(metric, str) for metric in (query_object.metrics or 
[])):
         raise ValueError("Adhoc metrics are not supported in Semantic Views.")
 
-    metric_names = {metric.name for metric in semantic_view.get_metrics()}
+    metric_names: set[str] = {metric.name for metric in metadata.metrics}
     if not set(query_object.metrics or []) <= metric_names:
         raise ValueError("All metrics must be defined in the Semantic View.")
 
 
-def _validate_dimensions(query_object: ValidatedQueryObject) -> None:
+def _validate_dimensions(
+    query_object: ValidatedQueryObject, metadata: _ViewMetadata
+) -> None:
     """
     Make sure all dimensions are defined in the semantic view.
     """
-    semantic_view = query_object.datasource.implementation
-    dimension_names = {dimension.name for dimension in 
semantic_view.get_dimensions()}
+    dimension_names: set[str] = {dimension.name for dimension in 
metadata.dimensions}
 
     # Normalize all columns to dimension names
     normalized_columns = [
@@ -1185,12 +1216,13 @@ def _validate_filters(query_object: 
ValidatedQueryObject) -> None:
             raise ValueError("All filters must have an operator defined.")
 
 
-def _validate_granularity(query_object: ValidatedQueryObject) -> None:
+def _validate_granularity(
+    query_object: ValidatedQueryObject, metadata: _ViewMetadata
+) -> None:
     """
     Make sure time column and time grain are valid.
     """
-    semantic_view = query_object.datasource.implementation
-    view_dimensions = semantic_view.get_dimensions()
+    view_dimensions: tuple[Dimension, ...] = metadata.dimensions
     all_dimensions = {dimension.name: dimension for dimension in 
view_dimensions}
     dimension_names = set(all_dimensions.keys())
 
@@ -1220,7 +1252,9 @@ def _validate_granularity(query_object: 
ValidatedQueryObject) -> None:
             )
 
 
-def _validate_group_limit(query_object: ValidatedQueryObject) -> None:
+def _validate_group_limit(
+    query_object: ValidatedQueryObject, metadata: _ViewMetadata
+) -> None:
     """
     Validate group limit related features in the query object.
     """
@@ -1242,7 +1276,7 @@ def _validate_group_limit(query_object: 
ValidatedQueryObject) -> None:
     if any(not isinstance(col, str) for col in query_object.series_columns):
         raise ValueError("Adhoc dimensions are not supported in series 
columns.")
 
-    metric_names = {metric.name for metric in semantic_view.get_metrics()}
+    metric_names: set[str] = {metric.name for metric in metadata.metrics}
     if query_object.series_limit_metric and (
         not isinstance(query_object.series_limit_metric, str)
         or query_object.series_limit_metric not in metric_names
@@ -1251,7 +1285,7 @@ def _validate_group_limit(query_object: 
ValidatedQueryObject) -> None:
             "The series limit metric must be defined in the Semantic View."
         )
 
-    dimension_names = {dimension.name for dimension in 
semantic_view.get_dimensions()}
+    dimension_names: set[str] = {dimension.name for dimension in 
metadata.dimensions}
     if not set(query_object.series_columns) <= dimension_names:
         raise ValueError("All series columns must be defined in the Semantic 
View.")
 
@@ -1265,7 +1299,9 @@ def _validate_group_limit(query_object: 
ValidatedQueryObject) -> None:
         )
 
 
-def _validate_orderby(query_object: ValidatedQueryObject) -> None:
+def _validate_orderby(
+    query_object: ValidatedQueryObject, metadata: _ViewMetadata
+) -> None:
     """
     Validate order by elements in the query object.
     """
@@ -1280,8 +1316,10 @@ def _validate_orderby(query_object: 
ValidatedQueryObject) -> None:
             "Adhoc expressions in order by are not supported in this Semantic 
View."
         )
 
-    elements = {orderby[0] for orderby in query_object.orderby}
-    metric_names = {metric.name for metric in semantic_view.get_metrics()}
-    dimension_names = {dimension.name for dimension in 
semantic_view.get_dimensions()}
+    elements: set[str] = {
+        element for element, _ in query_object.orderby if isinstance(element, 
str)
+    }
+    metric_names: set[str] = {metric.name for metric in metadata.metrics}
+    dimension_names: set[str] = {dimension.name for dimension in 
metadata.dimensions}
     if not elements <= metric_names | dimension_names:
         raise ValueError("All order by elements must be defined in the 
Semantic View.")
diff --git a/tests/unit_tests/semantic_layers/mapper_test.py 
b/tests/unit_tests/semantic_layers/mapper_test.py
index 0a7af8aa11b..0aa63832fef 100644
--- a/tests/unit_tests/semantic_layers/mapper_test.py
+++ b/tests/unit_tests/semantic_layers/mapper_test.py
@@ -66,6 +66,7 @@ from superset.semantic_layers.mapper import (
     _validate_granularity,
     _validate_group_limit,
     _validate_metrics,
+    _ViewMetadata,
     get_results,
     map_query_object,
     validate_query_object,
@@ -2988,7 +2989,13 @@ def test_validate_metrics_adhoc_error(
     query_object.metrics = [{"label": "adhoc", "sqlExpression": "SUM(x)"}]
 
     with pytest.raises(ValueError, match="Adhoc metrics are not supported"):
-        _validate_metrics(query_object)
+        _validate_metrics(
+            query_object,
+            _ViewMetadata(
+                tuple(mock_datasource.implementation.get_metrics()),
+                tuple(mock_datasource.implementation.get_dimensions()),
+            ),
+        )
 
 
 def test_validate_filters_adhoc_column_error(
@@ -3159,6 +3166,32 @@ def 
test_validate_query_object_adhoc_orderby_not_supported_error(
         validate_query_object(query_object)
 
 
[email protected]("include_unknown_name", [False, True])
+def test_validate_query_object_supported_adhoc_orderby(
+    mock_datasource: MagicMock,
+    include_unknown_name: bool,
+) -> None:
+    """Accept supported expressions while still rejecting unknown named 
elements."""
+    mock_datasource.implementation.features |= frozenset(
+        {SemanticViewFeature.ADHOC_EXPRESSIONS_IN_ORDERBY}
+    )
+    query_object: ValidatedQueryObject = ValidatedQueryObject(
+        datasource=mock_datasource,
+        metrics=["total_sales"],
+        columns=["category"],
+        orderby=[
+            ({"label": "custom_order", "sqlExpression": "RAND()"}, True),
+            ("undefined_column" if include_unknown_name else "category", 
False),
+        ],
+    )
+
+    if include_unknown_name:
+        with pytest.raises(ValueError, match="All order by elements must be 
defined"):
+            validate_query_object(query_object)
+    else:
+        validate_query_object(query_object)
+
+
 def test_validate_query_object_orderby_undefined_element_error(
     mock_datasource: MagicMock,
 ) -> None:
@@ -3369,7 +3402,13 @@ def test_validate_granularity_valid(mocker: 
MockerFixture) -> None:
     query_object.extras = {"time_grain_sqla": "P1D"}
 
     # Should not raise any error - valid granularity with supported time grain
-    _validate_granularity(query_object)
+    _validate_granularity(
+        query_object,
+        _ViewMetadata(
+            tuple(mock_datasource.implementation.get_metrics()),
+            tuple(mock_datasource.implementation.get_dimensions()),
+        ),
+    )
 
 
 def test_validate_group_limit_valid(mocker: MockerFixture) -> None:
@@ -3399,7 +3438,13 @@ def test_validate_group_limit_valid(mocker: 
MockerFixture) -> None:
     query_object.group_others_when_limit_reached = True
 
     # Should not raise any error - all settings are valid
-    _validate_group_limit(query_object)
+    _validate_group_limit(
+        query_object,
+        _ViewMetadata(
+            tuple(mock_datasource.implementation.get_metrics()),
+            tuple(mock_datasource.implementation.get_dimensions()),
+        ),
+    )
 
 
 def test_get_filters_from_query_object_filter_returns_none(
@@ -4235,6 +4280,99 @@ def test_abc_only_provider_validates_and_maps(mocker: 
MockerFixture) -> None:
     assert {dim.name for dim in queries[0].dimensions} == {"category"}
 
 
[email protected]("entrypoint", ["results", "validate", "map"])
+def test_mapper_fetches_metadata_once(
+    mock_datasource: MagicMock, mocker: MockerFixture, entrypoint: str
+) -> None:
+    """Validation and execution use one observation of each metadata 
collection."""
+    provider: MockSemanticView = mock_datasource.implementation
+    metrics: MagicMock = mocker.spy(provider, "get_metrics")
+    dimensions: MagicMock = mocker.spy(provider, "get_dimensions")
+    mocker.patch.object(
+        provider,
+        "get_table",
+        create=True,
+        return_value=SemanticResult(
+            requests=[],
+            results=pa.table({"category": ["Books"], "total_sales": [10.0]}),
+        ),
+    )
+    query: ValidatedQueryObject = ValidatedQueryObject(
+        datasource=mock_datasource,
+        metrics=["total_sales"],
+        columns=["category"],
+        series_columns=["category"],
+        series_limit=2,
+        series_limit_metric="total_sales",
+        orderby=[("total_sales", False)],
+        row_limit=10,
+    )
+    if entrypoint == "results":
+        result: QueryResult = get_results(query)
+        assert result.df.to_dict("records") == [
+            {"category": "Books", "total_sales": 10.0}
+        ]
+    elif entrypoint == "validate":
+        assert validate_query_object(query)
+    else:
+        mapped: list[SemanticQuery] = map_query_object(query)
+        assert [metric.name for metric in mapped[0].metrics] == ["total_sales"]
+    assert (metrics.call_count, dimensions.call_count) == (1, 1)
+
+
+def test_execution_reuses_members_but_next_query_fetches_fresh_metadata(
+    mock_datasource: MagicMock, mocker: MockerFixture
+) -> None:
+    """A second observation cannot change mapping midway or leak to another 
query."""
+    provider: MockSemanticView = mock_datasource.implementation
+    original_metrics: set[Metric] = provider.get_metrics()
+    original_dimensions: set[Dimension] = provider.get_dimensions()
+    metrics: MagicMock = mocker.patch.object(
+        provider, "get_metrics", side_effect=[original_metrics, set()]
+    )
+    dimensions: MagicMock = mocker.patch.object(
+        provider, "get_dimensions", side_effect=[original_dimensions, set()]
+    )
+    execute: MagicMock = mocker.patch.object(
+        provider,
+        "get_table",
+        create=True,
+        return_value=SemanticResult(
+            requests=[],
+            results=pa.table({"category": ["Books"], "total_sales": [10.0]}),
+        ),
+    )
+    query: ValidatedQueryObject = ValidatedQueryObject(
+        datasource=mock_datasource, metrics=["total_sales"], 
columns=["category"]
+    )
+    result: QueryResult = get_results(query)
+    assert result.df.to_dict("records") == [{"category": "Books", 
"total_sales": 10.0}]
+    with pytest.raises(ValueError, match="All metrics must be defined"):
+        get_results(query)
+    assert (metrics.call_count, dimensions.call_count) == (2, 2)
+    execute.assert_called_once()
+
+
+def test_execution_rejects_stale_selection_before_metadata_fetch(
+    mock_datasource: MagicMock, mocker: MockerFixture
+) -> None:
+    provider: MockSemanticView = mock_datasource.implementation
+    metrics: MagicMock = mocker.spy(provider, "get_metrics")
+    dimensions: MagicMock = mocker.spy(provider, "get_dimensions")
+    mocker.patch.object(
+        provider,
+        "validate_selection_version",
+        side_effect=ValueError("stale selection"),
+    )
+    query: ValidatedQueryObject = ValidatedQueryObject(
+        datasource=mock_datasource, metrics=["total_sales"], 
columns=["category"]
+    )
+    with pytest.raises(ValueError, match="stale selection"):
+        get_results(query)
+    metrics.assert_not_called()
+    dimensions.assert_not_called()
+
+
 def test_required_comparison_completeness_failure_rejects_main_result(
     mock_datasource: MagicMock,
     mocker: MockerFixture,

Reply via email to