Skip to content

Commit 78031fa

Browse files
committed
odata_to_staging: fix OData V4 parsing edge cases
1 parent 9370a41 commit 78031fa

3 files changed

Lines changed: 301 additions & 43 deletions

File tree

odata_to_staging/functions/download_parquet_odata.py

Lines changed: 14 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -125,7 +125,9 @@ def _is_v4_client(client: Any) -> bool:
125125
126126
OData v4 client has query_entities method, pyodata v2 uses entity_sets attribute.
127127
"""
128-
return hasattr(client, "query_entities") and hasattr(client, "get_entity_properties")
128+
return hasattr(client, "query_entities") and hasattr(
129+
client, "get_entity_properties"
130+
)
129131

130132

131133
def _entity_properties(
@@ -203,7 +205,6 @@ def _rows_from_dicts(
203205
return rows
204206

205207

206-
207208
def download_parquet_odata(
208209
client: Any,
209210
*,
@@ -240,7 +241,6 @@ def download_parquet_odata(
240241
else:
241242
logger.info("Using OData v2 client (pyodata) for data download")
242243

243-
244244
for es_name in entity_sets:
245245
logger.info("📥 Dumping OData EntitySet: %s", es_name)
246246

@@ -252,10 +252,12 @@ def download_parquet_odata(
252252

253253
# Validate entity set exists and get properties based on client type
254254
if is_v4:
255-
# OData v4 client
255+
# OData v4 client - uses case-insensitive lookup internally
256256
try:
257257
entity_set_names = client.get_entity_set_names()
258-
if es_name not in entity_set_names:
258+
# Case-insensitive check for validation message
259+
entity_set_names_lower = {n.lower(): n for n in entity_set_names}
260+
if es_name.lower() not in entity_set_names_lower:
259261
raise ValueError(
260262
f"EntitySet {es_name!r} not found in OData service metadata. "
261263
f"Available: {entity_set_names}"
@@ -267,7 +269,7 @@ def download_parquet_odata(
267269
f"EntitySet {es_name!r} not found in OData service metadata"
268270
) from e
269271

270-
# Get properties from v4 client
272+
# Get properties from v4 client (handles case-insensitive matching internally)
271273
props = client.get_entity_properties(es_name, select=select)
272274
else:
273275
# OData v2 client (pyodata)
@@ -301,7 +303,9 @@ def download_parquet_odata(
301303
else:
302304
try:
303305
if is_v4:
304-
total_count = client.count_entities(es_name, filter_expr=filter_txt)
306+
total_count = client.count_entities(
307+
es_name, filter_expr=filter_txt
308+
)
305309
else:
306310
total_count = es_proxy.get_entities().count().execute()
307311
if total_count is not None:
@@ -340,7 +344,9 @@ def download_parquet_odata(
340344
try:
341345
if next_url:
342346
# Follow @odata.nextLink for pagination
343-
entity_dicts, next_url = client.query_entities_from_url(next_url)
347+
entity_dicts, next_url = client.query_entities_from_url(
348+
next_url
349+
)
344350
else:
345351
entity_dicts, next_url = client.query_entities(
346352
es_name,

odata_to_staging/functions/odata_v4_client.py

Lines changed: 119 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,59 @@ def __init__(self, service_url: str, session: requests.Session):
8585
self.session = session
8686
self._schema: Optional[Dict[str, Any]] = None
8787

88+
@staticmethod
89+
def _extract_type_name(full_type: str) -> str:
90+
"""Extract type name from fully-qualified OData type.
91+
92+
Handles standard "Namespace.TypeName" format as well as bracket-quoted
93+
identifiers like "[Schema].[Type]" or "Namespace.[Type]".
94+
95+
Args:
96+
full_type: Fully-qualified type string (e.g., "Namespace.Type")
97+
98+
Returns:
99+
Just the type name portion
100+
"""
101+
if not full_type:
102+
return ""
103+
104+
# Handle bracket-quoted identifiers: "[Schema].[Type]" or "Namespace.[Type]"
105+
# Split on dots that are NOT inside brackets
106+
# Strategy: find the last segment, which may be bracket-quoted
107+
if "[" in full_type:
108+
# Find the last '[' that starts a bracket-quoted identifier
109+
last_bracket = full_type.rfind("[")
110+
if last_bracket > 0:
111+
# Check if there's a dot before the bracket
112+
if full_type[last_bracket - 1] == ".":
113+
return full_type[last_bracket:]
114+
# If the whole thing starts with '[', it might be the full type name
115+
if full_type.startswith("["):
116+
# Look for pattern like "[Schema].[Type]" - return last bracketed segment
117+
parts = []
118+
current = ""
119+
in_bracket = False
120+
for char in full_type:
121+
if char == "[":
122+
in_bracket = True
123+
current += char
124+
elif char == "]":
125+
in_bracket = False
126+
current += char
127+
elif char == "." and not in_bracket:
128+
if current:
129+
parts.append(current)
130+
current = ""
131+
else:
132+
current += char
133+
if current:
134+
parts.append(current)
135+
if parts:
136+
return parts[-1]
137+
138+
# Standard case: "Namespace.TypeName" -> "TypeName"
139+
return full_type.split(".")[-1]
140+
88141
@property
89142
def schema(self) -> Dict[str, Any]:
90143
"""Lazily load and return schema from $metadata."""
@@ -291,7 +344,8 @@ def _parse_entity_sets(
291344

292345
if name:
293346
# EntityType format may be "Namespace.TypeName", extract just the type name
294-
type_name = entity_type.split(".")[-1] if entity_type else ""
347+
# Handle bracket-quoted identifiers like "[Schema].[Type]" or "Namespace.[Type]"
348+
type_name = self._extract_type_name(entity_type) if entity_type else ""
295349
entity_sets.append(
296350
{
297351
"name": name,
@@ -309,6 +363,34 @@ def get_entity_set_names(self) -> List[str]:
309363
"""
310364
return [es["name"] for es in self.schema["entity_sets"]]
311365

