This is an automated email from the ASF dual-hosted git repository.
rusackas 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 d9c0f19b0c8 fix(pivot-table): keep each Rows field as its own CSV/XLSX
column (#42443)
d9c0f19b0c8 is described below
commit d9c0f19b0c83c5c557312e60b4f55b75e17b2d83
Author: Evan Rusackas <[email protected]>
AuthorDate: Wed Jul 29 15:47:28 2026 -0700
fix(pivot-table): keep each Rows field as its own CSV/XLSX column (#42443)
---
superset/charts/client_processing.py | 44 ++++++++++++++----
tests/unit_tests/charts/test_client_processing.py | 55 ++++++++++++++++++++++-
2 files changed, 89 insertions(+), 10 deletions(-)
diff --git a/superset/charts/client_processing.py
b/superset/charts/client_processing.py
index e0c2db71af7..c765a534c7f 100644
--- a/superset/charts/client_processing.py
+++ b/superset/charts/client_processing.py
@@ -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()
diff --git a/tests/unit_tests/charts/test_client_processing.py
b/tests/unit_tests/charts/test_client_processing.py
index 600c7cf4c7f..179b4647b7d 100644
--- a/tests/unit_tests/charts/test_client_processing.py
+++ b/tests/unit_tests/charts/test_client_processing.py
@@ -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"),
+ }