@@ -352,6 +352,110 @@ def _create_multiple(self, entity_set: str, table_schema_name: str, records: Lis
352352 return out
353353 return []
354354
355+ def _build_alternate_key_str (self , alternate_key : Dict [str , Any ]) -> str :
356+ """Build an OData alternate key segment from a mapping of key names to values.
357+
358+ String values are single-quoted and escaped; all other values are rendered as-is.
359+
360+ :param alternate_key: Mapping of alternate key attribute names to their values.
361+ :type alternate_key: ``dict[str, Any]``
362+
363+ :return: Comma-separated key=value pairs suitable for use in a URL segment.
364+ :rtype: ``str``
365+ """
366+ parts = []
367+ for k , v in alternate_key .items ():
368+ k_lower = k .lower () if isinstance (k , str ) else k
369+ if isinstance (v , str ):
370+ v_escaped = self ._escape_odata_quotes (v )
371+ parts .append (f"{ k_lower } ='{ v_escaped } '" )
372+ else :
373+ parts .append (f"{ k_lower } ={ v } " )
374+ return "," .join (parts )
375+
376+ def _upsert (
377+ self ,
378+ entity_set : str ,
379+ table_schema_name : str ,
380+ alternate_key : Dict [str , Any ],
381+ record : Dict [str , Any ],
382+ ) -> None :
383+ """Upsert a single record using an alternate key.
384+
385+ Issues a PATCH request to ``{entity_set}({key_pairs})`` where ``key_pairs``
386+ is the OData alternate key segment built from ``alternate_key``. Creates the
387+ record if it does not exist; updates it if it does.
388+
389+ :param entity_set: Resolved entity set (plural) name.
390+ :type entity_set: ``str``
391+ :param table_schema_name: Schema name of the table.
392+ :type table_schema_name: ``str``
393+ :param alternate_key: Mapping of alternate key attribute names to their values
394+ used to identify the target record in the URL.
395+ :type alternate_key: ``dict[str, Any]``
396+ :param record: Attribute payload to set on the record.
397+ :type record: ``dict[str, Any]``
398+
399+ :return: ``None``
400+ :rtype: ``None``
401+ """
402+ record = self ._lowercase_keys (record )
403+ record = self ._convert_labels_to_ints (table_schema_name , record )
404+ key_str = self ._build_alternate_key_str (alternate_key )
405+ url = f"{ self .api } /{ entity_set } ({ key_str } )"
406+ self ._request ("patch" , url , json = record , expected = (200 , 201 , 204 ))
407+
408+ def _upsert_multiple (
409+ self ,
410+ entity_set : str ,
411+ table_schema_name : str ,
412+ alternate_keys : List [Dict [str , Any ]],
413+ records : List [Dict [str , Any ]],
414+ ) -> None :
415+ """Upsert multiple records using the collection-bound ``UpsertMultiple`` action.
416+
417+ Each target is formed by merging the corresponding alternate key fields and record
418+ fields. The ``@odata.type`` annotation is injected automatically if absent.
419+
420+ :param entity_set: Resolved entity set (plural) name.
421+ :type entity_set: ``str``
422+ :param table_schema_name: Schema name of the table.
423+ :type table_schema_name: ``str``
424+ :param alternate_keys: List of alternate key dictionaries, one per record.
425+ Order is significant: ``alternate_keys[i]`` must correspond to ``records[i]``.
426+ Python ``list`` preserves insertion order, so the correspondence is guaranteed
427+ as long as both lists are built from the same source in the same order.
428+ :type alternate_keys: ``list[dict[str, Any]]``
429+ :param records: List of record payload dictionaries, one per record.
430+ Must be the same length as ``alternate_keys``.
431+ :type records: ``list[dict[str, Any]]``
432+
433+ :return: ``None``
434+ :rtype: ``None``
435+
436+ :raises ValueError: If ``alternate_keys`` and ``records`` differ in length.
437+ """
438+ if len (alternate_keys ) != len (records ):
439+ raise ValueError (
440+ f"alternate_keys and records must have the same length " f"({ len (alternate_keys )} != { len (records )} )"
441+ )
442+ logical_name = table_schema_name .lower ()
443+ targets : List [Dict [str , Any ]] = []
444+ for alt_key , record in zip (alternate_keys , records ):
445+ combined : Dict [str , Any ] = {}
446+ combined .update (self ._lowercase_keys (alt_key ))
447+ record_processed = self ._lowercase_keys (record )
448+ record_processed = self ._convert_labels_to_ints (table_schema_name , record_processed )
449+ combined .update (record_processed )
450+ if "@odata.type" not in combined :
451+ combined ["@odata.type" ] = f"Microsoft.Dynamics.CRM.{ logical_name } "
452+ key_str = self ._build_alternate_key_str (alt_key )
453+ combined ["@odata.id" ] = f"{ entity_set } ({ key_str } )"
454+ targets .append (combined )
455+ payload = {"Targets" : targets }
456+ url = f"{ self .api } /{ entity_set } /Microsoft.Dynamics.CRM.UpsertMultiple"
457+ self ._request ("post" , url , json = payload , expected = (200 , 201 , 204 ))
458+
355459 # --- Derived helpers for high-level client ergonomics ---
356460 def _primary_id_attr (self , table_schema_name : str ) -> str :
357461 """Return primary key attribute using metadata; error if unavailable."""
0 commit comments