This is an automated email from the ASF dual-hosted git repository.
sadpandajoe 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 3b7001836c7 fix(charts): keep large nullable-integer dimensions exact
(#45308)
3b7001836c7 is described below
commit 3b7001836c786a5a668e9424471150d091f7752f
Author: Joe Li <[email protected]>
AuthorDate: Thu Oct 22 15:14:38 2026 -0700
fix(charts): keep large nullable-integer dimensions exact (#45308)
Co-authored-by: Claude <[email protected]>
---
UPDATING.md | 9 +
superset/common/utils/dataframe_utils.py | 64 ++
superset/models/helpers.py | 21 +
superset/utils/excel.py | 41 +-
tests/unit_tests/charts/test_client_processing.py | 41 +
.../charts/test_exact_integer_chart_data.py | 824 +++++++++++++++++++++
tests/unit_tests/common/test_dataframe_utils.py | 53 ++
.../unit_tests/common/test_query_cache_manager.py | 38 +
.../common/test_query_context_processor.py | 50 ++
tests/unit_tests/datasource/test_query_api.py | 24 +
.../models/normalize_df_large_integer_test.py | 307 ++++++++
tests/unit_tests/utils/csv_tests.py | 14 +
tests/unit_tests/utils/excel_tests.py | 38 +
13 files changed, 1513 insertions(+), 11 deletions(-)
diff --git a/UPDATING.md b/UPDATING.md
index d9819c4720c..5342a2faaa1 100644
--- a/UPDATING.md
+++ b/UPDATING.md
@@ -24,6 +24,15 @@ assists people when migrating to a new version.
## Next
+- **Large nullable-integer dimensions stay exact in chart data.** In a plain
chart
+ query (no post-processing or time offsets, and not a report's
client-processed
+ result), a dimension column of nullable integers that holds a value beyond
+ `2**53 - 1` is no longer rounded to a float. JSON responses carry such
values as
+ strings (the existing contract for large integers), and CSV, Excel and Arrow
+ exports carry the exact integer. Consumers that parsed these values as JSON
+ numbers receive a string for them. Metric columns are unchanged, and results
+ cached before the upgrade keep the old values until they expire.
+
- **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,
diff --git a/superset/common/utils/dataframe_utils.py
b/superset/common/utils/dataframe_utils.py
index 062b727cf01..3a4fb7d0ef4 100644
--- a/superset/common/utils/dataframe_utils.py
+++ b/superset/common/utils/dataframe_utils.py
@@ -22,6 +22,10 @@ from typing import Any, Literal, TYPE_CHECKING
import numpy as np
import pandas as pd
+from pandas.api.types import infer_dtype
+
+from superset.common.chart_data import ChartDataResultType
+from superset.utils.core import get_metric_names, JS_MAX_INTEGER
if TYPE_CHECKING:
from superset.common.query_object import QueryObject
@@ -78,6 +82,66 @@ def df_columns_to_num(df: pd.DataFrame, columns:
Collection[str]) -> None:
df[col] = converted
+INT64_RANGE = (-(2**63), 2**63 - 1)
+
+
+def can_keep_exact_integers(query_object: QueryObject) -> bool:
+ """
+ Whether the result of ``query_object`` may carry exact large integers.
+
+ Only plain chart queries qualify. Queries with Python-side post-processing
or
+ time offsets keep the float64 normalization (a precaution: that machinery
is
+ written for numeric columns), and so do requests that go through report
+ client processing (``POST_PROCESSED``), which formats the rebuilt frame on
+ numeric dtypes only. The decision reads only fields that are part of the
query
+ cache key (``post_processing``, ``time_offsets`` and the per-query
+ ``result_type``), so a cached frame always has the shape its key implies.
+ """
+ return not (
+ query_object.post_processing
+ or query_object.time_offsets
+ or query_object.result_type == ChartDataResultType.POST_PROCESSED
+ )
+
+
+def lossy_integer_columns(df: pd.DataFrame, query_object: QueryObject) ->
list[str]:
+ """
+ Dimension columns that ``infer_objects`` would round to float64.
+
+ These are object columns of Python integers and nulls (how
+ ``SupersetResultSet`` hands back a nullable BIGINT) holding a value beyond
+ ``JS_MAX_INTEGER`` where every value fits int64. Without nulls pandas
+ already yields an exact int64, and values outside int64 stay exact objects
+ or are left to the float64 normalization (the uint64-only range).
+
+ Only columns selected as ``columns`` and not named as a metric qualify,
using
+ the labels the dataframe carries (no verbose-name mapping): a saved metric
+ comes back under its raw ``metric_name``, and metrics-only queries have
none.
+ """
+ if not df.columns.is_unique:
+ return []
+ candidates = set(query_object.column_names) - set(
+ get_metric_names(query_object.metrics)
+ )
+ columns: list[str] = []
+ for col in df.columns:
+ series = df[col]
+ if (
+ col not in candidates
+ or series.dtype != object
+ or infer_dtype(series, skipna=True) != "integer"
+ or not series.isna().any()
+ ):
+ continue
+ present = [int(value) for value in series.dropna()]
+ low, high = min(present), max(present)
+ if (low < -JS_MAX_INTEGER or high > JS_MAX_INTEGER) and (
+ INT64_RANGE[0] <= low and high <= INT64_RANGE[1]
+ ):
+ columns.append(col)
+ return columns
+
+
def is_datetime_series(series: Any) -> bool:
if series is None or not isinstance(series, pd.Series):
return False
diff --git a/superset/models/helpers.py b/superset/models/helpers.py
index 1aac9ef742c..0613eb934d1 100644
--- a/superset/models/helpers.py
+++ b/superset/models/helpers.py
@@ -2721,6 +2721,8 @@ class ExploreMixin: # pylint:
disable=too-many-public-methods
df: pd.DataFrame,
query_object: QueryObject,
sql_shifted_temporal_labels: set[str] | None = None,
+ *,
+ keep_exact_integers: bool = False,
) -> pd.DataFrame:
"""
Normalize the dataframe by converting datetime columns and ensuring
@@ -2729,6 +2731,9 @@ class ExploreMixin: # pylint:
disable=too-many-public-methods
:param df: The dataframe to normalize
:param query_object: The query object with metadata about columns
:param sql_shifted_temporal_labels: labels already shifted in
generated SQL
+ :param keep_exact_integers: leave nullable integer dimension columns
holding
+ values beyond ``JS_MAX_INTEGER`` as exact Python integers instead
of
+ rounding them to float64; only for frames that are not processed
further
:return: Normalized dataframe
"""
sql_shifted_temporal_labels = sql_shifted_temporal_labels or set()
@@ -2789,9 +2794,22 @@ class ExploreMixin: # pylint:
disable=too-many-public-methods
if getattr(self, "enforce_numerical_metrics", True):
dataframe_utils.df_metrics_to_num(df, query_object)
+ # Nullable integer dimensions beyond JS_MAX_INTEGER are set aside and
put
+ # back below: ``infer_objects`` would round them to float64.
+ exact_columns = (
+ {
+ col: df[col]
+ for col in dataframe_utils.lossy_integer_columns(df,
query_object)
+ }
+ if keep_exact_integers
+ else {}
+ )
+
# ``mask`` + ``infer_objects`` rather than ``replace``, which emits a
# FutureWarning about deprecated silent downcasting on object columns.
df = df.mask(df.isin([np.inf, -np.inf])).infer_objects()
+ for col, series in exact_columns.items():
+ df[col] = series
return df
@@ -2820,6 +2838,9 @@ class ExploreMixin: # pylint:
disable=too-many-public-methods
df,
query_object,
result.sql_shifted_temporal_labels,
+ keep_exact_integers=dataframe_utils.can_keep_exact_integers(
+ query_object
+ ),
)
# Process time offsets if requested
diff --git a/superset/utils/excel.py b/superset/utils/excel.py
index 437cd6739dd..5270e458106 100644
--- a/superset/utils/excel.py
+++ b/superset/utils/excel.py
@@ -18,6 +18,7 @@ import io
from datetime import datetime
from typing import Any, Optional
+import numpy as np
import pandas as pd
from superset.utils.core import GenericDataType
@@ -196,6 +197,20 @@ def df_to_excel(
return output.getvalue()
+def _large_number_as_text(value: Any) -> Any:
+ """
+ Return numbers Excel cannot hold exactly as text, everything else
unchanged.
+
+ NumPy integers are boxed to Python integers first: ``abs`` of the NumPy
+ minimum would overflow and ``str`` of a rounded float would lose digits.
+ """
+ if isinstance(value, np.integer):
+ value = int(value)
+ if isinstance(value, (int, float)) and abs(value) > 10**15:
+ return str(value)
+ return value
+
+
def apply_column_types(
df: pd.DataFrame, column_types: list[GenericDataType]
) -> pd.DataFrame:
@@ -219,19 +234,23 @@ def apply_column_types(
series = df.iloc[:, idx]
if column_type == GenericDataType.NUMERIC:
try:
- # Nullable integers retain exact values alongside blank totals.
- # Convert to object before apply, which would otherwise coerce
- # nullable integers through float64 during scalar mapping.
- series = pd.to_numeric(series,
dtype_backend="numpy_nullable").astype(
- object
- )
+ # Object columns of integers already hold exact Python (or
NumPy)
+ # integers, including values beyond int64 that ``to_numeric``
+ # would round to float64 when the column has nulls: leave them.
+ if not (
+ series.dtype == object
+ and pd.api.types.infer_dtype(series, skipna=True) ==
"integer"
+ ):
+ # Nullable integers retain exact values alongside blank
+ # totals. Convert to object before apply, which would
+ # otherwise coerce nullable integers through float64 during
+ # scalar mapping.
+ series = pd.to_numeric(
+ series, dtype_backend="numpy_nullable"
+ ).astype(object)
# if the number is too large, convert it to a string
# Excel does not support numbers larger than 10^15
- series = series.apply(
- lambda x: (
- str(x) if isinstance(x, (int, float)) and abs(x) >
10**15 else x
- )
- )
+ series = series.apply(_large_number_as_text)
except ValueError:
series = series.astype(str)
elif isinstance(series.dtype, pd.DatetimeTZDtype):
diff --git a/tests/unit_tests/charts/test_client_processing.py
b/tests/unit_tests/charts/test_client_processing.py
index e3bf7965748..b00b151866d 100644
--- a/tests/unit_tests/charts/test_client_processing.py
+++ b/tests/unit_tests/charts/test_client_processing.py
@@ -5338,3 +5338,44 @@ def
test_pivot_mixed_totals_preserve_integer_xlsx(combine: bool, transpose: bool
assert any(cell.value == str(large) and cell.data_type == "s" for cell in
cells)
assert any(cell.value == 3 and cell.data_type == "n" for cell in cells)
assert not any(str(cell.value).startswith(str(large - 1)) for cell in
cells)
+
+
[email protected]("apply_number_format", [False, True])
+def test_pivot_mixed_str_int_big_integer_dimension(apply_number_format: bool)
-> None:
+ """A dimension that mixes exact big-integer strings, numbers and nulls (the
+ shape the chart-data API uses for integers beyond 2**53 - 1) pivots without
+ error and keeps every label exact."""
+ wire = [
+ {"big": "4611686018427387905", "metric": 1},
+ {"big": None, "metric": 2},
+ {"big": 7, "metric": 3},
+ {"big": "4611686018427387905", "metric": 4},
+ {"big": "-4611686018427387905", "metric": 5},
+ ]
+ df = pd.DataFrame.from_dict(wire)
+ form_data = {
+ "groupbyRows": ["big"],
+ "groupbyColumns": [],
+ "metrics": ["metric"],
+ "aggregateFunction": "Sum",
+ "rowTotals": True,
+ "colTotals": True,
+ }
+
+ pivoted = pivot_table_v2(
+ df.copy(), form_data, apply_number_format=apply_number_format
+ )
+ unformatted = table(df.copy(), {"all_columns": ["big", "metric"]}, None,
True)
+
+ sums = {
+ label: str(value)
+ for label, value, *_ in pivoted.reset_index().itertuples(index=False)
+ if isinstance(label, str)
+ }
+ assert sums == {
+ "7": "3",
+ "-4611686018427387905": "5",
+ "4611686018427387905": "5",
+ "Total (Sum)": "15",
+ }
+ assert unformatted["big"].tolist() == df["big"].tolist()
diff --git a/tests/unit_tests/charts/test_exact_integer_chart_data.py
b/tests/unit_tests/charts/test_exact_integer_chart_data.py
new file mode 100644
index 00000000000..afacc368702
--- /dev/null
+++ b/tests/unit_tests/charts/test_exact_integer_chart_data.py
@@ -0,0 +1,824 @@
+# 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.
+"""
+Chart data over a real database: nullable large integers stay exact in plain
+queries and keep today's float behaviour wherever Python-side processing runs.
+"""
+
+from __future__ import annotations
+
+import copy
+import io
+import sqlite3
+from collections.abc import Iterator
+from datetime import datetime
+from pathlib import Path
+from typing import Any
+from unittest.mock import patch
+
+import pandas as pd
+import pyarrow as pa
+import pytest
+from flask_caching import Cache
+from openpyxl import load_workbook
+from sqlalchemy.orm.session import Session
+
+from superset.app import SupersetApp
+from superset.charts.client_processing import apply_client_processing
+from superset.charts.schemas import ChartDataQueryContextSchema
+from superset.common.chart_data import ChartDataResultFormat,
ChartDataResultType
+from superset.common.query_context import QueryContext
+from superset.common.query_context_factory import QueryContextFactory
+from superset.common.query_serialization import load_serialized_query,
serialize_query
+from superset.connectors.sqla.models import SqlaTable, SqlMetric, TableColumn
+from superset.constants import CacheRegion
+from superset.models.core import Database
+from superset.utils import json
+
+LARGE = 333466480986292423
+
+
[email protected]
+def table(session: Session, tmp_path: Path) -> SqlaTable:
+ """A table whose ``big`` column is a nullable BIGINT holding a large
value."""
+ path = tmp_path / "chart_data.db"
+ connection = sqlite3.connect(path)
+ connection.execute(
+ "CREATE TABLE t (id INTEGER, grp TEXT, ts DATETIME, big BIGINT, val
INTEGER)"
+ )
+ connection.executemany(
+ "INSERT INTO t VALUES (?, ?, ?, ?, ?)",
+ [
+ (1, "a", "2024-01-01 00:00:00", LARGE, 10),
+ (2, "a", "2024-01-02 00:00:00", None, 20),
+ (3, "b", "2024-01-03 00:00:00", 1000, 30),
+ (4, "b", "2024-01-04 00:00:00", LARGE, 40),
+ (5, "c", "2024-01-05 00:00:00", None, 50),
+ ],
+ )
+ connection.commit()
+ connection.close()
+
+ SqlaTable.metadata.create_all(session.get_bind()) # pylint:
disable=no-member
+ database = Database(database_name="db", sqlalchemy_uri=f"sqlite:///{path}")
+ sqla_table = SqlaTable(
+ table_name="t",
+ database=database,
+ columns=[
+ TableColumn(column_name="id", type="INTEGER"),
+ TableColumn(column_name="grp", type="TEXT", groupby=True),
+ TableColumn(column_name="ts", type="DATETIME", is_dttm=True),
+ TableColumn(column_name="big", type="BIGINT", verbose_name="Big
Value"),
+ TableColumn(column_name="val", type="INTEGER"),
+ ],
+ metrics=[
+ SqlMetric(
+ metric_name="sum_val", expression="SUM(val)",
verbose_name="Sum Val"
+ ),
+ SqlMetric(
+ metric_name="max_big", expression="MAX(big)",
verbose_name="Max Big"
+ ),
+ ],
+ )
+ session.add(database)
+ session.add(sqla_table)
+ session.flush()
+ # ``changed_on`` is part of every cache key; settle it so keys are stable
+ sqla_table.changed_on = datetime(2024, 1, 1)
+ session.flush()
+ return sqla_table
+
+
+def create_context(
+ table: SqlaTable,
+ queries: list[dict[str, Any]],
+ result_type: ChartDataResultType = ChartDataResultType.FULL,
+ result_format: ChartDataResultFormat = ChartDataResultFormat.JSON,
+ form_data: dict[str, Any] | None = None,
+ force: bool = True,
+) -> QueryContext:
+ return QueryContextFactory().create(
+ datasource={"type": "table", "id": table.id},
+ queries=queries,
+ form_data=form_data or {},
+ result_type=result_type,
+ result_format=result_format,
+ force=force,
+ )
+
+
+def run(table: SqlaTable, queries: list[dict[str, Any]], **kwargs: Any) -> Any:
+ """Return the first query's payload for a freshly built context."""
+ payload = create_context(table, queries, **kwargs).get_payload()
+ return payload["queries"][0]
+
+
+def rows(payload: dict[str, Any]) -> list[dict[str, Any]]:
+ return sorted(payload["data"], key=lambda row: json.dumps(row,
default=str))
+
+
+def cells(data: bytes) -> list[list[Any]]:
+ sheet = load_workbook(io.BytesIO(data)).active
+ return [[cell.value for cell in row] for row in sheet.iter_rows()]
+
+
+def schema_context(
+ table: SqlaTable,
+ queries: list[dict[str, Any]],
+ result_type: ChartDataResultType,
+ form_data: dict[str, Any] | None = None,
+ result_format: ChartDataResultFormat = ChartDataResultFormat.JSON,
+) -> QueryContext:
+ """Build a context the way the chart-data API does for a saved chart."""
+ body = {
+ "datasource": {"id": table.id, "type": "table"},
+ "queries": copy.deepcopy(queries),
+ "form_data": form_data or {},
+ "result_format": result_format.value,
+ "result_type": result_type.value,
+ }
+ schema = ChartDataQueryContextSchema()
+ schema.query_context_factory = QueryContextFactory()
+ return schema.load(body)
+
+
+def client_processed(
+ table: SqlaTable,
+ queries: list[dict[str, Any]],
+ form_data: dict[str, Any],
+ result_format: ChartDataResultFormat = ChartDataResultFormat.JSON,
+) -> Any:
+ """What a report receives: the client-processed result of a saved chart."""
+ query_context = schema_context(
+ table, queries, ChartDataResultType.POST_PROCESSED, form_data,
result_format
+ )
+ processed = apply_client_processing(
+ query_context.get_payload(), form_data, query_context.datasource
+ )
+ data = processed["queries"][0]["data"]
+ return cells(data) if result_format == ChartDataResultFormat.XLSX else data
+
+
+QUERY = {"row_limit": 100}
+
+
+def test_plain_dimension_queries_keep_nullable_big_integers_exact(
+ app_context: None, table: SqlaTable
+) -> None:
+ raw = rows(run(table, [{**QUERY, "columns": ["big"]}]))
+ assert raw == [
+ {"big": "333466480986292423"},
+ {"big": "333466480986292423"},
+ {"big": 1000},
+ {"big": None},
+ {"big": None},
+ ]
+
+ aggregate = rows(
+ run(table, [{**QUERY, "columns": ["big"], "metrics": ["sum_val",
"max_big"]}])
+ )
+ # the dimension is exact; the saved metrics (one of them nullable) are
+ # normalized as before, whatever their verbose names
+ assert aggregate == [
+ {"big": "333466480986292423", "sum_val": 50, "max_big":
3.334664809862924e17},
+ {"big": 1000, "sum_val": 30, "max_big": 1000.0},
+ {"big": None, "sum_val": 70, "max_big": None},
+ ]
+
+
[email protected](
+ "result_type, queries",
+ [
+ pytest.param(
+ ChartDataResultType.SAMPLES, [{**QUERY, "columns": ["big"]}],
id="samples"
+ ),
+ pytest.param(
+ ChartDataResultType.DRILL_DETAIL, [{**QUERY}], id="drill-to-detail"
+ ),
+ ],
+)
+def test_sample_queries_keep_nullable_big_integers_exact(
+ app_context: None,
+ table: SqlaTable,
+ result_type: ChartDataResultType,
+ queries: list[dict[str, Any]],
+) -> None:
+ payload = run(table, queries, result_type=result_type)
+
+ assert sorted((row["big"] for row in payload["data"]), key=repr) == sorted(
+ ["333466480986292423", "333466480986292423", 1000, None, None],
key=repr
+ )
+
+
+def test_grouping_sets_levels_keep_nullable_big_integers_exact(
+ app_context: None, table: SqlaTable
+) -> None:
+ """SQLite has no ``GROUPING SETS``, so this runs the per-level fallback."""
+ query = {
+ **QUERY,
+ "columns": ["grp", "big"],
+ "metrics": ["sum_val"],
+ "grouping_sets": [["grp", "big"], ["grp"]],
+ }
+
+ data = run(table, [query])["data"]
+
+ assert {row["big"] for row in data} == {"333466480986292423", 1000, None}
+
+
+def test_exports_keep_nullable_big_integers_exact(
+ app_context: None, table: SqlaTable
+) -> None:
+ query = {**QUERY, "columns": ["big", "grp"]}
+
+ csv_data = run(table, [query],
result_format=ChartDataResultFormat.CSV)["data"]
+ xlsx_data = run(table, [query],
result_format=ChartDataResultFormat.XLSX)["data"]
+ arrow_data = run(table, [query],
result_format=ChartDataResultFormat.ARROW)["data"]
+
+ assert csv_data.decode("utf-8-sig") == (
+ "Big
Value,grp\n333466480986292423,a\n,a\n1000,b\n333466480986292423,b\n,c\n"
+ )
+ assert cells(xlsx_data) == [
+ ["Big Value", "grp"],
+ ["333466480986292423", "a"],
+ [None, "a"],
+ [1000, "b"],
+ ["333466480986292423", "b"],
+ [None, "c"],
+ ]
+ arrow = pa.ipc.open_stream(arrow_data).read_all()
+ assert arrow.schema.field("big").type == pa.int64()
+ assert arrow.column("big").to_pylist() == [LARGE, None, 1000, LARGE, None]
+
+
+def test_metrics_stay_float(app_context: None, table: SqlaTable) -> None:
+ by_group = rows(run(table, [{**QUERY, "columns": ["grp"], "metrics":
["max_big"]}]))
+
+ assert by_group == [
+ {"grp": "a", "max_big": 3.334664809862924e17},
+ {"grp": "b", "max_big": 3.334664809862924e17},
+ {"grp": "c", "max_big": None},
+ ]
+
+
+def test_post_processed_dimension_stays_float(
+ app_context: None, table: SqlaTable
+) -> None:
+ cumulative = [
+ {
+ "operation": "cum",
+ "options": {"operator": "sum", "columns": {"sum_val": "sum_val"}},
+ }
+ ]
+
+ post_processing = rows(
+ run(
+ table,
+ [
+ {
+ **QUERY,
+ "columns": ["grp", "big"],
+ "metrics": ["sum_val"],
+ "post_processing": cumulative,
+ }
+ ],
+ )
+ )
+ post_processed_context = rows(
+ run(
+ table,
+ [{**QUERY, "columns": ["big"]}],
+ result_type=ChartDataResultType.POST_PROCESSED,
+ )
+ )
+
+ assert post_processing == [
+ {"grp": "a", "big": 3.334664809862924e17, "sum_val": 30},
+ {"grp": "a", "big": None, "sum_val": 20},
+ {"grp": "b", "big": 1000.0, "sum_val": 60},
+ {"grp": "b", "big": 3.334664809862924e17, "sum_val": 100},
+ {"grp": "c", "big": None, "sum_val": 150},
+ ]
+ assert post_processed_context == [
+ {"big": 1000.0},
+ {"big": 3.334664809862924e17},
+ {"big": 3.334664809862924e17},
+ {"big": None},
+ {"big": None},
+ ]
+
+
+def test_contribution_totals_unchanged(app_context: None, table: SqlaTable) ->
None:
+ contribution = [
+ {
+ "operation": "contribution",
+ "options": {
+ "orientation": "column",
+ "columns": ["sum_val"],
+ "rename_columns": ["%sum_val"],
+ },
+ }
+ ]
+ queries = [
+ {
+ **QUERY,
+ "columns": ["big"],
+ "metrics": ["sum_val"],
+ "post_processing": contribution,
+ },
+ {**QUERY, "metrics": ["sum_val"]},
+ ]
+
+ payload = create_context(table, queries).get_payload()
+
+ assert [rows(query) for query in payload["queries"]] == [
+ [
+ {"big": 1000.0, "sum_val": 30, "%sum_val": 0.2},
+ {"big": 3.334664809862924e17, "sum_val": 50, "%sum_val": 1 / 3},
+ {"big": None, "sum_val": 70, "%sum_val": 0.4666666666666667},
+ ],
+ [{"sum_val": 150}],
+ ]
+
+
+def test_time_offset_query_stays_float_and_null_keys_join(
+ app_context: None, table: SqlaTable
+) -> None:
+ """The row whose dimension is NULL still finds its offset value."""
+ query = {
+ **QUERY,
+ "columns": ["big"],
+ "metrics": ["sum_val"],
+ "time_offsets": ["3 days ago"],
+ "granularity": "ts",
+ "time_range": "2024-01-04 : 2024-01-06",
+ "is_timeseries": True,
+ "extras": {"time_grain_sqla": "P1D"},
+ }
+
+ assert rows(run(table, [query])) == [
+ {
+ "__timestamp": pd.Timestamp("2024-01-04"),
+ "big": 3.334664809862924e17,
+ "sum_val": 40,
+ "sum_val__3 days ago": 10,
+ },
+ {
+ "__timestamp": pd.Timestamp("2024-01-05"),
+ "big": None,
+ "sum_val": 50,
+ "sum_val__3 days ago": 20,
+ },
+ ]
+
+
+TABLE_RAW = {
+ "viz_type": "table",
+ "query_mode": "raw",
+ "all_columns": ["big"],
+ "metrics": [],
+}
+TABLE_AGGREGATE = {
+ "viz_type": "table",
+ "query_mode": "aggregate",
+ "groupby": ["big"],
+ "metrics": ["sum_val"],
+}
+PIVOT = {
+ "viz_type": "pivot_table_v2",
+ "groupbyRows": ["big"],
+ "groupbyColumns": [],
+ "metrics": ["sum_val"],
+ "aggregateFunction": "Sum",
+ "rowTotals": True,
+ "colTotals": True,
+ "transposePivot": False,
+ "combineMetric": False,
+}
+RAW_QUERY = [{**QUERY, "columns": ["big"]}]
+AGGREGATE_QUERY = [{**QUERY, "columns": ["big"], "metrics": ["sum_val"]}]
+
+
[email protected](
+ "form_data, queries, result_format, expected",
+ [
+ pytest.param(
+ {**TABLE_RAW, "column_config": {"big": {"d3NumberFormat":
",.2f"}}},
+ RAW_QUERY,
+ ChartDataResultFormat.JSON,
+ {
+ "Big Value": {
+ 0: "333,466,480,986,292,416.00",
+ 1: "",
+ 2: "1,000.00",
+ 3: "333,466,480,986,292,416.00",
+ 4: "",
+ }
+ },
+ id="raw-table-number-format",
+ ),
+ pytest.param(
+ {
+ **TABLE_RAW,
+ "column_config": {
+ "big": {
+ "currencyFormat": {"symbol": "USD", "symbolPosition":
"prefix"}
+ }
+ },
+ },
+ RAW_QUERY,
+ ChartDataResultFormat.JSON,
+ {"Big Value": {0: "$ 333P", 1: "", 2: "$ 1k", 3: "$ 333P", 4: ""}},
+ id="raw-table-currency",
+ ),
+ pytest.param(
+ {**TABLE_AGGREGATE, "column_config": {"big": {"d3NumberFormat":
",.2f"}}},
+ AGGREGATE_QUERY,
+ ChartDataResultFormat.JSON,
+ {
+ "Big Value": {0: "", 1: "1,000.00", 2:
"333,466,480,986,292,416.00"},
+ "Sum Val": {0: "70", 1: "30", 2: "50"},
+ },
+ id="aggregate-table-number-format",
+ ),
+ pytest.param(
+ PIVOT,
+ AGGREGATE_QUERY,
+ ChartDataResultFormat.JSON,
+ {
+ "Sum Val": {
+ "1000.0": "30",
+ "3.334664809862924e+17": "50",
+ "nan": "70",
+ "Total (Sum)": "150",
+ },
+ "Total (Sum)": {
+ "1000.0": "30",
+ "3.334664809862924e+17": "50",
+ "nan": "70",
+ "Total (Sum)": "150",
+ },
+ },
+ id="pivot-table",
+ ),
+ pytest.param(
+ TABLE_RAW,
+ RAW_QUERY,
+ ChartDataResultFormat.CSV,
+ 'Big
Value\n3.334664809862924e+17\n""\n1000.0\n3.334664809862924e+17\n""\n',
+ id="raw-table-csv",
+ ),
+ pytest.param(
+ TABLE_RAW,
+ RAW_QUERY,
+ ChartDataResultFormat.XLSX,
+ [
+ ["Big Value"],
+ ["3.334664809862924e+17"],
+ [None],
+ [1000],
+ ["3.334664809862924e+17"],
+ ],
+ id="raw-table-xlsx",
+ ),
+ pytest.param(
+ PIVOT,
+ AGGREGATE_QUERY,
+ ChartDataResultFormat.CSV,
+ ",Sum Val,Total (Sum)\n1000.0,30,30\n3.334664809862924e+17,50,50\n"
+ "nan,70,70\nTotal (Sum),150,150\n",
+ id="pivot-table-csv",
+ ),
+ pytest.param(
+ PIVOT,
+ AGGREGATE_QUERY,
+ ChartDataResultFormat.XLSX,
+ [
+ [None, "Sum Val", "Total (Sum)"],
+ ["1000.0", 30, 30],
+ ["3.334664809862924e+17", 50, 50],
+ ["nan", 70, 70],
+ ["Total (Sum)", 150, 150],
+ ],
+ id="pivot-table-xlsx",
+ ),
+ ],
+)
+def test_post_processed_table_formatting_matches_float_baseline(
+ app_context: None,
+ table: SqlaTable,
+ form_data: dict[str, Any],
+ queries: list[dict[str, Any]],
+ result_format: ChartDataResultFormat,
+ expected: Any,
+) -> None:
+ """Report (client-processed) Tables and Pivot Tables are formatted from
float
+ columns exactly as before: a large nullable dimension must not reach client
+ processing as an unformattable object column."""
+ assert client_processed(table, queries, form_data, result_format) ==
expected
+
+
[email protected](
+ "context_type", [ChartDataResultType.FULL,
ChartDataResultType.POST_PROCESSED]
+)
+def test_queries_inherit_the_context_result_type(
+ app_context: None, table: SqlaTable, context_type: ChartDataResultType
+) -> None:
+ """Saved charts, report/POST bodies and async rebuilds all hand each query
the
+ context's result type, so the per-query value that gates exact integers
(and
+ is part of the cache key) always agrees with the context."""
+ queries = [{**QUERY, "columns": ["big"]}]
+ form_data = {"viz_type": "table", "result_type": context_type.value}
+ contexts = {
+ "saved-chart": schema_context(table, queries, context_type),
+ "report-body": schema_context(table, queries, context_type, form_data),
+ }
+ contexts["async-rebuild"] = load_serialized_query(
+ serialize_query(contexts["saved-chart"], 0)
+ )
+
+ for context in contexts.values():
+ assert context.result_type == context_type
+ assert context.queries[0].result_type == context_type
+ keys = {
+ name: context.queries[0].cache_key(datasource=table.uid)
+ for name, context in contexts.items()
+ }
+ assert len(set(keys.values())) == 1
+
+
+def test_full_and_post_processed_requests_use_different_cache_keys(
+ app_context: None, table: SqlaTable
+) -> None:
+ queries = [{**QUERY, "columns": ["big"]}]
+
+ full = schema_context(table, queries, ChartDataResultType.FULL)
+ post_processed = schema_context(table, queries,
ChartDataResultType.POST_PROCESSED)
+
+ assert full.queries[0].cache_key(datasource=table.uid) !=
post_processed.queries[
+ 0
+ ].cache_key(datasource=table.uid)
+
+
+def test_mixed_result_type_query_shares_the_plain_cache_key(
+ app_context: None, table: SqlaTable
+) -> None:
+ """Documented residual: a query that sets its own ``result_type`` keeps it
+ under a differently typed context. Its cache key is the plain request's, so
+ it is gated (and cached) as that plain request: exact and deterministic."""
+ mixed = [{**QUERY, "columns": ["big"], "result_type": "full"}]
+ plain = [{**QUERY, "columns": ["big"]}]
+
+ mixed_context = schema_context(table, mixed,
ChartDataResultType.POST_PROCESSED)
+ plain_context = schema_context(table, plain, ChartDataResultType.FULL)
+
+ assert mixed_context.result_type == ChartDataResultType.POST_PROCESSED
+ assert mixed_context.queries[0].result_type == ChartDataResultType.FULL
+ assert mixed_context.queries[0].cache_key(
+ datasource=table.uid
+ ) == plain_context.queries[0].cache_key(datasource=table.uid)
+ assert mixed_context.get_payload()["queries"][0]["data"][0] == {
+ "big": "333466480986292423"
+ }
+
+
+MIXED_TABLE_RAW = {
+ "viz_type": "table",
+ "query_mode": "raw",
+ "all_columns": ["big", "val"],
+ "metrics": [],
+ "column_config": {
+ "big": {"d3NumberFormat": ",.2f"},
+ "val": {"currencyFormat": {"symbol": "USD", "symbolPosition":
"prefix"}},
+ },
+}
+MIXED_TABLE_AGGREGATE = {
+ **TABLE_AGGREGATE,
+ "column_config": {
+ "big": {"d3NumberFormat": ",.2f"},
+ "sum_val": {"d3NumberFormat": ",.2f"},
+ },
+}
+MIXED_PIVOT = {**PIVOT, "valueFormat": ",.2f"}
+PIVOT_TOTALS = {"a": "30.00", "b": "70.00", "c": "50.00", "Total (Sum)":
"150.00"}
+
+
[email protected](
+ "form_data, query, control, mixed",
+ [
+ pytest.param(
+ MIXED_TABLE_RAW,
+ {**QUERY, "columns": ["big", "val"]},
+ {
+ "Big Value": {
+ 0: "333,466,480,986,292,416.00",
+ 1: "",
+ 2: "1,000.00",
+ 3: "333,466,480,986,292,416.00",
+ 4: "",
+ },
+ "val": {0: "$ 10", 1: "$ 20", 2: "$ 30", 3: "$ 40", 4: "$ 50"},
+ },
+ {
+ "Big Value": {
+ 0: "333466480986292423",
+ 1: None,
+ 2: 1000,
+ 3: "333466480986292423",
+ 4: None,
+ },
+ "val": {0: "$ 10", 1: "$ 20", 2: "$ 30", 3: "$ 40", 4: "$ 50"},
+ },
+ id="raw-table",
+ ),
+ pytest.param(
+ MIXED_TABLE_AGGREGATE,
+ {**QUERY, "columns": ["big"], "metrics": ["sum_val"]},
+ {
+ "Big Value": {0: "", 1: "1,000.00", 2:
"333,466,480,986,292,416.00"},
+ "Sum Val": {0: "70.00", 1: "30.00", 2: "50.00"},
+ },
+ {
+ "Big Value": {0: None, 1: 1000, 2: "333466480986292423"},
+ "Sum Val": {0: "70.00", 1: "30.00", 2: "50.00"},
+ },
+ id="aggregate-table",
+ ),
+ pytest.param(
+ MIXED_PIVOT,
+ {**QUERY, "columns": ["big"], "metrics": ["sum_val"]},
+ {
+ "Sum Val": {
+ "1000.0": "30.00",
+ "3.334664809862924e+17": "50.00",
+ "nan": "70.00",
+ "Total (Sum)": "150.00",
+ },
+ "Total (Sum)": {
+ "1000.0": "30.00",
+ "3.334664809862924e+17": "50.00",
+ "nan": "70.00",
+ "Total (Sum)": "150.00",
+ },
+ },
+ {
+ "Sum Val": {
+ "1000": "30.00",
+ "333466480986292423": "50.00",
+ "nan": "70.00",
+ "Total (Sum)": "150.00",
+ },
+ "Total (Sum)": {
+ "1000": "30.00",
+ "333466480986292423": "50.00",
+ "nan": "70.00",
+ "Total (Sum)": "150.00",
+ },
+ },
+ id="pivot-table-rows",
+ ),
+ pytest.param(
+ {**MIXED_PIVOT, "groupbyRows": ["grp"], "groupbyColumns": ["big"]},
+ {**QUERY, "columns": ["grp", "big"], "metrics": ["sum_val"]},
+ {
+ "Sum Val 1000.0": {
+ "a": "",
+ "b": "30.00",
+ "c": "",
+ "Total (Sum)": "30.00",
+ },
+ "Sum Val 3.334664809862924e+17": {
+ "a": "10.00",
+ "b": "40.00",
+ "c": "",
+ "Total (Sum)": "50.00",
+ },
+ "Sum Val nan": {
+ "a": "20.00",
+ "b": "",
+ "c": "50.00",
+ "Total (Sum)": "70.00",
+ },
+ "Sum Val Subtotal": PIVOT_TOTALS,
+ "Total (Sum)": PIVOT_TOTALS,
+ },
+ {
+ "Sum Val 1000": {
+ "a": "",
+ "b": "30.00",
+ "c": "",
+ "Total (Sum)": "30.00",
+ },
+ "Sum Val 333466480986292423": {
+ "a": "10.00",
+ "b": "40.00",
+ "c": "",
+ "Total (Sum)": "50.00",
+ },
+ "Sum Val nan": {
+ "a": "20.00",
+ "b": "",
+ "c": "50.00",
+ "Total (Sum)": "70.00",
+ },
+ "Sum Val Subtotal": PIVOT_TOTALS,
+ "Total (Sum)": PIVOT_TOTALS,
+ },
+ id="pivot-table-columns",
+ ),
+ ],
+)
+def test_full_query_inside_post_processed_context_through_client_processing(
+ app_context: None,
+ table: SqlaTable,
+ form_data: dict[str, Any],
+ query: dict[str, Any],
+ control: dict[str, Any],
+ mixed: dict[str, Any],
+) -> None:
+ """A query that sets ``result_type="full"`` inside a POST_PROCESSED context
+ (the mixed construction) goes through the real client-processing path for a
+ Table and a Pivot Table without error, next to a control request whose
query
+ inherits the context's type.
+
+ Documented residual, asserted as it is: the control request normalizes the
+ nullable large integer to a float, so its column is formatted from the
+ rounded value (``333,466,480,986,292,416.00``). The mixed query is gated
as a
+ plain request, so the column reaches client processing exact but as an
object
+ column: its digits are intact and its configured number format is skipped
+ (a Table prints ``"333466480986292423"``, ``1000`` and ``None``
unformatted;
+ Pivot Table labels carry the exact digits). Everything else is identical to
+ the control: formatting of the other columns (currency, number format,
+ ``valueFormat``) and every pivot value and total. Changing this is a
+ deliberate decision, for example normalizing an explicit per-query
+ ``result_type`` to the context's in ``QueryContextFactory.create``.
+ """
+ mixed_query = {**query, "result_type": "full"}
+ mixed_context = schema_context(
+ table, [mixed_query], ChartDataResultType.POST_PROCESSED, form_data
+ )
+ assert mixed_context.result_type == ChartDataResultType.POST_PROCESSED
+ assert mixed_context.queries[0].result_type == ChartDataResultType.FULL
+
+ assert client_processed(table, [query], form_data) == control
+ assert client_processed(table, [mixed_query], form_data) == mixed
+
+
[email protected]
+def result_cache(app: SupersetApp, tmp_path: Path) -> Iterator[Cache]:
+ cache = Cache(
+ config={
+ "CACHE_TYPE": "FileSystemCache",
+ "CACHE_DIR": str(tmp_path / "result_cache"),
+ "CACHE_DEFAULT_TIMEOUT": 300,
+ }
+ )
+ cache.init_app(app)
+ regions = {CacheRegion.DEFAULT: cache, CacheRegion.DATA: cache}
+ with patch("superset.common.utils.query_cache_manager._cache", regions):
+ yield cache
+
+
+def test_warm_cache_and_force_cached_keep_each_requests_shape(
+ app_context: None, table: SqlaTable, result_cache: Cache
+) -> None:
+ queries = [{**QUERY, "columns": ["big"]}]
+
+ def df_payload(result_type: ChartDataResultType, force_cached: bool =
False) -> Any:
+ context = create_context(table, queries, result_type=result_type,
force=False)
+ return context.get_df_payload(context.queries[0],
force_cached=force_cached)
+
+ first = df_payload(ChartDataResultType.FULL)
+ warm = df_payload(ChartDataResultType.FULL)
+ force_cached = df_payload(ChartDataResultType.FULL, force_cached=True)
+
+ assert [bool(p["is_cached"]) for p in (first, warm, force_cached)] == [
+ False,
+ True,
+ True,
+ ]
+ for payload in (first, warm, force_cached):
+ assert payload["df"]["big"].dtype == object
+ assert payload["df"]["big"].tolist() == [LARGE, None, 1000, LARGE,
None]
+
+ # the POST_PROCESSED request has its own entry and keeps the float frame
+ post_processed = df_payload(ChartDataResultType.POST_PROCESSED)
+ post_processed_warm = df_payload(ChartDataResultType.POST_PROCESSED)
+ assert not post_processed["is_cached"]
+ assert post_processed_warm["is_cached"]
+ for payload in (post_processed, post_processed_warm):
+ assert payload["df"]["big"].dtype == "float64"
diff --git a/tests/unit_tests/common/test_dataframe_utils.py
b/tests/unit_tests/common/test_dataframe_utils.py
index 51d17f401ed..353072d2847 100644
--- a/tests/unit_tests/common/test_dataframe_utils.py
+++ b/tests/unit_tests/common/test_dataframe_utils.py
@@ -15,10 +15,14 @@
# specific language governing permissions and limitations
# under the License.
import datetime
+from typing import Any
from unittest.mock import MagicMock
import pandas as pd
+import pytest
+from superset.common.chart_data import ChartDataResultType
+from superset.common.query_object import QueryObject
from superset.common.utils import dataframe_utils
@@ -73,3 +77,52 @@ def test_df_metrics_to_num_converts_string_numerics():
assert df["text_col"].dtype == object, "text_col should remain object"
# dim_col: not in metric_names -> should NOT be touched
assert df["dim_col"].dtype == object, "dim_col should not be touched"
+
+
[email protected](
+ "fields, expected",
+ [
+ pytest.param({}, True, id="plain"),
+ pytest.param({"result_type": ChartDataResultType.FULL}, True,
id="full"),
+ pytest.param({"result_type": ChartDataResultType.RESULTS}, True,
id="results"),
+ pytest.param({"result_type": ChartDataResultType.SAMPLES}, True,
id="samples"),
+ pytest.param(
+ {"result_type": ChartDataResultType.DRILL_DETAIL}, True,
id="drill-detail"
+ ),
+ pytest.param({"result_type": None}, True, id="no-result-type"),
+ pytest.param({"grouping_sets": [["a"], []]}, True, id="grouping-sets"),
+ pytest.param(
+ {"post_processing": [{"operation": "cum", "options": {}}]},
+ False,
+ id="post-processing",
+ ),
+ pytest.param({"time_offsets": ["1 week ago"]}, False,
id="time-offsets"),
+ pytest.param(
+ {"result_type": ChartDataResultType.POST_PROCESSED},
+ False,
+ id="post-processed",
+ ),
+ ],
+)
+def test_can_keep_exact_integers(fields: dict[str, Any], expected: bool) ->
None:
+ query_object = QueryObject(datasource=MagicMock(), columns=["a"], **fields)
+
+ assert dataframe_utils.can_keep_exact_integers(query_object) is expected
+
+
+def test_lossy_integer_columns_lists_only_selected_dimensions() -> None:
+ large = 2**60 + 1
+ lossy = pd.Series([large, None], dtype=object)
+ query_object = QueryObject(
+ datasource=MagicMock(), columns=["dim", "small"], metrics=["metric"]
+ )
+ df = pd.DataFrame(
+ {
+ "dim": lossy,
+ "small": pd.Series([7, None], dtype=object),
+ "metric": lossy.copy(),
+ "unselected": lossy.copy(),
+ }
+ )
+
+ assert dataframe_utils.lossy_integer_columns(df, query_object) == ["dim"]
diff --git a/tests/unit_tests/common/test_query_cache_manager.py
b/tests/unit_tests/common/test_query_cache_manager.py
index 05f1fbee09f..46167feb543 100644
--- a/tests/unit_tests/common/test_query_cache_manager.py
+++ b/tests/unit_tests/common/test_query_cache_manager.py
@@ -14,10 +14,18 @@
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.
+from datetime import timedelta
+from pathlib import Path
from unittest.mock import MagicMock, patch
+import numpy as np
+import pandas as pd
+from flask_caching import Cache
+
+from superset.app import SupersetApp
from superset.common.utils.query_cache_manager import QueryCacheManager
from superset.constants import CacheRegion
+from superset.models.helpers import QueryResult
class TestQueryCacheManagerGet:
@@ -73,3 +81,33 @@ class TestQueryCacheManagerGet:
assert result.is_cached is True
assert result.semantic_cache_status == "MISS"
+
+
+def test_kept_big_integer_column_survives_cache_round_trip(
+ app: SupersetApp, tmp_path: Path
+) -> None:
+ """Exact large integers (Python and NumPy scalars, nulls and NaN) in an
object
+ column come back from a real cache backend unchanged."""
+ cache = Cache(
+ config={"CACHE_TYPE": "FileSystemCache", "CACHE_DIR": str(tmp_path /
"cache")}
+ )
+ cache.init_app(app)
+ values = [2**62 + 1, None, np.int64(-(2**63)), float("nan"),
np.int64(2**63 - 1)]
+ df = pd.DataFrame({"value": pd.Series(values, dtype=object)})
+
+ with patch(
+ "superset.common.utils.query_cache_manager._cache",
+ {CacheRegion.DEFAULT: cache, CacheRegion.DATA: cache},
+ ):
+ QueryCacheManager().set_query_result(
+ key="exact-integers",
+ query_result=QueryResult(df=df, query="", duration=timedelta(0)),
+ timeout=300,
+ region=CacheRegion.DATA,
+ )
+ loaded = QueryCacheManager.get(key="exact-integers",
region=CacheRegion.DATA)
+
+ assert loaded.is_cached
+ assert loaded.df["value"].dtype == object
+ assert [type(value) for value in loaded.df["value"]] == [type(v) for v in
values]
+ pd.testing.assert_frame_equal(loaded.df, df)
diff --git a/tests/unit_tests/common/test_query_context_processor.py
b/tests/unit_tests/common/test_query_context_processor.py
index 15c29779946..752d1f2b1c1 100644
--- a/tests/unit_tests/common/test_query_context_processor.py
+++ b/tests/unit_tests/common/test_query_context_processor.py
@@ -2448,6 +2448,56 @@ def
test_grouping_sets_fallback_applies_row_offset_once_globally() -> None:
assert len(result.df) == 2
+def test_grouping_sets_fallback_mixed_levels_exact() -> None:
+ """
+ Concatenating the per-level results must keep an exact large integer exact:
+ one level carries the nullable-integer dimension as an object column,
another
+ has no such column, a third carries it as float64. ``object`` + ``float64``
+ stays an exact ``object`` column (a nullable ``Int64`` would be widened to
+ ``Float64`` and round the value again).
+ """
+ from datetime import timedelta
+
+ import numpy as np
+
+ from superset.common.query_object import QueryObject
+ from superset.models.helpers import QueryResult
+
+ large = 2**62 + 1
+ levels = {
+ 2: pd.DataFrame(
+ {
+ "state": ["CA", "NY"],
+ "big": pd.Series([large, None], dtype=object),
+ "value": [1, 2],
+ }
+ ),
+ 1: pd.DataFrame({"state": ["CA", "NY"], "value": [3, 4]}),
+ 0: pd.DataFrame(
+ {"big": pd.Series([7.0, np.nan], dtype="float64"), "value": [5, 6]}
+ ),
+ }
+ mock_datasource = MagicMock()
+ query_obj = QueryObject(
+ datasource=mock_datasource,
+ columns=["state", "big"],
+ grouping_sets=[["state", "big"], ["state"], []],
+ )
+ processor = QueryContextProcessor(MagicMock())
+ processor._qc_datasource = mock_datasource
+ mock_datasource.get_query_result.side_effect = lambda sub_query:
QueryResult(
+ df=levels[len(sub_query.columns)],
+ query="SELECT 1",
+ duration=timedelta(seconds=0),
+ )
+
+ result = processor._grouping_sets_fallback(query_obj)
+
+ assert result.df["big"].dtype == object
+ assert result.df["big"].iloc[0] == large
+ assert type(result.df["big"].iloc[0]) is int
+
+
def test_relative_offset_preserves_inner_bounds(
processor: QueryContextProcessor,
) -> None:
diff --git a/tests/unit_tests/datasource/test_query_api.py
b/tests/unit_tests/datasource/test_query_api.py
index a20510eda69..6ff7c596901 100644
--- a/tests/unit_tests/datasource/test_query_api.py
+++ b/tests/unit_tests/datasource/test_query_api.py
@@ -19,6 +19,7 @@
from typing import Any
+import numpy as np
import pandas as pd
import pytest
from marshmallow import ValidationError
@@ -173,6 +174,29 @@ def test_arrow_serializer_round_trips() -> None:
pd.testing.assert_frame_equal(restored, df)
[email protected]("scalar", [int, np.int64], ids=["python-int",
"numpy-int64"])
+def test_to_arrow_ipc_object_big_integers_int64(scalar: type) -> None:
+ """Nullable integers kept as exact objects serialize as int64 across the
+ signed range, with nulls preserved."""
+ import pyarrow as pa
+
+ from superset.common.query_context_processor import QueryContextProcessor
+
+ values = [2**63 - 1, None, -(2**63), 2**53 + 1, 7]
+ frame = pd.DataFrame(
+ {
+ "value": pd.Series(
+ [None if v is None else scalar(v) for v in values],
dtype=object
+ )
+ }
+ )
+
+ table =
pa.ipc.open_stream(QueryContextProcessor._to_arrow_ipc(frame)).read_all()
+
+ assert table.schema.field("value").type == pa.int64()
+ assert table.column("value").to_pylist() == values
+
+
def test_chart_data_schema_rejects_arrow() -> None:
"""`ARROW` lives on the shared enum so `get_data` can serialize it, but
`_send_chart_response` has no Arrow branch — accepting it on chart/data
diff --git a/tests/unit_tests/models/normalize_df_large_integer_test.py
b/tests/unit_tests/models/normalize_df_large_integer_test.py
new file mode 100644
index 00000000000..f31df031d57
--- /dev/null
+++ b/tests/unit_tests/models/normalize_df_large_integer_test.py
@@ -0,0 +1,307 @@
+# 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.
+"""Chart-data results must not round integers beyond 2**53 - 1."""
+
+from datetime import timedelta
+from decimal import Decimal
+from typing import Any
+from unittest.mock import MagicMock
+
+import numpy as np
+import pandas as pd
+import pytest
+
+from superset.common.chart_data import ChartDataResultType
+from superset.common.query_object import QueryObject
+from superset.dataframe import df_to_records
+from superset.models.helpers import ExploreMixin, QueryResult
+from superset.utils import json
+from superset.utils.core import JS_MAX_INTEGER
+
+LARGE_INTEGER = 333466480986292423
+
+
+def _datasource(
+ df: pd.DataFrame | None = None, verbose_map: dict[str, str] | None = None
+) -> MagicMock:
+ """A datasource double with the real ``ExploreMixin`` normalization
bound."""
+ datasource = MagicMock()
+ datasource.offset = 0
+ datasource.enforce_numerical_metrics = True
+ datasource.columns = []
+ datasource.verbose_map = verbose_map or {}
+ datasource.get_column = lambda name: None
+ datasource.query = lambda query_obj: QueryResult(
+ df=df if df is not None else pd.DataFrame(),
+ query="",
+ duration=timedelta(0),
+ )
+ datasource.processing_time_offsets = lambda frame, *args, **kwargs: {
+ "df": frame,
+ "queries": [],
+ }
+ for method in (
+ "_python_date_format",
+ "_collect_dttm_labels",
+ "_offset_only_dttm_cols",
+ "normalize_df",
+ "get_query_result",
+ ):
+ setattr(datasource, method, getattr(ExploreMixin,
method).__get__(datasource))
+ return datasource
+
+
+def _run_query_and_serialize(df: pd.DataFrame, **query_fields: Any) -> str:
+ """Run ``ExploreMixin.get_query_result`` and the chart-data JSON
encoding."""
+ datasource = _datasource(df)
+
+ # a plain query: one selected column, no metrics, no Python-side processing
+ query_object = MagicMock()
+ query_object.columns = ["value"]
+ query_object.column_names = ["value"]
+ query_object.metrics = []
+ query_object.metric_names = []
+ query_object.granularity = None
+ query_object.time_shift = None
+ query_object.post_processing = []
+ query_object.time_offsets = []
+ query_object.result_type = ChartDataResultType.FULL
+ query_object.exec_post_processing = lambda frame: frame
+ for name, value in query_fields.items():
+ setattr(query_object, name, value)
+
+ result = datasource.get_query_result(query_object)
+ return json.dumps(
+ df_to_records(result.df),
+ default=json.json_int_dttm_ser,
+ ignore_nan=True,
+ )
+
+
+def _normalize(
+ df: pd.DataFrame,
+ columns: list[Any],
+ metrics: list[Any] | None = None,
+ verbose_map: dict[str, str] | None = None,
+ **kwargs: Any,
+) -> pd.DataFrame:
+ """Run ``normalize_df`` for a real ``QueryObject`` selecting
``columns``."""
+ datasource = _datasource(verbose_map=verbose_map)
+ query_object = QueryObject(
+ datasource=datasource, columns=columns, metrics=metrics or []
+ )
+ return datasource.normalize_df(df, query_object, **kwargs)
+
+
+def _unchanged_normalization(df: pd.DataFrame) -> pd.DataFrame:
+ """What ``normalize_df`` returned for a column before the keyword
existed."""
+ return df.mask(df.isin([np.inf, -np.inf])).infer_objects()
+
+
+def test_nullable_bigint_column_keeps_every_digit() -> None:
+ # a nullable BIGINT column arrives as an object column of ints and None
+ df = pd.DataFrame({"value": pd.Series([LARGE_INTEGER, None],
dtype=object)})
+
+ payload = _run_query_and_serialize(df)
+
+ assert str(LARGE_INTEGER) in payload
+
+
[email protected]("null", [None, float("nan"), pd.NA], ids=["none",
"nan", "na"])
[email protected]("scalar", [int, np.int64], ids=["python-int",
"numpy-int64"])
+def test_kept_column_wire_contract(scalar: type, null: Any) -> None:
+ """Exact strings beyond 2**53 - 1, ordinary numerics and JSON nulls."""
+ values = [
+ LARGE_INTEGER,
+ null,
+ 7,
+ -LARGE_INTEGER,
+ 2**63 - 1,
+ -(2**63),
+ JS_MAX_INTEGER,
+ JS_MAX_INTEGER + 1,
+ ]
+ column = pd.Series([v if v is null else scalar(v) for v in values],
dtype=object)
+
+ payload = _run_query_and_serialize(pd.DataFrame({"value": column}))
+
+ assert payload == (
+ '[{"value": "333466480986292423"}, {"value": null}, {"value": 7}, '
+ '{"value": "-333466480986292423"}, {"value": "9223372036854775807"}, '
+ '{"value": "-9223372036854775808"}, {"value": 9007199254740991}, '
+ '{"value": "9007199254740992"}]'
+ )
+
+
[email protected](
+ "query_fields, exact",
+ [
+ pytest.param({}, True, id="plain-full"),
+ pytest.param({"result_type": None}, True, id="no-result-type"),
+ pytest.param({"result_type": ChartDataResultType.RESULTS}, True,
id="results"),
+ pytest.param({"result_type": ChartDataResultType.SAMPLES}, True,
id="samples"),
+ pytest.param(
+ {"result_type": ChartDataResultType.DRILL_DETAIL}, True,
id="drill-detail"
+ ),
+ pytest.param({"grouping_sets": [["value"], []]}, True,
id="grouping-sets"),
+ pytest.param(
+ {"post_processing": [{"operation": "cum"}]}, False,
id="post-processing"
+ ),
+ pytest.param({"time_offsets": ["1 week ago"]}, False,
id="time-offsets"),
+ pytest.param(
+ {"result_type": ChartDataResultType.POST_PROCESSED},
+ False,
+ id="post-processed",
+ ),
+ ],
+)
+def test_exact_integers_follow_the_query_gate(
+ query_fields: dict[str, Any], exact: bool
+) -> None:
+ df = pd.DataFrame({"value": pd.Series([LARGE_INTEGER, None],
dtype=object)})
+
+ payload = _run_query_and_serialize(df, **query_fields)
+
+ expected = (
+ '[{"value": "333466480986292423"}, {"value": null}]'
+ if exact
+ else '[{"value": 3.334664809862924e+17}, {"value": null}]'
+ )
+ assert payload == expected
+
+
+def test_normalize_df_defaults_to_float_normalization() -> None:
+ """Callers that do not opt in (e.g. the time-offset subquery) keep
float64."""
+ df = pd.DataFrame({"value": pd.Series([LARGE_INTEGER, None],
dtype=object)})
+
+ result = _normalize(df, ["value"])
+
+ assert result["value"].dtype == np.float64
+ assert result["value"].iloc[0] == float(LARGE_INTEGER)
+
+
+KEPT_VALUES = {
+ "just-beyond-safe": [JS_MAX_INTEGER + 1, None],
+ "beyond-safe-plus-one": [JS_MAX_INTEGER + 2, None],
+ "negative-beyond-safe": [-(JS_MAX_INTEGER + 2), None],
+ "int64-minimum": [-(2**63), None],
+ "int64-maximum": [2**63 - 1, None],
+ "mixed-small-large-negative": [1, JS_MAX_INTEGER + 2, None, -5,
-LARGE_INTEGER],
+ "numpy-scalars": [np.int64(JS_MAX_INTEGER + 2), None, np.int64(-(2**63))],
+ "nan-null": [JS_MAX_INTEGER + 2, float("nan")],
+ "pandas-na-null": [JS_MAX_INTEGER + 2, pd.NA],
+}
+
+NOT_KEPT_VALUES = {
+ "safe-boundary": [JS_MAX_INTEGER, None],
+ "small-only": [7, None],
+ "above-int64": [2**63, None],
+ "uint64-only": [2**64 - 1, None],
+ "uint64-range-with-small": [2**63 + 5, 7, None],
+ "negative-and-above-int64": [-1, 2**63 + 1, None],
+ "beyond-64-bits": [2**64, None],
+ "no-nulls": [JS_MAX_INTEGER + 2, 3],
+ "bool-mixed": [True, JS_MAX_INTEGER + 2, None],
+ "float-mixed": [JS_MAX_INTEGER + 2, 1.5, None],
+ "all-null": [None, None],
+ "decimal": [Decimal(JS_MAX_INTEGER + 2), None],
+ "string": ["a", None],
+}
+
+
[email protected]("values", KEPT_VALUES.values(),
ids=KEPT_VALUES.keys())
+def test_kept_columns_stay_exact_python_integers(values: list[Any]) -> None:
+ df = pd.DataFrame({"value": pd.Series(values, dtype=object)})
+
+ result = _normalize(df, ["value"], keep_exact_integers=True)
+
+ assert result["value"].dtype == object
+ assert [None if pd.isna(v) else v for v in result["value"]] == [
+ None if pd.isna(v) else v for v in values
+ ]
+
+
[email protected]("values", NOT_KEPT_VALUES.values(),
ids=NOT_KEPT_VALUES.keys())
+def test_kept_columns_only_lossy_int64_range(values: list[Any]) -> None:
+ """Every other shape is normalized exactly as before the keyword
existed."""
+ df = pd.DataFrame({"value": pd.Series(values, dtype=object)})
+
+ result = _normalize(df, ["value"], keep_exact_integers=True)
+
+ pd.testing.assert_frame_equal(result, _unchanged_normalization(df))
+
+
+def test_value_above_int64_maximum_stays_float() -> None:
+ df = pd.DataFrame({"value": pd.Series([2**63, None], dtype=object)})
+
+ result = _normalize(df, ["value"], keep_exact_integers=True)
+
+ assert result["value"].dtype == np.float64
+
+
+def test_duplicate_labels_keep_float_normalization() -> None:
+ df = pd.DataFrame(
+ [[JS_MAX_INTEGER + 2, JS_MAX_INTEGER + 3], [None, None]],
+ columns=["value", "value"],
+ dtype=object,
+ )
+
+ result = _normalize(df, ["value"], keep_exact_integers=True)
+
+ pd.testing.assert_frame_equal(result, _unchanged_normalization(df))
+
+
+def test_saved_metric_with_verbose_name_stays_float() -> None:
+ """The frame carries the saved metric's raw name while ``metric_names`` is
+ mapped to the verbose name, so metrics must be told apart by the raw
label."""
+ verbose_map = {"max_big": "Max Big Verbose"}
+ datasource = _datasource(verbose_map=verbose_map)
+ query_object = QueryObject(
+ datasource=datasource, columns=["big"], metrics=["max_big"]
+ )
+ assert query_object.metric_names == ["Max Big Verbose"]
+ nullable = pd.Series([LARGE_INTEGER, None], dtype=object)
+ df = pd.DataFrame({"big": nullable, "max_big": nullable.copy()})
+
+ result = datasource.normalize_df(df, query_object,
keep_exact_integers=True)
+
+ assert result["big"].dtype == object
+ assert result["big"].tolist() == [LARGE_INTEGER, None]
+ assert result["max_big"].dtype == np.float64
+
+
+def test_metrics_only_totals_query_converts_nothing() -> None:
+ """A metrics-only query selects no columns, so nothing qualifies."""
+ df = pd.DataFrame({"sum_big": pd.Series([LARGE_INTEGER, None],
dtype=object)})
+
+ result = _normalize(df, [], metrics=["sum_big"], keep_exact_integers=True)
+
+ assert result["sum_big"].dtype == np.float64
+
+
+def test_columns_not_selected_by_the_query_are_not_kept() -> None:
+ df = pd.DataFrame(
+ {
+ "selected": pd.Series([LARGE_INTEGER, None], dtype=object),
+ "extra": pd.Series([LARGE_INTEGER, None], dtype=object),
+ }
+ )
+
+ result = _normalize(df, ["selected"], keep_exact_integers=True)
+
+ assert result["selected"].dtype == object
+ assert result["extra"].dtype == np.float64
diff --git a/tests/unit_tests/utils/csv_tests.py
b/tests/unit_tests/utils/csv_tests.py
index 3a3210a5171..706f015d22c 100644
--- a/tests/unit_tests/utils/csv_tests.py
+++ b/tests/unit_tests/utils/csv_tests.py
@@ -19,6 +19,7 @@
from typing import Any
from unittest import mock
+import numpy as np
import pandas as pd
import pyarrow as pa
import pytest
@@ -255,6 +256,19 @@ def test_df_to_escaped_csv_preserves_numeric_columns():
assert rows[2] == ["safe", "-20"]
[email protected]("scalar", [int, np.int64], ids=["python-int",
"numpy-int64"])
+def test_df_to_escaped_csv_object_big_integers(scalar: type) -> None:
+ """Nullable big integers keep every digit; null and NaN rows are blank."""
+ values = [scalar(2**53 + 1), None, scalar(7), float("nan"),
scalar(-(2**63))]
+ df = pd.DataFrame(
+ {"value": pd.Series(values, dtype=object), "label": list("abcde")}
+ )
+
+ assert df_to_escaped_csv(df, encoding="utf8", index=False) == (
+
"value,label\n9007199254740993,a\n,b\n7,c\n,d\n-9223372036854775808,e\n"
+ )
+
+
def test_df_to_escaped_csv_non_default_index():
"""
String cells are escaped against the correct rows even when the DataFrame
diff --git a/tests/unit_tests/utils/excel_tests.py
b/tests/unit_tests/utils/excel_tests.py
index 719d07dbd68..ff1718119df 100644
--- a/tests/unit_tests/utils/excel_tests.py
+++ b/tests/unit_tests/utils/excel_tests.py
@@ -18,7 +18,9 @@
import io
from datetime import datetime, timedelta, timezone
+import numpy as np
import pandas as pd
+import pytest
from openpyxl import load_workbook
from pandas.api.types import is_numeric_dtype
@@ -310,3 +312,39 @@ def
test_numeric_column_with_blanks_preserves_large_integers() -> None:
assert sheet["A3"].value is None
assert sheet["A4"].value == 3
assert sheet["A4"].data_type == "n"
+
+
[email protected](
+ "values, expected",
+ [
+ pytest.param(
+ [2**53 + 1, None, 7, -(2**53 + 3)],
+ ["9007199254740993", None, 7, "'-9007199254740995"],
+ id="python-integers",
+ ),
+ pytest.param(
+ [np.int64(2**53 + 1), None, np.int64(7), np.int64(-(2**63))],
+ ["9007199254740993", None, 7, "'-9223372036854775808"],
+ id="numpy-integers",
+ ),
+ pytest.param(
+ [2**63 - 1, np.int64(2**63 - 1), None, np.int64(-7)],
+ ["9223372036854775807", "9223372036854775807", None, -7],
+ id="python-and-numpy-integers",
+ ),
+ ],
+)
+def test_apply_column_types_object_big_integers_exact(
+ values: list[object], expected: list[object]
+) -> None:
+ """Nullable integers (an object column) keep every digit in the workbook.
+
+ Values beyond 10**15 become text, smaller values stay numeric cells, nulls
+ stay blank, and negative text keeps the formula-quoting prefix.
+ """
+ df = pd.DataFrame({"value": pd.Series(values, dtype=object)})
+
+ apply_column_types(df, [GenericDataType.NUMERIC])
+ sheet = load_workbook(io.BytesIO(df_to_excel(df, index=False))).active
+
+ assert [row[0].value for row in sheet.iter_rows(min_row=2)] == expected