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 76b62d34fba fix(semantic-layer): preserve nullable integer precision 
in chart data (#45126)
76b62d34fba is described below

commit 76b62d34fba99ced85e1ae9949062757e9fa7e4e
Author: Mike Bridge <[email protected]>
AuthorDate: Sat Oct 10 16:21:30 2026 -0600

    fix(semantic-layer): preserve nullable integer precision in chart data 
(#45126)
---
 .../creating-your-first-dashboard.mdx              |  13 +
 superset/common/utils/dataframe_utils.py           |   8 +-
 superset/semantic_layers/mapper.py                 |  20 +-
 superset/semantic_layers/models.py                 |  17 +-
 .../utils/pandas_postprocessing/contribution.py    |  24 +-
 .../semantic_layers/integer_precision_test.py      | 366 +++++++++++++++++++++
 6 files changed, 441 insertions(+), 7 deletions(-)

diff --git a/docs/docs/using-superset/creating-your-first-dashboard.mdx 
b/docs/docs/using-superset/creating-your-first-dashboard.mdx
index 0f0e3449202..3000021ca18 100644
--- a/docs/docs/using-superset/creating-your-first-dashboard.mdx
+++ b/docs/docs/using-superset/creating-your-first-dashboard.mdx
@@ -165,6 +165,19 @@ You can also certify metrics if you'd like for your team 
in this view.
 - [Blog: Unlocking the Power of Virtual 
Datasets](https://preset.io/blog/unlocking-the-power-of-virtual-datasets-in-apache-superset/)
   :::
 
+### Integer precision in semantic views
+
+Semantic-view chart-data responses preserve signed 64-bit integer values even 
when the same
+column contains nulls, including time-comparison results. Integers outside
+JavaScript's safe integer range are returned as exact strings; nulls remain 
null.
+This applies to values supplied as integers by the semantic provider, not 
values
+already rounded to floating point.
+
+Chart calculations such as contribution, rolling windows or differences convert
+metric columns to floating-point numbers first, as they do for SQL datasets, so
+their results follow normal floating-point rounding. Previously cached chart
+results can retain rounded values until the query is refreshed.
+
 ### Native filters on semantic views
 
 When the `SEMANTIC_LAYERS` feature flag is enabled, Superset can connect to 
external semantic layers
diff --git a/superset/common/utils/dataframe_utils.py 
b/superset/common/utils/dataframe_utils.py
index d85fabc5ded..062b727cf01 100644
--- a/superset/common/utils/dataframe_utils.py
+++ b/superset/common/utils/dataframe_utils.py
@@ -17,6 +17,7 @@
 from __future__ import annotations
 
 import datetime
+from collections.abc import Collection
 from typing import Any, Literal, TYPE_CHECKING
 
 import numpy as np
@@ -61,8 +62,13 @@ def full_outer_join_df(
 
 def df_metrics_to_num(df: pd.DataFrame, query_object: QueryObject) -> None:
     """Converting metrics to numeric when pandas.read_sql cannot"""
+    df_columns_to_num(df, query_object.metric_names)
+
+
+def df_columns_to_num(df: pd.DataFrame, columns: Collection[str]) -> None:
+    """Soft-convert the named object columns to numeric, in place."""
     for col, dtype in df.dtypes.items():
-        if dtype.type == np.object_ and col in query_object.metric_names:
+        if dtype.type == np.object_ and col in columns:
             # soft-convert a metric column to numeric only if all
             # non-null values look numeric (e.g. ClickHouse returns
             # SUM() results as strings). Leaves truly non-numeric
diff --git a/superset/semantic_layers/mapper.py 
b/superset/semantic_layers/mapper.py
index 1c055630bb8..c99c8e392f0 100644
--- a/superset/semantic_layers/mapper.py
+++ b/superset/semantic_layers/mapper.py
@@ -172,7 +172,9 @@ def get_results(query_object: QueryObject) -> QueryResult:
         main_result = dispatcher(main_query)
     main_result = _coerce_empty_result(main_result, main_query)
 
-    main_df = stringify_extension_columns(main_result.results).to_pandas()
+    main_df = stringify_extension_columns(main_result.results).to_pandas(
+        integer_object_nulls=True,
+    )
 
     # Collect all requests (SQL queries, HTTP requests, etc.) for 
troubleshooting
     all_requests = list(main_result.requests)
@@ -208,7 +210,17 @@ def get_results(query_object: QueryObject) -> QueryResult:
         # Add this query's requests to the collection
         all_requests.extend(result.requests)
 
-        offset_df = stringify_extension_columns(result.results).to_pandas()
+        offset_df = stringify_extension_columns(result.results).to_pandas(
+            integer_object_nulls=True,
+        )
+        # A missing join match would upcast int64 offset metrics to float64.
+        offset_df = offset_df.astype(
+            {
+                metric: object
+                for metric in metric_names
+                if metric in offset_df and offset_df[metric].dtype.kind == "i"
+            }
+        )
 
         # Handle empty results - add NaN columns directly instead of merging
         # This avoids dtype mismatch issues with empty DataFrames
@@ -320,7 +332,9 @@ def map_semantic_result_to_query_result(
 
     return QueryResult(
         # Core data
-        df=stringify_extension_columns(semantic_result.results).to_pandas(),
+        df=stringify_extension_columns(semantic_result.results).to_pandas(
+            integer_object_nulls=True,
+        ),
         query=query_str,
         duration=duration,
         # Template filters - not applicable to semantic layers
diff --git a/superset/semantic_layers/models.py 
b/superset/semantic_layers/models.py
index d5afe5c4b80..15cc74c06f4 100644
--- a/superset/semantic_layers/models.py
+++ b/superset/semantic_layers/models.py
@@ -52,6 +52,7 @@ from superset_core.semantic_layers.view import (
 )
 
 from superset.common.query_object import QueryObject
+from superset.common.utils.dataframe_utils import df_columns_to_num
 from superset.exceptions import (
     InvalidPostProcessingError,
     QueryObjectValidationError,
@@ -65,7 +66,7 @@ from superset.semantic_layers.completeness import 
provider_completeness
 from superset.semantic_layers.mapper import get_results
 from superset.semantic_layers.registry import registry
 from superset.utils import json
-from superset.utils.core import GenericDataType
+from superset.utils.core import GenericDataType, TIME_COMPARISON
 
 if TYPE_CHECKING:
     from superset.superset_typing import ExplorableData, QueryObjectDict
@@ -389,6 +390,20 @@ class SemanticView(AuditMixinNullable, Model):
         security_manager.raise_for_unsupported_guest_rls(self)
         result: QueryResult = get_results(query_object)
         if query_object.post_processing and not result.df.empty:
+            # Nullable integer metrics arrive as exact Python ints; 
calculations
+            # need numbers, as for SQL datasets. Plain chart data stays exact.
+            metric_names: list[str] = query_object.metric_names
+            df_columns_to_num(
+                result.df,
+                {
+                    *metric_names,
+                    *(
+                        TIME_COMPARISON.join([metric, offset])
+                        for metric in metric_names
+                        for offset in query_object.time_offsets
+                    ),
+                },
+            )
             try:
                 result.df = query_object.exec_post_processing(result.df)
             except InvalidPostProcessingError as ex:
diff --git a/superset/utils/pandas_postprocessing/contribution.py 
b/superset/utils/pandas_postprocessing/contribution.py
index fc4a7425343..c4dbc56ec9f 100644
--- a/superset/utils/pandas_postprocessing/contribution.py
+++ b/superset/utils/pandas_postprocessing/contribution.py
@@ -17,10 +17,11 @@
 
 from __future__ import annotations
 
+from decimal import Decimal
 from typing import Any
 
 from flask_babel import gettext as _
-from pandas import DataFrame, MultiIndex
+from pandas import DataFrame, MultiIndex, Series
 from pandas.api.types import infer_dtype, is_bool_dtype, is_numeric_dtype
 
 from superset.exceptions import InvalidPostProcessingError
@@ -68,6 +69,23 @@ def _select_arithmetic_columns(df: DataFrame) -> DataFrame:
     ]
 
 
+def _align_total(total: Any, column: Series) -> Any:
+    """
+    Match a supplied contribution total to the column it divides.
+
+    Totals come from a separate query and can stay ``Decimal`` while the
+    column was converted to a numeric dtype; ``float`` and ``Decimal`` don't
+    divide, so such a total is converted to ``float``.
+
+    :param total: Total for the column, as supplied by the totals query.
+    :param column: Column the total divides.
+    :return: The total, converted to ``float`` when needed.
+    """
+    if isinstance(total, Decimal) and is_numeric_dtype(column.dtype):
+        return float(total)
+    return total
+
+
 @validate_column_args("columns")
 def contribution(
     df: DataFrame,
@@ -132,7 +150,9 @@ def contribution(
                 if total is None or total == 0:
                     contribution_df[rename_col] = 0
                 else:
-                    contribution_df[rename_col] = numeric_df_view[col] / total
+                    contribution_df[rename_col] = numeric_df_view[col] / 
_align_total(
+                        total, numeric_df_view[col]
+                    )
         else:
             numeric_df_view = numeric_df_view / numeric_df_view.values.sum(
                 axis=0, keepdims=True
diff --git a/tests/unit_tests/semantic_layers/integer_precision_test.py 
b/tests/unit_tests/semantic_layers/integer_precision_test.py
new file mode 100644
index 00000000000..0402aa8959b
--- /dev/null
+++ b/tests/unit_tests/semantic_layers/integer_precision_test.py
@@ -0,0 +1,366 @@
+# 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.
+
+"""Preserve nullable integer values through semantic chart-data 
serialization."""
+
+from decimal import Decimal
+from unittest.mock import MagicMock
+
+import pyarrow as pa
+import pytest
+from flask.testing import FlaskClient
+from pytest_mock import MockerFixture
+from superset_core.semantic_layers.types import (
+    Dimension,
+    Metric,
+    SemanticQuery,
+    SemanticResult,
+)
+from superset_core.semantic_layers.view import SemanticView as ProviderView
+from werkzeug.test import TestResponse
+
+from superset.semantic_layers.models import SemanticLayer, SemanticView
+
+
[email protected]
+def semantic_layer(mocker: MockerFixture) -> SemanticLayer:
+    """Give chart-data views the registered layer required by cache keys."""
+    mocker.patch.dict(
+        "superset.semantic_layers.models.registry",
+        {"fixture": MagicMock(result_cache_version=None)},
+    )
+    return SemanticLayer(type="fixture")
+
+
[email protected]("value", [9007199254740993, -9007199254740993, 2**63 
- 1, 7])
[email protected](
+    ("with_offset", "missing_prior_category"),
+    [(False, False), (True, False), (True, True)],
+)
+def test_chart_data_preserves_nullable_integer_precision(
+    client: FlaskClient,
+    full_api_access: None,
+    mocker: MockerFixture,
+    semantic_layer: SemanticLayer,
+    value: int,
+    with_offset: bool,
+    missing_prior_category: bool,
+) -> None:
+    """Serialize exact main/offset integers and nulls through the real host 
path."""
+    provider: MagicMock = MagicMock(spec=ProviderView)
+    provider.features = frozenset()
+    provider.selection_identity_version = None
+    provider.uid.return_value = "integer-precision-view"
+    provider.get_dimensions.return_value = {
+        Dimension("category", "category", pa.string()),
+    }
+    provider.get_metrics.return_value = {
+        Metric("amount", "amount", pa.int64(), "SUM(amount)"),
+    }
+    offset_values: list[int | None] = (
+        [value] if missing_prior_category else [None, -value]
+    )
+    provider.get_table.side_effect = [
+        SemanticResult(
+            results=pa.table(
+                {
+                    "category": ["A", "B"],
+                    "amount": pa.array([value, None], type=pa.int64()),
+                }
+            ),
+            requests=[],
+        ),
+        SemanticResult(
+            results=pa.table(
+                {
+                    "category": ["A"] if missing_prior_category else ["A", 
"B"],
+                    "amount": pa.array(offset_values, type=pa.int64()),
+                }
+            ),
+            requests=[],
+        ),
+    ]
+    view: SemanticView = SemanticView(
+        id=7, name="Amounts", configuration="{}", semantic_layer=semantic_layer
+    )
+    view.__dict__["implementation"] = provider
+    mocker.patch(
+        "superset.common.query_context_factory.DatasourceDAO.get_datasource",
+        return_value=view,
+    )
+    mocker.patch("superset.common.query_context.QueryContext.raise_for_access")
+    mocker.patch("superset.security_manager.raise_for_unsupported_guest_rls")
+    mocker.patch(
+        
"superset.common.query_context_processor.QueryContextProcessor.get_cache_timeout",
+        return_value=-1,
+    )
+
+    response: TestResponse = client.post(
+        "/api/v1/chart/data",
+        json={
+            "datasource": {"id": 7, "type": "semantic_view"},
+            "queries": [
+                {
+                    "columns": ["category"],
+                    "metrics": ["amount"],
+                    "time_offsets": ["1 week ago"] if with_offset else [],
+                }
+            ],
+            "result_format": "json",
+            "result_type": "full",
+            "force": True,
+        },
+    )
+
+    assert response.status_code == 200, response.get_data(as_text=True)
+    expected: list[dict[str, str | int | None]] = [
+        {"category": "A", "amount": str(value) if abs(value) > 2**53 - 1 else 
value},
+        {"category": "B", "amount": None},
+    ]
+    if with_offset:
+        if missing_prior_category:
+            expected[0]["amount__1 week ago"] = (
+                str(value) if abs(value) > 2**53 - 1 else value
+            )
+            expected[1]["amount__1 week ago"] = None
+        else:
+            expected[0]["amount__1 week ago"] = None
+            expected[1]["amount__1 week ago"] = (
+                str(-value) if abs(value) > 2**53 - 1 else -value
+            )
+    assert response.get_json()["result"][0]["data"] == expected
+    assert provider.get_table.call_count == (2 if with_offset else 1)
+    if abs(value) > 2**53 - 1:
+        # Check exact quoted wire values, not a numpy float/int equality 
comparison.
+        assert f'"{value}"' in response.get_data(as_text=True)
+        if with_offset:
+            offset_value: int = value if missing_prior_category else -value
+            assert f'"{offset_value}"' in response.get_data(as_text=True)
+
+
[email protected](
+    ("post_processing", "time_offsets", "expected"),
+    [
+        (
+            [
+                {
+                    "operation": "contribution",
+                    "options": {"orientation": "column", "columns": 
["amount"]},
+                }
+            ],
+            [],
+            {"amount": [0.25, 0.0, 0.75]},
+        ),
+        (
+            [
+                {
+                    "operation": "diff",
+                    "options": {"columns": {"amount": "amount"}, "periods": 1},
+                }
+            ],
+            [],
+            {"amount": [None, None, None]},
+        ),
+        (
+            [
+                {
+                    "operation": "contribution",
+                    "options": {
+                        "orientation": "column",
+                        "columns": ["amount__1 week ago"],
+                    },
+                }
+            ],
+            ["1 week ago"],
+            {"amount__1 week ago": [0.2, 0.0, 0.8]},
+        ),
+    ],
+    ids=["contribution", "diff", "contribution-offset"],
+)
+def test_post_processing_receives_numeric_nullable_integer_metrics(
+    client: FlaskClient,
+    full_api_access: None,
+    mocker: MockerFixture,
+    semantic_layer: SemanticLayer,
+    post_processing: list[dict[str, object]],
+    time_offsets: list[str],
+    expected: dict[str, list[float | None]],
+) -> None:
+    """Metric calculations see numbers, as for SQL datasets, not object 
integers."""
+    provider: MagicMock = MagicMock(spec=ProviderView)
+    provider.features = frozenset()
+    provider.selection_identity_version = None
+    provider.uid.return_value = "integer-precision-view"
+    provider.get_dimensions.return_value = {
+        Dimension("category", "category", pa.string()),
+    }
+    provider.get_metrics.return_value = {
+        Metric("amount", "amount", pa.int64(), "SUM(amount)"),
+    }
+    provider.get_table.side_effect = [
+        SemanticResult(
+            results=pa.table(
+                {
+                    "category": ["A", "B", "C"],
+                    "amount": pa.array([5, None, 15], type=pa.int64()),
+                }
+            ),
+            requests=[],
+        ),
+        SemanticResult(
+            results=pa.table(
+                {
+                    "category": ["A", "B", "C"],
+                    "amount": pa.array([2, None, 8], type=pa.int64()),
+                }
+            ),
+            requests=[],
+        ),
+    ]
+    view: SemanticView = SemanticView(
+        id=7, name="Amounts", configuration="{}", semantic_layer=semantic_layer
+    )
+    view.__dict__["implementation"] = provider
+    mocker.patch(
+        "superset.common.query_context_factory.DatasourceDAO.get_datasource",
+        return_value=view,
+    )
+    mocker.patch("superset.common.query_context.QueryContext.raise_for_access")
+    mocker.patch("superset.security_manager.raise_for_unsupported_guest_rls")
+    mocker.patch(
+        
"superset.common.query_context_processor.QueryContextProcessor.get_cache_timeout",
+        return_value=-1,
+    )
+
+    response: TestResponse = client.post(
+        "/api/v1/chart/data",
+        json={
+            "datasource": {"id": 7, "type": "semantic_view"},
+            "queries": [
+                {
+                    "columns": ["category"],
+                    "metrics": ["amount"],
+                    "time_offsets": time_offsets,
+                    "post_processing": post_processing,
+                }
+            ],
+            "result_format": "json",
+            "result_type": "full",
+            "force": True,
+        },
+    )
+
+    assert response.status_code == 200, response.get_data(as_text=True)
+    data: list[dict[str, object]] = response.get_json()["result"][0]["data"]
+    for column, values in expected.items():
+        assert [row[column] for row in data] == values
+
+
[email protected](
+    ("dtype", "values"),
+    [
+        (
+            pa.decimal128(12, 2),
+            [Decimal("5.25"), None, Decimal("15.75"), Decimal("26.25")],
+        ),
+        (pa.int64(), [5, None, 15, 27]),
+        (pa.float64(), [5.25, None, 15.75, 26.25]),
+    ],
+    ids=["decimal", "int", "float"],
+)
+def test_contribution_with_separate_totals_query(
+    client: FlaskClient,
+    full_api_access: None,
+    mocker: MockerFixture,
+    semantic_layer: SemanticLayer,
+    dtype: pa.DataType,
+    values: list[Decimal | int | float | None],
+) -> None:
+    """Contribution divides by totals from a second query of the same 
metric."""
+    provider: MagicMock = MagicMock(spec=ProviderView)
+    provider.features = frozenset()
+    provider.selection_identity_version = None
+    provider.uid.return_value = "contribution-totals-view"
+    provider.get_dimensions.return_value = {
+        Dimension("category", "category", pa.string()),
+    }
+    provider.get_metrics.return_value = {
+        Metric("amount", "amount", dtype, "SUM(amount)"),
+    }
+    total: Decimal | int | float = sum(value for value in values if value is 
not None)
+    main_result: SemanticResult = SemanticResult(
+        results=pa.table(
+            {
+                "category": ["A", "B", "C", "D"],
+                "amount": pa.array(values, type=dtype),
+            }
+        ),
+        requests=[],
+    )
+    totals_result: SemanticResult = SemanticResult(
+        results=pa.table({"amount": pa.array([total], type=dtype)}),
+        requests=[],
+    )
+
+    def get_table(query: SemanticQuery) -> SemanticResult:
+        return main_result if query.dimensions else totals_result
+
+    provider.get_table.side_effect = get_table
+    view: SemanticView = SemanticView(
+        id=7, name="Amounts", configuration="{}", semantic_layer=semantic_layer
+    )
+    view.__dict__["implementation"] = provider
+    mocker.patch(
+        "superset.common.query_context_factory.DatasourceDAO.get_datasource",
+        return_value=view,
+    )
+    mocker.patch("superset.common.query_context.QueryContext.raise_for_access")
+    mocker.patch("superset.security_manager.raise_for_unsupported_guest_rls")
+    mocker.patch(
+        
"superset.common.query_context_processor.QueryContextProcessor.get_cache_timeout",
+        return_value=-1,
+    )
+
+    response: TestResponse = client.post(
+        "/api/v1/chart/data",
+        json={
+            "datasource": {"id": 7, "type": "semantic_view"},
+            "queries": [
+                {
+                    "columns": ["category"],
+                    "metrics": ["amount"],
+                    "post_processing": [
+                        {
+                            "operation": "contribution",
+                            "options": {"orientation": "column", "columns": 
["amount"]},
+                        }
+                    ],
+                },
+                {"columns": [], "metrics": ["amount"], "post_processing": []},
+            ],
+            "result_format": "json",
+            "result_type": "full",
+            "force": True,
+        },
+    )
+
+    assert response.status_code == 200, response.get_data(as_text=True)
+    data: list[dict[str, object]] = response.get_json()["result"][0]["data"]
+    expected: list[float] = [
+        0.0 if value is None else float(value) / float(total) for value in 
values
+    ]
+    assert [row["amount"] for row in data] == pytest.approx(expected)

Reply via email to