366+
def _find_entity_set(self, entity_set_name: str) -> Optional[Dict[str, str]]:
367+
"""Find EntitySet by name with case-insensitive fallback.
368+
369+
Args:
370+
entity_set_name: Name of the EntitySet to find
371+
372+
Returns:
373+
EntitySet dict if found, None otherwise
374+
"""
375+
# Try exact match first
376+
entity_set = next(
377+
(es for es in self.schema["entity_sets"] if es["name"] == entity_set_name),
378+
None,
379+
)
380+
if entity_set:
381+
return entity_set
382+
383+
# Try case-insensitive match
384+
entity_set = next(
385+
(
386+
es
387+
for es in self.schema["entity_sets"]
388+
if es["name"].lower() == entity_set_name.lower()
389+
),
390+
None,
391+
)
392+
return entity_set
393+
312394
def get_entity_properties(
313395
self, entity_set_name: str, select: Optional[str] = None
314396
) -> List[str]:
@@ -324,26 +406,13 @@ def get_entity_properties(
324406
Raises:
325407
ValueError: If EntitySet or EntityType not found in schema
326408
"""
327-
# Find the entity type for this set
328-
entity_set = next(
329-
(es for es in self.schema["entity_sets"] if es["name"] == entity_set_name),
330-
None,
331-
)
409+
# Find the entity set with case-insensitive fallback
410+
entity_set = self._find_entity_set(entity_set_name)
332411
if not entity_set:
333412
raise ValueError(f"EntitySet {entity_set_name!r} not found in schema")
334413

335414
type_name = entity_set["entity_type"]
336-
entity_type = self.schema["entity_types"].get(type_name)
337-
if not entity_type:
338-
# Try case-insensitive match
339-
entity_type = next(
340-
(
341-
et
342-
for name, et in self.schema["entity_types"].items()
343-
if name.lower() == type_name.lower()
344-
),
345-
None,
346-
)
415+
entity_type = self._find_entity_type(type_name)
347416
if not entity_type:
348417
raise ValueError(
349418
f"EntityType {type_name!r} not found in schema for EntitySet {entity_set_name!r}"
@@ -365,6 +434,31 @@ def get_entity_properties(
365434

366435
return ordered
367436

437+
def _find_entity_type(self, type_name: str) -> Optional[Dict[str, Any]]:
438+
"""Find EntityType by name with case-insensitive fallback.
439+
440+
Args:
441+
type_name: Name of the EntityType to find
442+
443+
Returns:
444+
EntityType dict if found, None otherwise
445+
"""
446+
# Try exact match first
447+
entity_type = self.schema["entity_types"].get(type_name)
448+
if entity_type:
449+
return entity_type
450+
451+
# Try case-insensitive match
452+
entity_type = next(
453+
(
454+
et
455+
for name, et in self.schema["entity_types"].items()
456+
if name.lower() == type_name.lower()
457+
),
458+
None,
459+
)
460+
return entity_type
461+
368462
def get_navigation_properties(self, entity_set_name: str) -> List[str]:
369463
"""Return list of navigation property names for an EntitySet.
370464
@@ -377,25 +471,12 @@ def get_navigation_properties(self, entity_set_name: str) -> List[str]:
377471
Raises:
378472
ValueError: If EntitySet or EntityType not found in schema
379473
"""
380-
entity_set = next(
381-
(es for es in self.schema["entity_sets"] if es["name"] == entity_set_name),
382-
None,
383-
)
474+
entity_set = self._find_entity_set(entity_set_name)
384475
if not entity_set:
385476
raise ValueError(f"EntitySet {entity_set_name!r} not found in schema")
386477

387478
type_name = entity_set["entity_type"]
388-
entity_type = self.schema["entity_types"].get(type_name)
389-
if not entity_type:
390-
# Try case-insensitive match
391-
entity_type = next(
392-
(
393-
et
394-
for name, et in self.schema["entity_types"].items()
395-
if name.lower() == type_name.lower()
396-
),
397-
None,
398-
)
479+
entity_type = self._find_entity_type(type_name)
399480
if not entity_type:
400481
raise ValueError(
401482
f"EntityType {type_name!r} not found in schema for EntitySet {entity_set_name!r}"
@@ -429,7 +510,9 @@ def query_entities(
429510
Raises:
430511
ODataV4Error: If the query fails
431512
"""
432-
base_url = f"{self.service_url}/{entity_set_name}"
513+
# URL-encode entity set name to handle special characters like brackets
514+
encoded_name = quote(entity_set_name, safe="")
515+
base_url = f"{self.service_url}/{encoded_name}"
433516

434517
params: Dict[str, str] = {}
435518
if select:
@@ -528,7 +611,9 @@ def count_entities(
528611
Returns:
529612
Count of entities, or None if count not supported by service
530613
"""
531-
base_url = f"{self.service_url}/{entity_set_name}/$count"
614+
# URL-encode entity set name to handle special characters like brackets
615+
encoded_name = quote(entity_set_name, safe="")
616+
base_url = f"{self.service_url}/{encoded_name}/$count"
532617

533618
params: Dict[str, str] = {}
534619
if filter_expr:

0 commit comments

Comments
 (0)