This is an automated email from the ASF dual-hosted git repository. rusackas pushed a commit to branch fix/issue-32369-pivot-rows-csv-wf612d443b in repository https://gitbox.apache.org/repos/asf/superset.git
commit 9f93591a0644a3880eded623611dd00597282cb4 Author: rusackas <[email protected]> AuthorDate: Sun Jul 26 19:09:26 2026 -0700 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 --- 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"), + }
