44import logging
55from typing import Callable
66
7+ import numpy
78import orjson
89import pandas
9- from gooddata_sdk .type_converter import AttributeConverterStore
10+ from gooddata_sdk .type_converter import AttributeConverterStore , DateConverter , DatetimeConverter
1011
1112from gooddata_pandas .arrow_types import TypesMapper
1213
1314try :
1415 import pyarrow as pa
16+ import pyarrow .compute as pc
1517except ImportError as _exc :
1618 raise ImportError (
1719 "pyarrow is required for Arrow support. Install it with: pip install gooddata-pandas[arrow]"
@@ -96,9 +98,30 @@ def convert_label_values(label_id: str, values: list, model_labels: dict) -> lis
9698 Returns:
9799 Converted list, or the original *values* object when no conversion is needed.
98100 """
101+ # pick the converter for this label's granularity (None for plain text attributes)
99102 converter = _get_date_converter_for_label (label_id , model_labels )
100103 if converter is None :
101104 return values
105+
106+ if isinstance (converter , (DateConverter , DatetimeConverter )):
107+ # Date/datetime granularity is costly, so vectorize it in three steps:
108+
109+ # 1) parse each raw string into a datetime.date/datetime with the cheap
110+ # per-value to_type() (handles partial dates like "2023" or "2023-01");
111+ # keep None as None. This step stays per-value so that pandas
112+ # does not re-infer the date format from the raw strings in step 2.
113+ typed = [converter .to_type (v ) if v is not None else None for v in values ]
114+
115+ # 2) convert the whole column to Timestamps in one vectorized call (this
116+ # single call replaces N per-value ones; None becomes NaT here).
117+ converted = pandas .to_datetime (typed )
118+
119+ # 3) rebuild the list, restoring None wherever the input was None so that NaT
120+ # does not leak into the output.
121+ return [None if orig is None else c for orig , c in zip (values , converted )]
122+
123+ # WEEK / QUARTER (StringConverter) and integer granularities are cheap per value
124+ # and could change type subtly if batched, so convert them one by one (None kept).
102125 return [converter .to_external_type (v ) if v is not None else None for v in values ]
103126
104127
@@ -420,16 +443,23 @@ def reorder_grand_totals(
420443 return table
421444 if _COL_ROW_TYPE not in table .schema .names :
422445 return table
423- row_type_vals = table .column (_COL_ROW_TYPE ).to_pylist ()
424- grand_mask = pa .array ([v == 2 for v in row_type_vals ], type = pa .bool_ ())
425- grand_total_rows = table .filter (grand_mask )
446+
447+ # uses pyarrow.compute to run these in a vectorized way.
448+ # the fill_null ensures that nulls are still marked as non-totals.
449+ # (pyarrow.compute functions are generated at runtime, so ty cannot see them.)
450+ row_type_col = table .column (_COL_ROW_TYPE )
451+ is_grand_total = pc .fill_null (pc .equal (row_type_col , 2 ), False ) # ty: ignore[unresolved-attribute]
452+ grand_total_rows = table .filter (is_grand_total )
426453 if grand_total_rows .num_rows == 0 :
427454 return table
428- data_and_sub_rows = table .filter (pa .array ([v != 2 for v in row_type_vals ], type = pa .bool_ ()))
429- return pa .concat_tables ([grand_total_rows , data_and_sub_rows ])
455+
456+ not_grand_total = pc .fill_null (pc .not_equal (row_type_col , 2 ), True ) # ty: ignore[unresolved-attribute]
457+ return pa .concat_tables ([grand_total_rows , table .filter (not_grand_total )])
430458
431459
432- def compute_column_totals_indexes (table : pa .Table , execution_dims : list ) -> list [list [int ]]:
460+ def compute_column_totals_indexes (
461+ table : pa .Table , execution_dims : list , schema_meta : dict | None = None
462+ ) -> list [list [int ]]:
433463 """
434464 Compute column_totals_indexes compatible with DataFrameMetadata from an Arrow table.
435465
@@ -445,7 +475,9 @@ def compute_column_totals_indexes(table: pa.Table, execution_dims: list) -> list
445475 grand_total_* fields become output rows and are already covered by
446476 compute_row_totals_indexes. Returns [] in that case.
447477 """
448- schema_meta = _parse_schema_metadata (table )
478+ if schema_meta is None :
479+ schema_meta = _parse_schema_metadata (table )
480+
449481 xtab_meta = schema_meta [_META_XTAB ]
450482 is_transposed = schema_meta [_META_VIEW ]["isTransposed" ]
451483
@@ -518,7 +550,9 @@ def _label_ids_in_dim(dim: dict) -> set:
518550 return result
519551
520552
521- def compute_row_totals_indexes (table : pa .Table , execution_dims : list ) -> list [list [int ]]:
553+ def compute_row_totals_indexes (
554+ table : pa .Table , execution_dims : list , schema_meta : dict | None = None
555+ ) -> list [list [int ]]:
522556 """
523557 Compute row_totals_indexes compatible with DataFrameMetadata from an Arrow table.
524558
@@ -534,7 +568,9 @@ def compute_row_totals_indexes(table: pa.Table, execution_dims: list) -> list[li
534568 Total rows are grand_total_N fields. A field is total at level j only when
535569 j >= len(gdc["label_values"]), i.e. that label level is being aggregated.
536570 """
537- schema_meta = _parse_schema_metadata (table )
571+ if schema_meta is None :
572+ schema_meta = _parse_schema_metadata (table )
573+
538574 xtab_meta = schema_meta [_META_XTAB ]
539575 is_transposed = schema_meta [_META_VIEW ]["isTransposed" ]
540576
@@ -608,8 +644,8 @@ def _label_ids_in_dim(dim: dict) -> set:
608644 else :
609645 # Output rows are Arrow rows; every total row (row_type != 0) is listed
610646 # in the total-indexes for every attribute level.
611- row_types = _get_row_types ( table )
612- total_row_idxs = [ i for i , rt in enumerate (row_types ) if rt != 0 ]
647+ row_types = table . column ( _COL_ROW_TYPE ). to_numpy ( zero_copy_only = False )
648+ total_row_idxs = numpy . nonzero (row_types != 0 )[ 0 ]. tolist ()
613649
614650 result = []
615651 for header in row_dim .get ("headers" , []):
@@ -643,30 +679,47 @@ def _compute_primary_labels_from_inline(
643679 """
644680 result : dict [int , dict [str , str ]] = {}
645681 label_meta = xtab_meta .get ("labelMetadata" , {})
646- row_types = _get_row_types (table )
647- data_row_mask = [rt == 0 for rt in row_types ]
648682
649- for j , ref in enumerate (label_refs ):
683+ # Project to only the columns this function reads - __row_type plus the label
684+ # and primary-label columns - before filtering. Filtering the whole table would
685+ # also copy every metric column for the data rows even though only these
686+ # attribute columns are ever read below.
687+ # table.select is zero-copy, so the subsequent filter copies just these columns.
688+ needed_cols = [_COL_ROW_TYPE ]
689+ for ref in label_refs :
650690 info = label_meta .get (ref , {})
651691 label_id = label_ref_to_id .get (ref , info .get ("labelId" , "" ))
652692 primary_label_id = info .get ("primaryLabelId" , label_id )
693+ for col in (label_id , primary_label_id ):
694+ if col in table .schema .names and col not in needed_cols :
695+ needed_cols .append (col )
696+
697+ projected = table .select (needed_cols )
698+
699+ # Extract the data rows (row_type == 0) once and reuse. Only filter when totals
700+ # are actually present - otherwise the projected table already is the data rows.
701+ # (pyarrow.compute functions are generated at runtime, so ty cannot see them.)
702+ row_type_col = projected .column (_COL_ROW_TYPE )
703+ has_total_rows = pc .any (pc .not_equal (row_type_col , 0 )).as_py () # ty: ignore[unresolved-attribute]
704+ data_row_mask = pc .equal (row_type_col , 0 ) # ty: ignore[unresolved-attribute]
705+ data_rows = projected .filter (data_row_mask ) if has_total_rows else projected
653706
654- display_vals = table .column (label_id ).to_pylist ()
707+ for j , ref in enumerate (label_refs ):
708+ info = label_meta .get (ref , {})
709+ label_id = label_ref_to_id .get (ref , info .get ("labelId" , "" ))
710+ primary_label_id = info .get ("primaryLabelId" , label_id )
655711
656- if label_id == primary_label_id :
712+ if label_id == primary_label_id or primary_label_id not in table .schema .names :
713+ # identity (or fallback when the primary column is absent): map each
714+ # distinct display value to itself.
657715 mapping : dict [str , str ] = {
658- v : v for v , is_data in zip (display_vals , data_row_mask ) if is_data and isinstance (v , str )
659- }
660- elif primary_label_id in table .schema .names :
661- primary_vals = table .column (primary_label_id ).to_pylist ()
662- mapping = {
663- p : d
664- for p , d , is_data in zip (primary_vals , display_vals , data_row_mask )
665- if is_data and isinstance (p , str ) and isinstance (d , str )
716+ v : v for v in data_rows .column (label_id ).unique ().to_pylist () if isinstance (v , str )
666717 }
667718 else :
668- # Fallback: identity (primary label data not present in table)
669- mapping = {v : v for v , is_data in zip (display_vals , data_row_mask ) if is_data and isinstance (v , str )}
719+ # primary != display: map each primary value to its display value
720+ primary_vals = data_rows .column (primary_label_id ).to_pylist ()
721+ display_vals = data_rows .column (label_id ).to_pylist ()
722+ mapping = {p : d for p , d in zip (primary_vals , display_vals ) if isinstance (p , str ) and isinstance (d , str )}
670723
671724 result [j ] = mapping
672725 return result
@@ -709,6 +762,7 @@ def _compute_primary_labels_from_fields(
709762
710763def compute_primary_labels (
711764 table : pa .Table ,
765+ schema_meta : dict | None = None ,
712766) -> tuple [dict [int , dict [str , str ]], dict [int , dict [str , str ]]]:
713767 """
714768 Compute primary_labels_from_index and primary_labels_from_columns from an Arrow table.
@@ -719,7 +773,9 @@ def compute_primary_labels(
719773 Returns:
720774 (primary_labels_from_index, primary_labels_from_columns)
721775 """
722- schema_meta = _parse_schema_metadata (table )
776+ if schema_meta is None :
777+ schema_meta = _parse_schema_metadata (table )
778+
723779 xtab_meta = schema_meta [_META_XTAB ]
724780 is_transposed = schema_meta [_META_VIEW ]["isTransposed" ]
725781
@@ -750,6 +806,7 @@ def convert_arrow_table_to_dataframe(
750806 types_mapper : TypesMapper = TypesMapper .DEFAULT ,
751807 custom_mapping : dict | None = None ,
752808 label_overrides : dict | None = None ,
809+ schema_meta : dict | None = None ,
753810) -> pandas .DataFrame :
754811 """
755812 Convert a pyarrow Table returned by the GoodData /binary execution endpoint
@@ -800,7 +857,9 @@ def convert_arrow_table_to_dataframe(
800857 else :
801858 raise ValueError ("Unknown types_mapper value" )
802859
803- schema_meta = _parse_schema_metadata (table )
860+ if schema_meta is None :
861+ schema_meta = _parse_schema_metadata (table )
862+
804863 xtab_meta = schema_meta [_META_XTAB ]
805864 model_meta = schema_meta [_META_MODEL ]
806865 is_transposed = schema_meta [_META_VIEW ]["isTransposed" ]
0 commit comments