22import json
33import uuid
44import logging
5- from datetime import date , datetime
6- from typing import Any , Dict , Iterable , List , Optional
5+ import base64
6+ from datetime import date , datetime , time , timedelta
7+ from decimal import Decimal
8+ from typing import Any , Dict , Iterable , List , Optional , Set
79
810import polars as pl
911
1012
1113logger = logging .getLogger ("odata_to_staging.download_parquet" )
1214
1315
16+ def _to_jsonable (value : Any ) -> Any :
17+ """Convert OData values to JSON-serializable Python types (no string wrapping).
18+
19+ This is the inner conversion layer that produces Python dicts/lists
20+ for nested structures, which can then be JSON-serialized by the caller.
21+ """
22+ if value is None :
23+ return None
24+ if isinstance (value , (bool , int , float , str )):
25+ return value
26+ if isinstance (value , Decimal ):
27+ return str (value )
28+ if isinstance (value , uuid .UUID ):
29+ return str (value )
30+ if isinstance (value , (datetime , date )):
31+ return value .isoformat ()
32+ if isinstance (value , time ):
33+ return value .isoformat ()
34+ if isinstance (value , timedelta ):
35+ return value .total_seconds ()
36+ if isinstance (value , (bytes , bytearray )):
37+ return base64 .b64encode (bytes (value )).decode ("ascii" )
38+ # Handle lists
39+ if isinstance (value , (list , tuple )):
40+ return [_to_jsonable (item ) for item in value ]
41+ # Handle dicts
42+ if isinstance (value , dict ):
43+ return {k : _to_jsonable (v ) for k , v in value .items ()}
44+ # Handle OData entity proxies or other objects with attributes
45+ try :
46+ if hasattr (value , "__dict__" ):
47+ obj_dict = {
48+ k : _to_jsonable (v )
49+ for k , v in value .__dict__ .items ()
50+ if not k .startswith ("_" )
51+ }
52+ if obj_dict :
53+ return obj_dict
54+ except Exception :
55+ pass
56+ # Last resort: try str() representation
57+ try :
58+ return str (value )
59+ except Exception :
60+ return None
61+
62+
1463def _to_scalar (value : Any ) -> Any :
1564 """Best-effort conversion of OData values to parquet-friendly scalars.
1665
1766 - Keep None, bool, int, float, str
67+ - Keep Decimal, UUID (as str)
1868 - Keep datetime/date
19- - For other objects (proxies, nested collections), return None
69+ - Convert time to ISO string
70+ - Convert timedelta to total seconds (float)
71+ - Convert bytes to base64 string
72+ - Lists/dicts (from $expand or complex types) are JSON-serialized to string
73+ - Other objects (proxies, entity refs) are recursively converted to dict then JSON
2074 """
2175 if value is None :
2276 return None
2377 if isinstance (value , (bool , int , float , str )):
2478 return value
79+ if isinstance (value , Decimal ):
80+ # Polars inference from python Decimals can be backend/version-dependent;
81+ # store as string to avoid silent NULLs or object dtypes in parquet.
82+ return str (value )
83+ if isinstance (value , uuid .UUID ):
84+ return str (value )
2585 if isinstance (value , (datetime , date )):
2686 return value
27- # Fallback: not a scalar we handle
28- return None
87+ if isinstance (value , time ):
88+ return value .isoformat ()
89+ if isinstance (value , timedelta ):
90+ return value .total_seconds ()
91+ if isinstance (value , (bytes , bytearray )):
92+ return base64 .b64encode (bytes (value )).decode ("ascii" )
93+ # Handle lists (e.g., from $expand navigation properties returning collections)
94+ if isinstance (value , (list , tuple )):
95+ jsonable = [_to_jsonable (item ) for item in value ]
96+ return json .dumps (jsonable , default = str , ensure_ascii = False )
97+ # Handle dicts (e.g., complex types or already-converted entities)
98+ if isinstance (value , dict ):
99+ jsonable = {k : _to_jsonable (v ) for k , v in value .items ()}
100+ return json .dumps (jsonable , default = str , ensure_ascii = False )
101+ # Handle OData entity proxies or other objects with attributes
102+ # Try to extract a dict representation for nested/expanded entities
103+ try :
104+ # pyodata entity proxies often have a way to get properties
105+ if hasattr (value , "__dict__" ):
106+ # Filter out private/internal attributes
107+ obj_dict = {
108+ k : _to_jsonable (v )
109+ for k , v in value .__dict__ .items ()
110+ if not k .startswith ("_" )
111+ }
112+ if obj_dict :
113+ return json .dumps (obj_dict , default = str , ensure_ascii = False )
114+ except Exception :
115+ pass
116+ # Last resort: try str() representation
117+ try :
118+ return str (value )
119+ except Exception :
120+ return None
121+
29122
123+ def _entity_properties (
124+ client : Any , entity_set_name : str , select : Optional [str ] = None
125+ ) -> List [str ]:
126+ """Return ordered list of properties of an entity set (keys first).
30127
31- def _entity_properties (client : Any , entity_set_name : str ) -> List [str ]:
32- """Return ordered list of properties of an entity set (keys first)."""
128+ If select is provided (comma-separated property names from $select),
129+ only those properties are returned, preserving the order in select.
130+ Keys are always included first if present in select.
131+ """
33132 es = client .schema .entity_set (entity_set_name )
34133 typ = es .entity_type
35134 keys = [kp .name for kp in typ .key_proprties ]
36135 # Remaining props (excluding keys) in schema order
37136 members = [mp .name for mp in typ .proprties () if mp .name not in set (keys )]
38- return keys + members
137+ all_props = keys + members
138+
139+ if select :
140+ # Parse $select: comma-separated, may contain navigation paths like "Orders/OrderID"
141+ # We only take top-level property names (before any slash)
142+ selected_raw = [s .strip () for s in select .split ("," ) if s .strip ()]
143+ selected_names : List [str ] = []
144+ for s in selected_raw :
145+ # Handle navigation paths: "Orders/OrderID" -> "Orders"
146+ prop_name = s .split ("/" )[0 ].strip ()
147+ if prop_name and prop_name not in selected_names :
148+ selected_names .append (prop_name )
149+
150+ # Keep only those that exist in the entity type
151+ all_props_set = set (all_props )
152+ filtered = [p for p in selected_names if p in all_props_set ]
153+
154+ # Ensure keys come first (only those that are in the selection)
155+ keys_in_select = [k for k in keys if k in filtered ]
156+ others_in_select = [p for p in filtered if p not in keys ]
157+ return keys_in_select + others_in_select
158+
159+ return all_props
39160
40161
41162def _rows_from_entities (
@@ -96,16 +217,33 @@ def download_parquet_odata(
96217 f"EntitySet { es_name !r} not found in OData service metadata"
97218 ) from e
98219
99- props = _entity_properties (client , es_name )
220+ # When $select is provided, only include selected columns in output
221+ # This avoids all-NULL columns for unselected properties
222+ props = _entity_properties (client , es_name , select = select )
223+
224+ # If $expand is specified, add navigation property names to props
225+ # so that expanded data is captured in the output
226+ if expand :
227+ expand_props = [
228+ e .strip ().split ("/" )[0 ] for e in expand .split ("," ) if e .strip ()
229+ ]
230+ for ep in expand_props :
231+ if ep not in props :
232+ props .append (ep )
100233
101234 # Optional total count (may be expensive on some services)
102235 total_count : Optional [int ] = None
103236 if log_row_count :
104- try :
105- total_count = es_proxy .get_entities ().count ().execute ()
106- logger .info (" (total rows: %s)" , f"{ total_count :,} " )
107- except Exception as e :
108- logger .warning ("Failed to COUNT() for %s: %s" , es_name , e )
237+ if row_limit and row_limit > 0 :
238+ logger .info (
239+ " (row count skipped; ROW_LIMIT is set – using limit instead)"
240+ )
241+ else :
242+ try :
243+ total_count = es_proxy .get_entities ().count ().execute ()
244+ logger .info (" (total rows: %s)" , f"{ total_count :,} " )
245+ except Exception as e :
246+ logger .warning ("Failed to COUNT() for %s: %s" , es_name , e )
109247 else :
110248 logger .info (" (row count skipped; LOG_ROW_COUNT disabled)" )
111249
@@ -139,12 +277,42 @@ def download_parquet_odata(
139277 req = req .filter (filter_txt )
140278
141279 if next_url :
142- # Continue server-driven paging
280+ # Continue server-driven paging (e.g., $skiptoken)
281+ # Some OData services use server-driven paging exclusively
143282 try :
144283 req = es_proxy .get_entities ().next_url (next_url ) # type: ignore[attr-defined]
145- except Exception :
284+ # Re-apply options to the next_url request in case they're not preserved
285+ if select :
286+ try :
287+ req = req .select (select )
288+ except Exception :
289+ pass
290+ if expand :
291+ try :
292+ req = req .expand (expand )
293+ except Exception :
294+ pass
295+ if filter_txt :
296+ try :
297+ req = req .filter (filter_txt )
298+ except Exception :
299+ pass
300+ except Exception as e :
146301 # Fallback to skip/top if next_url API not supported
147- req = es_proxy .get_entities ().skip (skip ).top (top_n )
302+ logger .debug (
303+ "Server-driven paging (next_url) not supported for %s, "
304+ "falling back to $skip/$top: %s" ,
305+ es_name ,
306+ e ,
307+ )
308+ req = es_proxy .get_entities ()
309+ if select :
310+ req = req .select (select )
311+ if expand :
312+ req = req .expand (expand )
313+ if filter_txt :
314+ req = req .filter (filter_txt )
315+ req = req .skip (skip ).top (top_n )
148316 next_url = None
149317 else :
150318 req = req .skip (skip ).top (top_n )
@@ -169,7 +337,10 @@ def download_parquet_odata(
169337 break
170338
171339 df = pl .DataFrame (rows )
172- out_path = os .path .join (output_dir , f"{ es_name } _part{ part_idx :04d} .parquet" )
340+ # Include run_id in filename to avoid conflicts with concurrent runs
341+ out_path = os .path .join (
342+ output_dir , f"{ es_name } _{ run_id } _part{ part_idx :04d} .parquet"
343+ )
173344 df .write_parquet (out_path )
174345 created_files .append (os .path .basename (out_path ))
175346 wrote_any = True
0 commit comments