Skip to content

Commit 514563a

Browse files
committed
odata_to_staging improvements
* Prevent silent NULLs for common OData scalar-ish types (Decimal, UUID, time, timedelta, bytes); add tests to verify * Skip COUNT() when ROW_LIMIT is set * Reject duplicate entity-set names (case-insensitive) to avoid downstream table collisions * Fix LOG_FILE in examples to odata_to_staging.log * Add unit coverage for the scalar conversions * Pagination: improved next_url handling to re-apply query options and added debug logging when falling back to $skip/$top * Updated _entity_properties() to accept a select parameter and filter columns when $select is used. Also added logic to include navigation properties when $expand is specified * Fix $expand/nested values dropping: added _to_jsonable() helper and updated _to_scalar() to JSON-serialize lists, dicts, and object proxies instead of dropping the * Avoid concurrent file naming issues; changed filename pattern to include run_id
1 parent 057393a commit 514563a

7 files changed

Lines changed: 901 additions & 23 deletions

odata_to_staging/.env.example

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -82,7 +82,7 @@ LOG_LEVEL = INFO
8282
# Of logs in een logbestand moeten worden opgeslagen
8383
LOG_TO_FILE = True
8484
# Locatie van het logbestand (wordt aangemaakt indien deze nog niet bestaat)
85-
LOG_FILE = logs/sql_to_staging.log
85+
LOG_FILE = logs/odata_to_staging.log
8686
# Max. grootte (in bytes) van het logbestand voordat er een nieuw bestand wordt gestart (rotatie). Voorbeeld: 5.000.000 ≈ 5 MB
8787
LOG_ROTATE_BYTES = 5000000
8888
# Aantal oude logbestanden dat bewaard blijft bij rotatie (bijv. app.log.1 t/m app.log.N)

odata_to_staging/config.ini.example

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -82,10 +82,10 @@ LOG_LEVEL = INFO
8282
# Of logs in een logbestand moeten worden opgeslagen
8383
LOG_TO_FILE = True
8484
# Locatie van het logbestand (wordt aangemaakt indien deze nog niet bestaat)
85-
LOG_FILE = logs/sql_to_staging.log
85+
LOG_FILE = logs/odata_to_staging.log
8686
# Max. grootte (in bytes) van het logbestand voordat er een nieuw bestand wordt gestart (rotatie). Voorbeeld: 5.000.000 ≈ 5 MB
8787
LOG_ROTATE_BYTES = 5000000
8888
# Aantal oude logbestanden dat bewaard blijft bij rotatie (bijv. app.log.1 t/m app.log.N)
8989
LOG_BACKUP_COUNT = 3
9090
# Optioneel: eigen opmaak van logregels. Laat weg voor standaard; voorbeeld toont tijd, level, logger en bericht
91-
# LOG_FORMAT = %(asctime)s %(levelname)-8s [%(name)s] %(message)s
91+
# LOG_FORMAT = %(asctime)s %(levelname)-8s [%(name)s] %(message)s

odata_to_staging/functions/download_parquet_odata.py

Lines changed: 189 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -2,40 +2,161 @@
22
import json
33
import uuid
44
import 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

810
import polars as pl
911

1012

1113
logger = 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+
1463
def _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

41162
def _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

odata_to_staging/main.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -130,6 +130,20 @@ def main() -> None:
130130
if not entities or len(entities) != len(raw_entities):
131131
raise ValueError(f"ODATA_ENTITY_SETS contains empty items: {entities_str!r}")
132132

133+
# Avoid table collisions later (destination upload normalizes identifiers in most dialects)
134+
seen: set[str] = set()
135+
dupes: list[str] = []
136+
for e in entities:
137+
k = e.casefold()
138+
if k in seen:
139+
dupes.append(e)
140+
seen.add(k)
141+
if dupes:
142+
raise ValueError(
143+
"ODATA_ENTITY_SETS contains duplicate EntitySet names (case-insensitive): "
144+
+ ", ".join(dupes)
145+
)
146+
133147
page_size = cast(
134148
int,
135149
get_config_value(

odata_to_staging/tests/test_download_parquet_odata_pagination_no_count.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -119,7 +119,10 @@ def test_download_parquet_odata_pagination_no_count(tmp_path):
119119
manifest = json.load(f)
120120

121121
# Expect 3 part files, not truncated at 1
122-
part_files = [f for f in manifest["files"] if f.startswith("Items_part")]
122+
# Filename pattern is now Items_{run_id}_part####.parquet
123+
part_files = [
124+
f for f in manifest["files"] if f.startswith("Items_") and "_part" in f
125+
]
123126
assert len(part_files) == 3, f"Expected 3 part files, got {part_files}"
124127

125128
# Read back total rows to confirm all 5 exported

0 commit comments

Comments
 (0)