@@ -375,3 +375,209 @@ def sql_join(
375375 src = from_alias or from_table .lower ()
376376 tgt = to_alias or to_lower [0 ]
377377 return f"JOIN { to_lower } { tgt } " f"ON { src } .{ j ['column' ]} = { tgt } .{ j ['target_pk' ]} "
378+
379+ # ===========================================================
380+ # OData helpers -- eliminate friction for records.get() users
381+ # ===========================================================
382+
383+ # -------------------------------------------------------- odata_select
384+
385+ def odata_select (
386+ self ,
387+ table : str ,
388+ * ,
389+ include_system : bool = False ,
390+ ) -> List [str ]:
391+ """Return a list of column logical names suitable for ``$select``.
392+
393+ Can be passed directly to ``client.records.get(table, select=...)``.
394+
395+ :param table: Schema name of the table (e.g. ``"account"``).
396+ :type table: :class:`str`
397+ :param include_system: Include system columns (default ``False``).
398+ :type include_system: :class:`bool`
399+
400+ :return: List of lowercase column logical names.
401+ :rtype: list[str]
402+
403+ Example::
404+
405+ cols = client.query.odata_select("account")
406+ for page in client.records.get("account", select=cols, top=10):
407+ for r in page:
408+ print(r)
409+ """
410+ columns = self .sql_columns (table , include_system = include_system )
411+ return [c ["name" ] for c in columns ]
412+
413+ # ------------------------------------------------------- odata_expands
414+
415+ def odata_expands (
416+ self ,
417+ table : str ,
418+ ) -> List [Dict [str , Any ]]:
419+ """Discover all ``$expand`` navigation properties from a table.
420+
421+ Returns entries for each outgoing lookup (single-valued navigation
422+ property). Each entry contains the exact PascalCase navigation
423+ property name needed for ``$expand`` and ``@odata.bind``, plus
424+ the target entity set name.
425+
426+ :param table: Schema name of the table (e.g. ``"contact"``).
427+ :type table: :class:`str`
428+
429+ :return: List of dicts, each with:
430+
431+ - ``nav_property`` -- PascalCase navigation property for $expand
432+ - ``target_table`` -- target entity logical name
433+ - ``target_entity_set`` -- target entity set (for @odata.bind)
434+ - ``lookup_attribute`` -- the lookup column logical name
435+ - ``relationship`` -- relationship schema name
436+
437+ :rtype: list[dict[str, typing.Any]]
438+
439+ Example::
440+
441+ expands = client.query.odata_expands("contact")
442+ for e in expands:
443+ print(f"expand={e['nav_property']} -> {e['target_table']}")
444+
445+ # Use in a query
446+ e = next(e for e in expands if e['target_table'] == 'account')
447+ for page in client.records.get("contact",
448+ select=["fullname"],
449+ expand=[e['nav_property']]):
450+ ...
451+ """
452+ table_lower = table .lower ()
453+ rels = self ._client .tables .list_table_relationships (table )
454+
455+ result : List [Dict [str , Any ]] = []
456+ for r in rels :
457+ ref_entity = (r .get ("ReferencingEntity" ) or "" ).lower ()
458+ if ref_entity != table_lower :
459+ continue
460+ nav_prop = r .get ("ReferencingEntityNavigationPropertyName" , "" )
461+ target = r .get ("ReferencedEntity" , "" )
462+ lookup_attr = r .get ("ReferencingAttribute" , "" )
463+ schema = r .get ("SchemaName" , "" )
464+ if not nav_prop or not target :
465+ continue
466+
467+ # Resolve entity set name for @odata.bind
468+ target_set = ""
469+ try :
470+ with self ._client ._scoped_odata () as od :
471+ target_set = od ._entity_set_from_schema_name (target )
472+ except Exception :
473+ pass
474+
475+ result .append (
476+ {
477+ "nav_property" : nav_prop ,
478+ "target_table" : target ,
479+ "target_entity_set" : target_set ,
480+ "lookup_attribute" : lookup_attr ,
481+ "relationship" : schema ,
482+ }
483+ )
484+
485+ result .sort (key = lambda x : (x ["target_table" ], x ["nav_property" ]))
486+ return result
487+
488+ # -------------------------------------------------------- odata_expand
489+
490+ def odata_expand (
491+ self ,
492+ from_table : str ,
493+ to_table : str ,
494+ ) -> str :
495+ """Return the navigation property name to ``$expand`` from one table to another.
496+
497+ Discovers via relationship metadata. Returns the exact PascalCase
498+ string for the ``expand=`` parameter.
499+
500+ :param from_table: Schema name of the source table (e.g. ``"contact"``).
501+ :type from_table: :class:`str`
502+ :param to_table: Schema name of the target table (e.g. ``"account"``).
503+ :type to_table: :class:`str`
504+
505+ :return: The navigation property name (PascalCase).
506+ :rtype: :class:`str`
507+
508+ :raises ValueError: If no navigation property found for the target.
509+
510+ Example::
511+
512+ nav = client.query.odata_expand("contact", "account")
513+ # Returns e.g. "parentcustomerid_account"
514+ for page in client.records.get("contact",
515+ select=["fullname"],
516+ expand=[nav],
517+ top=5):
518+ for r in page:
519+ acct = r.get(nav) or {}
520+ print(f"{r['fullname']} -> {acct.get('name', 'N/A')}")
521+ """
522+ to_lower = to_table .lower ()
523+ expands = self .odata_expands (from_table )
524+ match = [e for e in expands if e ["target_table" ].lower () == to_lower ]
525+ if not match :
526+ raise ValueError (
527+ f"No navigation property found from '{ from_table } ' to "
528+ f"'{ to_table } '. Use client.query.odata_expands('{ from_table } ') "
529+ f"to see available targets."
530+ )
531+ return match [0 ]["nav_property" ]
532+
533+ # --------------------------------------------------------- odata_bind
534+
535+ def odata_bind (
536+ self ,
537+ from_table : str ,
538+ to_table : str ,
539+ target_id : str ,
540+ ) -> Dict [str , str ]:
541+ """Build an ``@odata.bind`` entry for setting a lookup field.
542+
543+ Auto-discovers the navigation property name and entity set name
544+ from metadata. Returns a single-entry dict that can be merged
545+ into a create or update payload.
546+
547+ :param from_table: Schema name of the entity being created/updated.
548+ :type from_table: :class:`str`
549+ :param to_table: Schema name of the target entity the lookup points to.
550+ :type to_table: :class:`str`
551+ :param target_id: GUID of the target record.
552+ :type target_id: :class:`str`
553+
554+ :return: A dict like ``{"NavProp@odata.bind": "/entityset(guid)"}``.
555+ :rtype: dict[str, str]
556+
557+ :raises ValueError: If no relationship found between the tables.
558+
559+ Example::
560+
561+ # Instead of manually constructing:
562+ # {"parentcustomerid_account@odata.bind": "/accounts(guid)"}
563+ # Just do:
564+ bind = client.query.odata_bind("contact", "account", acct_id)
565+ client.records.create("contact", {
566+ "firstname": "Jane",
567+ "lastname": "Doe",
568+ **bind,
569+ })
570+ """
571+ to_lower = to_table .lower ()
572+ expands = self .odata_expands (from_table )
573+ match = [e for e in expands if e ["target_table" ].lower () == to_lower and e ["target_entity_set" ]]
574+ if not match :
575+ raise ValueError (
576+ f"No relationship found from '{ from_table } ' to '{ to_table } '. "
577+ f"Use client.query.odata_expands('{ from_table } ') to see options."
578+ )
579+
580+ e = match [0 ]
581+ key = f"{ e ['nav_property' ]} @odata.bind"
582+ value = f"/{ e ['target_entity_set' ]} ({ target_id } )"
583+ return {key : value }
0 commit comments