Skip to content

Commit aa1e7c7

Browse files
author
Samson Gebre
committed
enhance upsert validation and add unit tests for alternate key handling
1 parent 606e31b commit aa1e7c7

4 files changed

Lines changed: 76 additions & 12 deletions

File tree

src/PowerPlatform/Dataverse/data/_odata.py

Lines changed: 20 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -358,11 +358,20 @@ def _build_alternate_key_str(self, alternate_key: Dict[str, Any]) -> str:
358358
String values are single-quoted and escaped; all other values are rendered as-is.
359359
360360
:param alternate_key: Mapping of alternate key attribute names to their values.
361+
Must be a non-empty dict with string keys.
361362
:type alternate_key: ``dict[str, Any]``
362363
363364
:return: Comma-separated key=value pairs suitable for use in a URL segment.
364365
:rtype: ``str``
366+
367+
:raises ValueError: If ``alternate_key`` is empty.
368+
:raises TypeError: If any key in ``alternate_key`` is not a string.
365369
"""
370+
if not alternate_key:
371+
raise ValueError("alternate_key must be a non-empty dict")
372+
bad_keys = [k for k in alternate_key if not isinstance(k, str)]
373+
if bad_keys:
374+
raise TypeError(f"alternate_key keys must be strings; got: {bad_keys!r}")
366375
parts = []
367376
for k, v in alternate_key.items():
368377
k_lower = k.lower() if isinstance(k, str) else k
@@ -433,20 +442,26 @@ def _upsert_multiple(
433442
:return: ``None``
434443
:rtype: ``None``
435444
436-
:raises ValueError: If ``alternate_keys`` and ``records`` differ in length.
445+
:raises ValueError: If ``alternate_keys`` and ``records`` differ in length, or if
446+
any record payload contains an alternate key field with a conflicting value.
437447
"""
438448
if len(alternate_keys) != len(records):
439449
raise ValueError(
440-
f"alternate_keys and records must have the same length " f"({len(alternate_keys)} != {len(records)})"
450+
f"alternate_keys and records must have the same length "
451+
f"({len(alternate_keys)} != {len(records)})"
441452
)
442453
logical_name = table_schema_name.lower()
443454
targets: List[Dict[str, Any]] = []
444455
for alt_key, record in zip(alternate_keys, records):
445-
combined: Dict[str, Any] = {}
446-
combined.update(self._lowercase_keys(alt_key))
456+
alt_key_lower = self._lowercase_keys(alt_key)
447457
record_processed = self._lowercase_keys(record)
448458
record_processed = self._convert_labels_to_ints(table_schema_name, record_processed)
449-
combined.update(record_processed)
459+
conflicting = {k for k in set(alt_key_lower) & set(record_processed) if alt_key_lower[k] != record_processed[k]}
460+
if conflicting:
461+
raise ValueError(
462+
f"record payload conflicts with alternate_key on fields: {sorted(conflicting)!r}"
463+
)
464+
combined: Dict[str, Any] = {**alt_key_lower, **record_processed}
450465
if "@odata.type" not in combined:
451466
combined["@odata.type"] = f"Microsoft.Dynamics.CRM.{logical_name}"
452467
key_str = self._build_alternate_key_str(alt_key)

src/PowerPlatform/Dataverse/operations/records.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -343,7 +343,11 @@ def upsert(self, table: str, items: List[Union[UpsertItem, Dict[str, Any]]]) ->
343343
for i in items:
344344
if isinstance(i, UpsertItem):
345345
normalized.append(i)
346-
elif isinstance(i, dict) and isinstance(i.get("alternate_key"), dict) and isinstance(i.get("record"), dict):
346+
elif (
347+
isinstance(i, dict)
348+
and isinstance(i.get("alternate_key"), dict)
349+
and isinstance(i.get("record"), dict)
350+
):
347351
normalized.append(UpsertItem(alternate_key=i["alternate_key"], record=i["record"]))
348352
else:
349353
raise TypeError("Each item must be a UpsertItem or a dict with 'alternate_key' and 'record' keys")
Lines changed: 35 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,26 @@ def test_equal_lengths_does_not_raise(self):
5757
self.assertEqual(len(post_calls), 1)
5858
self.assertIn("UpsertMultiple", post_calls[0].args[1])
5959

