Skip to content

Commit b3c7a1f

Browse files
author
Saurabh Badenkal
committed
Extract CreateMultiple/UpsertMultiple IDs from batch response body
BatchResult.created_ids now extracts GUIDs from two sources: - entity_id from OData-EntityId header (individual POST creates) - data['Ids'] array from CreateMultiple/UpsertMultiple action responses Previously, bulk creates via CreateMultiple returned 200 OK with {'Ids': [...]} in the body but created_ids only looked at the OData-EntityId header (which is absent for action responses). Added 7 new unit tests covering: - CreateMultiple response body parsing - Mixed single + bulk creates in one batch - Non-string ID filtering - Failed CreateMultiple exclusion - Full multipart response simulation
1 parent 29d3cdb commit b3c7a1f

2 files changed

Lines changed: 153 additions & 3 deletions

File tree

src/PowerPlatform/Dataverse/models/batch.py

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -87,5 +87,20 @@ def has_errors(self) -> bool:
8787

8888
@property
8989
def created_ids(self) -> List[str]:
90-
"""GUIDs from all successful create operations (where entity_id is set)."""
91-
return [r.entity_id for r in self.succeeded if r.entity_id is not None]
90+
"""GUIDs from all successful create operations.
91+
92+
Collects IDs from two sources:
93+
94+
- ``entity_id`` extracted from the ``OData-EntityId`` response header
95+
(individual POST creates return ``204 No Content`` with this header).
96+
- ``data["Ids"]`` from ``CreateMultiple`` / ``UpsertMultiple`` action
97+
responses (these return ``200 OK`` with ``{"Ids": [...]}`` in the
98+
body instead of per-record headers).
99+
"""
100+
ids: List[str] = []
101+
for r in self.succeeded:
102+
if r.entity_id is not None:
103+
ids.append(r.entity_id)
104+
elif r.data is not None and isinstance(r.data.get("Ids"), list):
105+
ids.extend(i for i in r.data["Ids"] if isinstance(i, str))
106+
return ids

tests/unit/data/test_batch_edge_cases.py

Lines changed: 136 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -643,9 +643,144 @@ def test_single_failure_makes_has_errors_true(self):
643643
self.assertTrue(result.has_errors)
644644
self.assertEqual(len(result.failed), 1)
645645

