Compare commits

...

1 Commits

Author SHA1 Message Date
rusackas
9f93591a06 fix(pivot-table): keep each Rows field as its own CSV/XLSX column
When a pivot table has multiple "Rows" fields selected, exporting to
pivoted CSV (or scheduled XLSX reports) collapsed all of them into a
single space-joined column instead of one column per field, unlike
the table rendered in Explore.

Fixes #32369
2026-07-26 19:09:26 -07:00
2 changed files with 89 additions and 10 deletions

View File

@@ -403,7 +403,7 @@ def apply_client_processing( # noqa: C901
# Check if the DataFrame has a default RangeIndex, which should not be shown
show_default_index = not isinstance(processed_df.index, pd.RangeIndex)
# Flatten hierarchical columns/index since they are represented as
# Flatten hierarchical columns since they are represented as
# `Tuple[str]`. Otherwise encoding to JSON later will fail because
# maps cannot have tuples as their keys in JSON.
processed_df.columns = [
@@ -414,14 +414,40 @@ def apply_client_processing( # noqa: C901
)
for column in processed_df.columns
]
processed_df.index = [
(
" ".join(str(name) for name in index).strip()
if isinstance(index, tuple)
else index
)
for index in processed_df.index
]
if query["result_format"] == ChartDataResultFormat.JSON:
# JSON object keys must be strings, so a hierarchical (multi-level)
# row index has to be flattened into a single string per row.
processed_df.index = [
(
" ".join(str(name) for name in index).strip()
if isinstance(index, tuple)
else index
)
for index in processed_df.index
]
elif (
isinstance(processed_df.index, pd.MultiIndex)
and processed_df.index.nlevels > 1
):
# For tabular formats (CSV/XLSX) keep each "Rows" field as its own
# column instead of collapsing them into a single joined string.
# Previously, when a pivot table had multiple "Rows" fields, they
# were merged into a single column on export; pandas natively
# writes a MultiIndex as one column per level when serializing to
# CSV/Excel. See: https://github.com/apache/superset/issues/32369
processed_df.index.names = [
name if name is not None else "" for name in processed_df.index.names
]
else:
processed_df.index = [
(
" ".join(str(name) for name in index).strip()
if isinstance(index, tuple)
else index
)
for index in processed_df.index
]
if query["result_format"] == ChartDataResultFormat.JSON:
query["data"] = processed_df.to_dict()

View File

@@ -15,7 +15,7 @@
# specific language governing permissions and limitations
# under the License.
from io import BytesIO
from io import BytesIO, StringIO
import pandas as pd
import pytest
@@ -3150,3 +3150,56 @@ def test_apply_client_processing_csv_format_partial_config_decimal_only():
assert lines[0] == "name,value", f"Expected comma-separated header, got: {lines[0]}"
assert "foo" in lines[1]
assert "bar" in lines[2]
def test_apply_client_processing_csv_format_pivot_table_multiple_rows():
"""
When multiple "Rows" fields are selected in a pivot table, exporting to
pivoted CSV should keep each field in its own column instead of merging
them into a single column.
See: https://github.com/apache/superset/issues/32369
"""
csv_data = (
"city,segment,value\n"
"Paris,Consumer,10\n"
"Paris,Corporate,20\n"
"London,Consumer,30\n"
"London,Corporate,40\n"
)
result = {
"queries": [
{
"result_format": ChartDataResultFormat.CSV,
"data": csv_data,
}
]
}
form_data = {
"viz_type": "pivot_table_v2",
"groupbyColumns": [],
"groupbyRows": ["city", "segment"],
"metrics": ["value"],
"metricsLayout": "COLUMNS",
}
processed_result = apply_client_processing(result, form_data)
query = processed_result["queries"][0]
output_data = query["data"]
lines = output_data.strip().split("\n")
# the header should have a separate column for each "Rows" field, instead
# of a single merged column
assert lines[0] == "city,segment,value"
# each data row should have the city and segment in their own columns
output_df = pd.read_csv(StringIO(output_data))
assert list(output_df.columns) == ["city", "segment", "value"]
assert set(zip(output_df["city"], output_df["segment"], strict=False)) == {
("Paris", "Consumer"),
("Paris", "Corporate"),
("London", "Consumer"),
("London", "Corporate"),
}