60+
def test_record_conflicts_with_alternate_key_raises_value_error(self):
61+
"""_upsert_multiple raises ValueError when a record field contradicts its alternate key."""
62+
with self.assertRaises(ValueError) as ctx:
63+
self.od._upsert_multiple(
64+
"accounts",
65+
"account",
66+
[{"accountnumber": "ACC-001"}],
67+
[{"accountnumber": "ACC-WRONG", "name": "Contoso"}],
68+
)
69+
self.assertIn("accountnumber", str(ctx.exception))
70+
71+
def test_record_matching_alternate_key_field_does_not_raise(self):
72+
"""_upsert_multiple does not raise when a record field matches its alternate key value."""
73+
self.od._upsert_multiple(
74+
"accounts",
75+
"account",
76+
[{"accountnumber": "ACC-001"}],
77+
[{"accountnumber": "ACC-001", "name": "Contoso"}],
78+
)
79+
6080

6181
class TestBuildAlternateKeyStr(unittest.TestCase):
6282
"""Unit tests for _ODataClient._build_alternate_key_str."""
@@ -76,7 +96,9 @@ def test_single_int_value(self):
7696

7797
def test_composite_key_string_and_string(self):
7898
"""Composite key with two string values produces comma-separated pairs."""
79-
result = self.od._build_alternate_key_str({"accountnumber": "ACC-001", "address1_postalcode": "98052"})
99+
result = self.od._build_alternate_key_str(
100+
{"accountnumber": "ACC-001", "address1_postalcode": "98052"}
101+
)
80102
self.assertEqual(result, "accountnumber='ACC-001',address1_postalcode='98052'")
81103

82104
def test_composite_key_string_and_int(self):
@@ -94,6 +116,16 @@ def test_single_quote_in_value_is_escaped(self):
94116
result = self.od._build_alternate_key_str({"name": "O'Brien"})
95117
self.assertEqual(result, "name='O''Brien'")
96118

119+
def test_empty_dict_raises_value_error(self):
120+
"""Empty alternate_key raises ValueError."""
121+
with self.assertRaises(ValueError):
122+
self.od._build_alternate_key_str({})
123+
124+
def test_non_string_key_raises_type_error(self):
125+
"""Non-string key raises TypeError."""
126+
with self.assertRaises(TypeError):
127+
self.od._build_alternate_key_str({1: "ACC-001"})
128+
97129

98130
class TestUpsert(unittest.TestCase):
99131
"""Unit tests for _ODataClient._upsert."""
@@ -128,7 +160,8 @@ def test_url_contains_composite_alternate_key(self):
128160
{"name": "Contoso"},
129161
)
130162
call = self._patch_call()
131-
self.assertIn("accountnumber='ACC-001',address1_postalcode='98052'", call.args[1])
163+
expected_key = "accountnumber='ACC-001',address1_postalcode='98052'"
164+
self.assertIn(expected_key, call.args[1])
132165

133166
def test_record_keys_lowercased(self):
134167
"""Record field names are lowercased before sending."""

tests/unit/test_records_operations.py

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -191,11 +191,17 @@ def test_upsert_multiple_calls_upsert_multiple(self):
191191
"UpsertItem",
192192
[
193193
UpsertItem(
194-
alternate_key={"accountnumber": "ACC-001", "address1_postalcode": "98052"},
194+
alternate_key={
195+
"accountnumber": "ACC-001",
196+
"address1_postalcode": "98052",
197+
},
195198
record={"name": "Contoso Ltd"},
196199
),
197200
UpsertItem(
198-
alternate_key={"accountnumber": "ACC-002", "address1_postalcode": "10001"},
201+
alternate_key={
202+
"accountnumber": "ACC-002",
203+
"address1_postalcode": "10001",
204+
},
199205
record={"name": "Fabrikam Inc"},
200206
),
201207
],
@@ -204,11 +210,17 @@ def test_upsert_multiple_calls_upsert_multiple(self):
204210
"dict",
205211
[
206212
{
207-
"alternate_key": {"accountnumber": "ACC-001", "address1_postalcode": "98052"},
213+
"alternate_key": {
214+
"accountnumber": "ACC-001",
215+
"address1_postalcode": "98052",
216+
},
208217
"record": {"name": "Contoso Ltd"},
209218
},
210219
{
211-
"alternate_key": {"accountnumber": "ACC-002", "address1_postalcode": "10001"},
220+
"alternate_key": {
221+
"accountnumber": "ACC-002",
222+
"address1_postalcode": "10001",
223+
},
212224
"record": {"name": "Fabrikam Inc"},
213225
},
214226
],

0 commit comments

Comments
 (0)