646+
def test_created_ids_from_create_multiple_response_body(self):
647+
"""CreateMultiple returns IDs in data['Ids'], not in entity_id header."""
648+
responses = [
649+
# CreateMultiple response: 200 OK with {"Ids": [...]} body
650+
BatchItemResponse(
651+
status_code=200,
652+
entity_id=None,
653+
data={"Ids": ["guid-1", "guid-2", "guid-3"]},
654+
),
655+
]
656+
result = BatchResult(responses=responses)
657+
self.assertEqual(result.created_ids, ["guid-1", "guid-2", "guid-3"])
658+
659+
def test_created_ids_combines_header_and_body_ids(self):
660+
"""created_ids includes both OData-EntityId (single create) and Ids array (bulk)."""
661+
responses = [
662+
# Single create: entity_id from header
663+
BatchItemResponse(status_code=204, entity_id="single-id"),
664+
# CreateMultiple: Ids from body
665+
BatchItemResponse(
666+
status_code=200,
667+
entity_id=None,
668+
data={"Ids": ["bulk-id-1", "bulk-id-2"]},
669+
),
670+
]
671+
result = BatchResult(responses=responses)
672+
self.assertEqual(result.created_ids, ["single-id", "bulk-id-1", "bulk-id-2"])
673+
674+
def test_created_ids_ignores_non_string_ids_in_body(self):
675+
"""Non-string values in data['Ids'] are filtered out."""
676+
responses = [
677+
BatchItemResponse(
678+
status_code=200,
679+
data={"Ids": ["good-id", 12345, None, "another-id"]},
680+
),
681+
]
682+
result = BatchResult(responses=responses)
683+
self.assertEqual(result.created_ids, ["good-id", "another-id"])
684+
685+
def test_created_ids_skips_failed_create_multiple(self):
686+
"""Failed CreateMultiple responses should not contribute IDs."""
687+
responses = [
688+
BatchItemResponse(
689+
status_code=400,
690+
data={"error": {"code": "0x123", "message": "fail"}},
691+
),
692+
]
693+
result = BatchResult(responses=responses)
694+
self.assertEqual(result.created_ids, [])
695+
696+
697+
# ---------------------------------------------------------------------------
698+
# 12. CreateMultiple response parsing in batch
699+
# ---------------------------------------------------------------------------
700+
701+
702+
class TestCreateMultipleInBatch(unittest.TestCase):
703+
"""CreateMultiple action returns 200 with {Ids: [...]} in the body."""
704+
705+
def test_create_multiple_response_parsed(self):
706+
"""A 200 OK CreateMultiple response has IDs in the body, not in headers."""
707+
ids_body = json.dumps({"Ids": ["aaa-111", "bbb-222", "ccc-333"]})
708+
batch_boundary = "batchresponse_cm123"
709+
resp_text = (
710+
f"--{batch_boundary}\r\n"
711+
"Content-Type: application/http\r\n"
712+
"Content-Transfer-Encoding: binary\r\n"
713+
"\r\n"
714+
"HTTP/1.1 200 OK\r\n"
715+
"Content-Type: application/json; odata.metadata=minimal\r\n"
716+
"OData-Version: 4.0\r\n"
717+
"\r\n"
718+
f"{ids_body}\r\n"
719+
f"--{batch_boundary}--\r\n"
720+
)
721+
722+
mock_response = MagicMock()
723+
mock_response.headers = {"Content-Type": f'multipart/mixed; boundary="{batch_boundary}"'}
724+
mock_response.text = resp_text
725+
726+
od = _make_od()
727+
client = _BatchClient(od)
728+
result = client._parse_batch_response(mock_response)
729+
730+
self.assertFalse(result.has_errors)
731+
self.assertEqual(len(result.succeeded), 1)
732+
# entity_id should be None (no OData-EntityId header for CreateMultiple)
733+
self.assertIsNone(result.succeeded[0].entity_id)
734+
# But data should contain the Ids array
735+
self.assertEqual(result.succeeded[0].data["Ids"], ["aaa-111", "bbb-222", "ccc-333"])
736+
# And created_ids should extract them
737+
self.assertEqual(result.created_ids, ["aaa-111", "bbb-222", "ccc-333"])
738+
739+
def test_mixed_single_and_bulk_creates(self):
740+
"""Batch with both individual POST create and CreateMultiple."""
741+
single_guid = "11111111-1111-1111-1111-111111111111"
742+
ids_body = json.dumps({"Ids": ["bulk-1", "bulk-2"]})
743+
batch_boundary = "batchresponse_mix_cm"
744+
resp_text = (
745+
# Individual create: 204 with OData-EntityId
746+
f"--{batch_boundary}\r\n"
747+
"Content-Type: application/http\r\n"
748+
"Content-Transfer-Encoding: binary\r\n"
749+
"\r\n"
750+
"HTTP/1.1 204 No Content\r\n"
751+
"OData-Version: 4.0\r\n"
752+
f"OData-EntityId: https://org.crm.dynamics.com/api/data/v9.2/"
753+
f"accounts({single_guid})\r\n"
754+
"\r\n"
755+
"\r\n"
756+
# CreateMultiple: 200 with Ids body
757+
f"--{batch_boundary}\r\n"
758+
"Content-Type: application/http\r\n"
759+
"Content-Transfer-Encoding: binary\r\n"
760+
"\r\n"
761+
"HTTP/1.1 200 OK\r\n"
762+
"Content-Type: application/json\r\n"
763+
"\r\n"
764+
f"{ids_body}\r\n"
765+
f"--{batch_boundary}--\r\n"
766+
)
767+
768+
mock_response = MagicMock()
769+
mock_response.headers = {"Content-Type": f'multipart/mixed; boundary="{batch_boundary}"'}
770+
mock_response.text = resp_text
771+
772+
od = _make_od()
773+
client = _BatchClient(od)
774+
result = client._parse_batch_response(mock_response)
775+
776+
self.assertFalse(result.has_errors)
777+
self.assertEqual(len(result.succeeded), 2)
778+
# All 3 IDs should appear in created_ids
779+
self.assertEqual(result.created_ids, [single_guid, "bulk-1", "bulk-2"])
780+
646781

647782
# ---------------------------------------------------------------------------
648-
# 12. Multipart parsing edge cases
783+
# 13. Multipart parsing edge cases
649784
# ---------------------------------------------------------------------------
650785

651786

0 commit comments

Comments
 (0)