Skip to content

Commit 769785f

Browse files
committed
feat: primary attribute auto-exclusion
1 parent ac66269 commit 769785f

File tree

4 files changed

+247
-4
lines changed

4 files changed

+247
-4
lines changed

doc/changelog.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ Added
1111
- Validation that extension schemas are known during SCIM context validation.
1212
- Introduce SCIM exceptions hierarchy (:class:`~scim2_models.SCIMException` and subclasses) corresponding to RFC 7644 error types. :issue:`103`
1313
- :meth:`Error.from_validation_error <scim2_models.Error.from_validation_error>` to convert Pydantic :class:`~pydantic.ValidationError` to SCIM :class:`~scim2_models.Error`.
14+
- :meth:`PatchOp.patch <scim2_models.PatchOp.patch>` auto-excludes other ``primary`` values when setting one to ``True``. :issue:`116`
1415

1516
Changed
1617
^^^^^^^

scim2_models/attributes.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,11 @@ class MultiValuedComplexAttribute(ComplexAttribute):
3434

3535
primary: bool | None = None
3636
"""A Boolean value indicating the 'primary' or preferred attribute value
37-
for this attribute."""
37+
for this attribute.
38+
39+
Per :rfc:`RFC 7643 §2.4 <7643#section-2.4>`, the primary attribute value
40+
``True`` MUST appear no more than once in a multi-valued attribute list.
41+
"""
3842

3943
display: Annotated[str | None, Mutability.immutable] = None
4044
"""A human-readable name, primarily used for display purposes."""

scim2_models/messages/patch_op.py

Lines changed: 97 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
from typing import Generic
66
from typing import TypeVar
77

8+
from pydantic import BaseModel as PydanticBaseModel
89
from pydantic import Field
910
from pydantic import ValidationInfo
1011
from pydantic import field_validator
@@ -257,10 +258,14 @@ def patch(self, resource: ResourceT) -> bool:
257258
"add", "replace", and "remove". If any operation modifies the resource, the method
258259
returns True; otherwise, False.
259260
261+
Per :rfc:`RFC 7644 §3.5.2 <7644#section-3.5.2>`, when an operation sets a value's
262+
``primary`` sub-attribute to ``True``, any other values in the same multi-valued
263+
attribute will have their ``primary`` set to ``False`` automatically.
264+
260265
:param resource: The SCIM resource to patch. This object is modified in-place.
261-
:type resource: T
262266
:return: True if the resource was modified by any operation, False otherwise.
263-
:raises SCIMException: If an operation is invalid (e.g., invalid path, forbidden mutation).
267+
:raises InvalidValueException: If multiple values are marked as primary in a single
268+
operation, or if multiple primary values already exist before the patch.
264269
"""
265270
if not self.operations:
266271
return False
@@ -291,13 +296,102 @@ def _apply_add_replace(
291296
self, resource: Resource[Any], operation: PatchOperation[ResourceT]
292297
) -> bool:
293298
"""Apply an add or replace operation."""
299+
before_state = self._capture_primary_state(resource)
300+
294301
path = operation.path if operation.path is not None else Path("")
295-
return path.set(
302+
modified = path.set(
296303
resource, # type: ignore[arg-type]
297304
operation.value,
298305
is_add=operation.op == PatchOperation.Op.add,
299306
)
300307

308+
if modified:
309+
self._normalize_primary_after_patch(resource, before_state)
310+
311+
return modified
312+
313+
def _capture_primary_state(self, resource: Resource[Any]) -> dict[str, set[int]]:
314+
"""Capture indices of elements with primary=True for each multi-valued attribute."""
315+
state: dict[str, set[int]] = {}
316+
for field_name in type(resource).model_fields:
317+
if not resource.get_field_multiplicity(field_name):
318+
continue
319+
320+
field_value = getattr(resource, field_name, None)
321+
if not field_value:
322+
continue
323+
324+
element_type = resource.get_field_root_type(field_name)
325+
if (
326+
not element_type
327+
or not isclass(element_type)
328+
or not issubclass(element_type, PydanticBaseModel)
329+
or "primary" not in element_type.model_fields
330+
):
331+
continue
332+
333+
primary_indices = {
334+
i
335+
for i, item in enumerate(field_value)
336+
if getattr(item, "primary", None) is True
337+
}
338+
state[field_name] = primary_indices
339+
340+
return state
341+
342+
def _normalize_primary_after_patch(
343+
self, resource: Resource[Any], before_state: dict[str, set[int]]
344+
) -> None:
345+
"""Normalize primary attributes after a patch operation.
346+
347+
Per :rfc:`RFC 7644 §3.5.2 <7644#section-3.5.2>`: a PATCH operation that
348+
sets a value's "primary" sub-attribute to "true" SHALL cause the server
349+
to automatically set "primary" to "false" for any other values.
350+
"""
351+
for field_name in type(resource).model_fields:
352+
if not resource.get_field_multiplicity(field_name):
353+
continue
354+
355+
field_value = getattr(resource, field_name, None)
356+
if not field_value:
357+
continue
358+
359+
element_type = resource.get_field_root_type(field_name)
360+
if (
361+
not element_type
362+
or not isclass(element_type)
363+
or not issubclass(element_type, PydanticBaseModel)
364+
or "primary" not in element_type.model_fields
365+
):
366+
continue
367+
368+
current_primary_indices = {
369+
i
370+
for i, item in enumerate(field_value)
371+
if getattr(item, "primary", None) is True
372+
}
373+
374+
if len(current_primary_indices) <= 1:
375+
continue
376+
377+
before_primaries = before_state.get(field_name, set())
378+
new_primaries = current_primary_indices - before_primaries
379+
380+
if len(new_primaries) > 1:
381+
raise InvalidValueException(
382+
detail=f"Multiple values marked as primary in field '{field_name}'"
383+
)
384+
385+
if not new_primaries:
386+
raise InvalidValueException(
387+
detail=f"Multiple primary values already exist in field '{field_name}'"
388+
)
389+
390+
keep_index = next(iter(new_primaries))
391+
for i in current_primary_indices:
392+
if i != keep_index:
393+
field_value[i].primary = False
394+
301395
def _apply_remove(
302396
self, resource: Resource[Any], operation: PatchOperation[ResourceT]
303397
) -> bool:

tests/test_patch_op_replace.py

Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -219,3 +219,147 @@ class Dummy(Resource):
219219
)
220220
]
221221
)
222+
223+
224+
def test_primary_auto_exclusion_on_add():
225+
"""Test that adding an element with primary=true auto-excludes other primary values.
226+
227+
:rfc:`RFC 7644 §3.5.2 <7644#section-3.5.2>`: "a PATCH operation that sets a
228+
value's 'primary' sub-attribute to 'true' SHALL cause the server to
229+
automatically set 'primary' to 'false' for any other values in the array."
230+
"""
231+
from scim2_models import Email
232+
233+
user = User(
234+
emails=[
235+
Email(value="existing@example.com", primary=True),
236+
]
237+
)
238+
239+
patch = PatchOp[User](
240+
operations=[
241+
PatchOperation[User](
242+
op=PatchOperation.Op.add,
243+
path="emails",
244+
value={"value": "new@example.com", "primary": True},
245+
)
246+
]
247+
)
248+
249+
result = patch.patch(user)
250+
251+
assert result is True
252+
assert user.emails[0].primary is False
253+
assert user.emails[1].primary is True
254+
255+
256+
def test_primary_auto_exclusion_on_replace_list():
257+
"""Test that replacing a list with a new primary auto-excludes the old one."""
258+
from scim2_models import Email
259+
260+
user = User(
261+
emails=[
262+
Email(value="old@example.com", primary=True),
263+
]
264+
)
265+
266+
patch = PatchOp[User](
267+
operations=[
268+
PatchOperation[User](
269+
op=PatchOperation.Op.replace_,
270+
path="emails",
271+
value=[
272+
{"value": "old@example.com", "primary": False},
273+
{"value": "new@example.com", "primary": True},
274+
],
275+
)
276+
]
277+
)
278+
279+
result = patch.patch(user)
280+
281+
assert result is True
282+
assert user.emails[0].primary is False
283+
assert user.emails[1].primary is True
284+
285+
286+
def test_primary_no_change_when_single_primary():
287+
"""Test that no change occurs when there's only one primary after patch."""
288+
from scim2_models import Email
289+
290+
user = User(
291+
emails=[
292+
Email(value="a@example.com", primary=True),
293+
Email(value="b@example.com", primary=False),
294+
]
295+
)
296+
297+
patch = PatchOp[User](
298+
operations=[
299+
PatchOperation[User](
300+
op=PatchOperation.Op.add,
301+
path="emails",
302+
value={"value": "c@example.com", "primary": False},
303+
)
304+
]
305+
)
306+
307+
result = patch.patch(user)
308+
309+
assert result is True
310+
assert user.emails[0].primary is True
311+
assert user.emails[1].primary is False
312+
assert user.emails[2].primary is False
313+
314+
315+
def test_primary_auto_exclusion_rejects_multiple_new_primaries():
316+
"""Test that setting multiple new primaries in one operation raises an error."""
317+
from scim2_models import Email
318+
319+
user = User(
320+
emails=[
321+
Email(value="a@example.com", primary=False),
322+
Email(value="b@example.com", primary=False),
323+
]
324+
)
325+
326+
patch = PatchOp[User](
327+
operations=[
328+
PatchOperation[User](
329+
op=PatchOperation.Op.replace_,
330+
path="emails",
331+
value=[
332+
{"value": "a@example.com", "primary": True},
333+
{"value": "b@example.com", "primary": True},
334+
],
335+
)
336+
]
337+
)
338+
339+
with pytest.raises(Exception, match="Multiple values marked as primary"):
340+
patch.patch(user)
341+
342+
343+
def test_primary_auto_exclusion_rejects_preexisting_multiple_primaries():
344+
"""Test that patching data with preexisting multiple primaries raises an error."""
345+
from scim2_models import Email
346+
347+
user = User(
348+
emails=[
349+
Email(value="a@example.com", primary=True),
350+
Email(value="b@example.com", primary=True),
351+
]
352+
)
353+
354+
patch = PatchOp[User](
355+
operations=[
356+
PatchOperation[User](
357+
op=PatchOperation.Op.add,
358+
path="emails",
359+
value={"value": "c@example.com", "primary": False},
360+
)
361+
]
362+
)
363+
364+
with pytest.raises(Exception, match="Multiple primary values already exist"):
365+
patch.patch(user)

0 commit comments

Comments
 (0)