mirror of
https://github.com/apache/superset.git
synced 2026-07-25 16:12:39 +00:00
Co-authored-by: Superset Dev <dev@superset.apache.org> Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com> Co-authored-by: Amin Ghadersohi <amin.ghadersohi@gmail.com>
316 lines
13 KiB
Python
316 lines
13 KiB
Python
# Licensed to the Apache Software Foundation (ASF) under one
|
|
# or more contributor license agreements. See the NOTICE file
|
|
# distributed with this work for additional information
|
|
# regarding copyright ownership. The ASF licenses this file
|
|
# to you under the Apache License, Version 2.0 (the
|
|
# "License"); you may not use this file except in compliance
|
|
# with the License. You may obtain a copy of the License at
|
|
#
|
|
# http://www.apache.org/licenses/LICENSE-2.0
|
|
#
|
|
# Unless required by applicable law or agreed to in writing,
|
|
# software distributed under the License is distributed on an
|
|
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
|
# KIND, either express or implied. See the License for the
|
|
# specific language governing permissions and limitations
|
|
# under the License.
|
|
|
|
"""
|
|
End-to-end acceptance tests for non-additive metric totals (Table chart).
|
|
|
|
Companion to tests/unit_tests/charts/test_non_additive_totals.py and the
|
|
root SIP.md. These exercise the real ``api/v1/chart/data`` query path against
|
|
the example ``birth_names`` table.
|
|
|
|
Findings these tests pin down:
|
|
|
|
* **Bucket A (SQL-aggregate grand total)** is already correct for the Table
|
|
chart, because the "Show summary" row is produced by a *separate query with
|
|
no GROUP BY* (``columns=[]``). The database evaluates the metric expression
|
|
over all rows, so a ratio / distinct count is right even though summing the
|
|
per-group cells would be wrong. These are asserted as regression guards.
|
|
* **Bucket B (post-processing % columns)** was broken in the *frontend*:
|
|
``plugin-chart-table/buildQuery.ts`` built the totals query with
|
|
``post_processing=[]``, so a contribution / percent column never appeared in
|
|
the summary row (#37627, #34350). The fix retains post-processing on that
|
|
query; the guard below pins the backend capability the fix relies on (a
|
|
no-GROUP-BY summary query computes the percent column = 100%).
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from typing import Any
|
|
|
|
import numpy as np
|
|
import pytest
|
|
|
|
from superset.charts.schemas import ChartDataQueryContextSchema
|
|
from superset.common.grouping_sets import split_grouping_sets_result
|
|
from superset.common.query_context import QueryContext
|
|
from superset.utils.core import QueryStatus
|
|
from tests.integration_tests.base_tests import SupersetTestCase
|
|
from tests.integration_tests.fixtures.birth_names_dashboard import (
|
|
load_birth_names_dashboard_with_slices, # noqa: F401
|
|
load_birth_names_data, # noqa: F401
|
|
)
|
|
from tests.integration_tests.fixtures.query_context import get_query_context
|
|
|
|
# A non-additive ratio metric: fraction of births in California.
|
|
# Inlined CASE over physical columns (num, state); * 1.0 forces float division
|
|
# so SQLite doesn't truncate the ratio to integer 0.
|
|
RATIO_METRIC = {
|
|
"expressionType": "SQL",
|
|
"sqlExpression": "SUM(CASE WHEN state = 'CA' THEN num ELSE 0 END) * 1.0 / SUM(num)",
|
|
"label": "ca_share",
|
|
}
|
|
|
|
# COUNT_DISTINCT over a low-cardinality column (gender) that recurs across the
|
|
# groupby (state). The per-group distinct counts therefore overlap heavily, so
|
|
# summing them double-counts -- which is exactly what the grand total must avoid.
|
|
DISTINCT_METRIC = {
|
|
"expressionType": "SIMPLE",
|
|
"column": {"column_name": "gender"},
|
|
"aggregate": "COUNT_DISTINCT",
|
|
"label": "distinct_genders",
|
|
}
|
|
|
|
|
|
def _result_df(payload: dict[str, Any]):
|
|
# Use get_query_result (not get_payload) to avoid the flask-caching/Redis
|
|
# layer, so these tests run against just the metadata + results DB.
|
|
query_context: QueryContext = ChartDataQueryContextSchema().load(payload)
|
|
query_object = query_context.queries[0]
|
|
result = query_context.get_query_result(query_object)
|
|
assert result.status == QueryStatus.SUCCESS, result.errors
|
|
return result.df
|
|
|
|
|
|
def _base_payload(
|
|
metric: dict[str, Any], columns: list[str], clear_filters: bool = False
|
|
):
|
|
payload = get_query_context("birth_names")
|
|
query = payload["queries"][0]
|
|
query["metrics"] = [metric]
|
|
query["columns"] = columns
|
|
query["groupby"] = columns
|
|
query["orderby"] = []
|
|
query["post_processing"] = []
|
|
query["is_timeseries"] = False
|
|
query["row_limit"] = None
|
|
if clear_filters:
|
|
# Drop the default gender='boy' filter so both genders are present and
|
|
# genuinely recur across states.
|
|
query["filters"] = []
|
|
return payload
|
|
|
|
|
|
@pytest.mark.usefixtures("load_birth_names_dashboard_with_slices")
|
|
class TestNonAdditiveTotalsTable(SupersetTestCase):
|
|
def test_ratio_grand_total_is_db_computed_not_summed(self):
|
|
"""
|
|
Bucket A regression guard: the grand total of a ratio metric is the
|
|
ratio of the summed parts (DB-computed at no-GROUP-BY), and is NOT the
|
|
sum of the per-group ratios.
|
|
"""
|
|
self.login("admin")
|
|
|
|
# Per-group ratios (grouped by state).
|
|
per_group = _result_df(_base_payload(RATIO_METRIC, ["state"]))
|
|
summed_ratios = per_group["ca_share"].sum()
|
|
|
|
# Grand total: no GROUP BY -> the summary-row query the table chart runs.
|
|
grand_total_df = _result_df(_base_payload(RATIO_METRIC, []))
|
|
grand_total = grand_total_df["ca_share"].iloc[0]
|
|
|
|
# The grand total is a genuine ratio in [0, 1] ...
|
|
assert 0.0 <= grand_total <= 1.0
|
|
# ... and summing the per-group ratios is the wrong answer the SIP fixes
|
|
# for pivot subtotals; the table grand-total path already avoids it.
|
|
assert not np.isclose(grand_total, summed_ratios), (
|
|
f"grand total {grand_total} should differ from summed per-group "
|
|
f"ratios {summed_ratios}"
|
|
)
|
|
|
|
def test_distinct_count_grand_total_is_db_computed(self):
|
|
"""
|
|
Bucket A regression guard: COUNT_DISTINCT grand total is computed over
|
|
all rows, so it equals the true distinct count and is strictly less than
|
|
the sum of per-group distinct counts when members recur across groups
|
|
(gender recurs across every state, so summing double-counts).
|
|
"""
|
|
self.login("admin")
|
|
|
|
per_group = _result_df(
|
|
_base_payload(DISTINCT_METRIC, ["state"], clear_filters=True)
|
|
)
|
|
summed = per_group["distinct_genders"].sum()
|
|
|
|
grand_total_df = _result_df(
|
|
_base_payload(DISTINCT_METRIC, [], clear_filters=True)
|
|
)
|
|
grand_total = grand_total_df["distinct_genders"].iloc[0]
|
|
|
|
# Grand total is the true number of distinct genders (boy + girl) ...
|
|
assert grand_total == 2
|
|
# ... and summing per-state distinct counts double-counts (2 per state
|
|
# across many states), so the naive sum is strictly larger.
|
|
assert grand_total < summed
|
|
|
|
def test_backend_computes_percent_column_for_summary_query(self):
|
|
"""
|
|
Bucket B backend-capability guard.
|
|
|
|
The #37627 bug is in the frontend (``plugin-chart-table/buildQuery.ts``
|
|
built the totals query with ``post_processing=[]``). The backend itself
|
|
can compute the contribution/percent column on a no-GROUP-BY summary
|
|
query: the total's contribution to itself is 100%. This guard pins that
|
|
capability so the frontend fix (retaining post_processing on the totals
|
|
query) produces a correct summary % rather than an empty one.
|
|
"""
|
|
self.login("admin")
|
|
|
|
# Mirror the *fixed* frontend totals query: no GROUP BY, but with the
|
|
# percent-metric contribution op retained.
|
|
totals_payload = _base_payload({"label": "sum__num"}, [])
|
|
totals_payload["queries"][0]["post_processing"] = [
|
|
{
|
|
"operation": "contribution",
|
|
"options": {
|
|
"columns": ["sum__num"],
|
|
"orientation": "column",
|
|
"rename_columns": ["sum__num_pct"],
|
|
},
|
|
}
|
|
]
|
|
totals_df = _result_df(totals_payload)
|
|
|
|
assert "sum__num_pct" in totals_df.columns
|
|
# Single total row -> its contribution to itself is 100%.
|
|
assert totals_df["sum__num_pct"].iloc[0] == pytest.approx(1.0)
|
|
|
|
|
|
@pytest.mark.usefixtures("load_birth_names_dashboard_with_slices")
|
|
class TestTablePercentMetricSummary(SupersetTestCase):
|
|
"""
|
|
#37627 / #34350: a Table "Percentage metrics" column must appear in the
|
|
summary row when "Show summary" is on. The fix retains the contribution
|
|
post-processing on the totals query (plugin-chart-table buildQuery), so the
|
|
summary row recomputes the percent column instead of omitting it. The main
|
|
column is unaffected (verified: identical with/without the fix).
|
|
"""
|
|
|
|
def test_percent_metric_present_in_summary_and_nonzero_in_body(self):
|
|
self.login("admin")
|
|
pct = {
|
|
"expressionType": "SIMPLE",
|
|
"column": {"column_name": "num"},
|
|
"aggregate": "SUM",
|
|
"label": "sum__num",
|
|
}
|
|
contribution = {
|
|
"operation": "contribution",
|
|
"options": {
|
|
"columns": ["sum__num"],
|
|
"rename_columns": ["%sum__num"],
|
|
},
|
|
}
|
|
main_query = {
|
|
"columns": ["state"],
|
|
"metrics": [pct],
|
|
"post_processing": [contribution],
|
|
"orderby": [],
|
|
"row_limit": 100,
|
|
"is_timeseries": False,
|
|
}
|
|
# Mirrors the fixed frontend buildQuery: the show_totals query retains
|
|
# the contribution op (was post_processing=[]).
|
|
totals_query = {
|
|
"columns": [],
|
|
"metrics": [pct],
|
|
"post_processing": [contribution],
|
|
"orderby": [],
|
|
"row_limit": 0,
|
|
"is_timeseries": False,
|
|
}
|
|
payload = {
|
|
"datasource": {"id": self.get_birth_names_dataset().id, "type": "table"},
|
|
"queries": [main_query, totals_query],
|
|
"result_type": "full",
|
|
}
|
|
query_context: QueryContext = ChartDataQueryContextSchema().load(payload)
|
|
response = query_context.get_payload()
|
|
|
|
body = response["queries"][0]["data"]
|
|
summary = response["queries"][1]["data"]
|
|
# Body percent column is computed (non-zero for rows with data).
|
|
assert any(row.get("%sum__num") for row in body)
|
|
# The fix: the percent column is present in the summary row too.
|
|
assert summary
|
|
assert "%sum__num" in summary[0]
|
|
|
|
def get_birth_names_dataset(self):
|
|
from superset import db
|
|
from superset.connectors.sqla.models import SqlaTable
|
|
|
|
return db.session.query(SqlaTable).filter_by(table_name="birth_names").one()
|
|
|
|
|
|
@pytest.mark.usefixtures("load_birth_names_dashboard_with_slices")
|
|
class TestGroupingSetsRollup(SupersetTestCase):
|
|
"""
|
|
Phase 3b: a `grouping_sets` query returns every rollup level tagged with
|
|
GROUPING()-equivalent markers, regardless of engine. Engines that support
|
|
GROUPING SETS (Postgres) compute it in one native query
|
|
(get_sqla_query); engines that don't (SQLite) use the per-level fallback in
|
|
QueryContextProcessor. Either way the combined result is identical in shape,
|
|
so this test exercises both paths.
|
|
"""
|
|
|
|
def test_grouping_sets_returns_all_levels(self):
|
|
self.login("admin")
|
|
ratio = {
|
|
"expressionType": "SQL",
|
|
"sqlExpression": "SUM(CASE WHEN state = 'CA' THEN num ELSE 0 END) "
|
|
"* 1.0 / SUM(num)",
|
|
"label": "ca_share",
|
|
}
|
|
levels = [["gender", "state"], ["gender"], []]
|
|
payload = {
|
|
"datasource": {"id": 1, "type": "table"},
|
|
"queries": [
|
|
{
|
|
"columns": ["gender", "state"],
|
|
"metrics": [ratio],
|
|
"grouping_sets": levels,
|
|
"orderby": [],
|
|
"row_limit": 50000,
|
|
"is_timeseries": False,
|
|
}
|
|
],
|
|
"result_type": "full",
|
|
}
|
|
# birth_names dataset id is environment-specific; resolve it.
|
|
from superset import db
|
|
from superset.connectors.sqla.models import SqlaTable
|
|
|
|
table = db.session.query(SqlaTable).filter_by(table_name="birth_names").one()
|
|
payload["datasource"]["id"] = table.id
|
|
|
|
query_context: QueryContext = ChartDataQueryContextSchema().load(payload)
|
|
df = query_context.get_query_result(query_context.queries[0]).df
|
|
|
|
# One query produced the GROUPING() markers for both groupby columns.
|
|
assert "gender__superset_grouping" in df.columns
|
|
assert "state__superset_grouping" in df.columns
|
|
|
|
leaf, gender_sub, grand = split_grouping_sets_result(
|
|
df, levels, ["gender", "state"]
|
|
)
|
|
# Markers are stripped from the split frames.
|
|
assert "gender__superset_grouping" not in leaf.columns
|
|
# Grand total is a single, valid ratio (DB-computed, not summed cells).
|
|
assert len(grand) == 1
|
|
assert 0.0 <= grand["ca_share"].iloc[0] <= 1.0
|
|
# One subtotal row per gender.
|
|
assert len(gender_sub) == 2
|