From 5bce3c5d95eeccfe3eac144742d911095ae9629b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=89loi=20Rivard?= Date: Mon, 14 Sep 2026 09:49:16 +0200 Subject: [PATCH] feat: build a patch from two resources --- doc/changelog.rst | 4 + doc/explanation/patch.rst | 12 + doc/how-to/build-a-patch.rst | 122 +++++++++ doc/how-to/index.rst | 1 + scim2_models/messages/patch_op.py | 183 ++++++++++++++ tests/test_patch_op_build.py | 401 ++++++++++++++++++++++++++++++ 6 files changed, 723 insertions(+) create mode 100644 doc/how-to/build-a-patch.rst create mode 100644 tests/test_patch_op_build.py diff --git a/doc/changelog.rst b/doc/changelog.rst index f6a9d0a..3e12bb6 100644 --- a/doc/changelog.rst +++ b/doc/changelog.rst @@ -44,6 +44,10 @@ Added :class:`~scim2_models.ResponseParameters` a client sent, instead of its ``attributes`` and ``excludedAttributes`` spelled out one by one. A :class:`~scim2_models.SearchRequest` is one, so a server answering ``POST /.search`` passes the request it received. :issue:`141` +- :meth:`~scim2_models.PatchOp.build_from` builds the patch turning one resource state into + another. Only the attributes the wanted state names take part in the comparison, so what a peer + maintains and the caller does not model survives the modification — which is what a PATCH + offers over a PUT. See :doc:`how-to/build-a-patch`. :issue:`104` - lark is a new dependency. Changed diff --git a/doc/explanation/patch.rst b/doc/explanation/patch.rst index 260500b..7761ce5 100644 --- a/doc/explanation/patch.rst +++ b/doc/explanation/patch.rst @@ -168,5 +168,17 @@ model does not declare. Table 9 lists ``invalidFilter`` as applying to a "PATCH so it answers what goes wrong between the brackets. That covers an unknown sub-attribute, a comparison the attribute cannot take, and a selection over an attribute holding a single value. +Building a patch rather than applying one +----------------------------------------- + +An application holding both the state a peer has and the state it should have does not have to +spell the operations out. :meth:`~scim2_models.PatchOp.build_from` compares the two states and +builds them, restricted to the attributes the wanted state names, so what the peer maintains and +the application does not model is left alone. + +What that builder can express follows from this page. A multi-valued attribute is replaced as a +whole, since :rfc:`RFC7643 §2.4 <7643#section-2.4>` gives its entries no identity to match one +state against the other. See :doc:`../how-to/build-a-patch`. + To inspect or change a resource directly with the same path syntax, outside a PATCH request, use :doc:`../how-to/access-resource-values`. diff --git a/doc/how-to/build-a-patch.rst b/doc/how-to/build-a-patch.rst new file mode 100644 index 0000000..34c4ddd --- /dev/null +++ b/doc/how-to/build-a-patch.rst @@ -0,0 +1,122 @@ +Build a patch from two resource states +====================================== + +Use this guide when an application holds the state a peer is believed to have and the state it +should have, and must send the modification. :meth:`~scim2_models.PatchOp.build_from` compares the +two and returns the :class:`~scim2_models.PatchOp` that closes the gap. + +Build the patch +--------------- + +Pass the state the peer holds first, then the state it should hold: + +.. doctest:: + + >>> from scim2_models import Name, PatchOp, User + >>> distant = User(user_name="bjensen", name=Name(given_name="Barbara", family_name="Jensen")) + >>> wanted = User(user_name="bjensen", name=Name(given_name="Babs", family_name="Jensen")) + >>> patch = PatchOp.build_from(distant, wanted) + >>> patch.model_dump()["Operations"] + [{'op': 'replace', 'path': 'name.givenName', 'value': 'Babs'}] + +A complex attribute is compared sub-attribute by sub-attribute, and each one gets its own path. +Targeting ``name`` as a whole would replace it entirely and drop what the operation does not +carry. + +Leave alone what the wanted state does not name +----------------------------------------------- + +Only the attributes the wanted state names take part in the comparison. This is what separates a +patch from the :meth:`~scim2_models.Resource.replace` it stands for: an attribute the peer +maintains and the application does not model survives the modification. + +.. doctest:: + + >>> distant = User(user_name="bjensen", nick_name="Barb", title="CEO") + >>> wanted = User(user_name="bjensen", nick_name="Babs") + >>> patch = PatchOp.build_from(distant, wanted) + >>> patch.model_dump()["Operations"] + [{'op': 'replace', 'path': 'nickName', 'value': 'Babs'}] + +Naming an attribute with no value says the opposite. ``title=None`` reads as "clear the title", +where an unnamed ``title`` reads as "leave it alone": + +.. doctest:: + + >>> wanted = User(user_name="bjensen", title=None) + >>> patch = PatchOp.build_from(distant, wanted) + >>> patch.model_dump()["Operations"] + [{'op': 'remove', 'path': 'title'}] + +The same rule reaches sub-attributes, and an extension is named by its schema URN: + +.. doctest:: + + >>> from scim2_models import EnterpriseUser + >>> distant = User[EnterpriseUser](user_name="bjensen") + >>> distant[EnterpriseUser] = EnterpriseUser(department="Tour") + >>> wanted = User[EnterpriseUser](user_name="bjensen") + >>> wanted[EnterpriseUser] = EnterpriseUser(department="Chess") + >>> patch = PatchOp.build_from(distant, wanted) + >>> str(patch.operations[0].path) + 'urn:ietf:params:scim:schemas:extension:enterprise:2.0:User:department' + +Send nothing when nothing changed +--------------------------------- + +Two states that agree have no patch to describe: an ``Operations`` array must hold at least one +operation, per :rfc:`RFC7644 §3.5.2 <7644#section-3.5.2>`. :meth:`~scim2_models.PatchOp.build_from` +returns :data:`None`, so an application tests it before sending a request: + +.. doctest:: + + >>> PatchOp.build_from(wanted, wanted) is None + True + +Know how collections are compared +--------------------------------- + +A multi-valued attribute is replaced as a whole. Only the sub-attributes the wanted entries name +decide whether it changed, so the sub-attributes the peer alone maintains do not read as a +difference: + +.. doctest:: + + >>> from scim2_models import Email + >>> distant = User(emails=[Email(value="barb@example.com", type="work", primary=True)]) + >>> wanted = User(emails=[Email(value="barb@example.com")]) + >>> PatchOp.build_from(distant, wanted) is None + True + +When the collection does change, the whole of it is replaced and the peer's own sub-attributes go +with it: + +.. doctest:: + + >>> wanted = User(emails=[Email(value="babs@example.com")]) + >>> patch = PatchOp.build_from(distant, wanted) + >>> patch.model_dump()["Operations"] + [{'op': 'replace', 'path': 'emails', 'value': [{'value': 'babs@example.com'}]}] + +The builder cannot do better: :rfc:`RFC7643 §2.4 <7643#section-2.4>` gives the entries of a +multi-valued attribute no identity, so nothing distinguishes an entry that changed from an entry +that was removed and another that was added. An application that knows how to identify its own +entries writes those operations itself, targeting a sub-attribute through a filter such as +``emails[type eq "work"].value``. + +Read what the patch never carries +--------------------------------- + +A ``readOnly`` attribute is left out however much the two states differ: +:rfc:`RFC7644 §3.5.2 <7644#section-3.5.2>` forbids a client to modify one, and naming it would +make the patch invalid. That covers :attr:`~scim2_models.Resource.id`, +:attr:`~scim2_models.Resource.meta` and :attr:`~scim2_models.User.groups`, along with the +``readOnly`` sub-attributes of a complex attribute. + +An ``immutable`` attribute that holds no value yet is added, which +:rfc:`RFC7644 §3.5.2 <7644#section-3.5.2>` allows. One that already holds a value cannot be +modified, and :meth:`~scim2_models.PatchOp.build_from` raises a +:class:`~scim2_models.MutabilityException` rather than building a request the peer must refuse. + +An attribute a server never returns, such as :attr:`~scim2_models.User.password`, reads as unset on +the side of the peer. Every patch built from a state naming it carries it again. diff --git a/doc/how-to/index.rst b/doc/how-to/index.rst index 80e9461..1ed6757 100644 --- a/doc/how-to/index.rst +++ b/doc/how-to/index.rst @@ -8,6 +8,7 @@ ends where an application resumes its own work, and assumes the :doc:`../overvie :maxdepth: 1 access-resource-values + build-a-patch build-filters define-custom-models describe-a-scim-service diff --git a/scim2_models/messages/patch_op.py b/scim2_models/messages/patch_op.py index eb5424e..a49f010 100644 --- a/scim2_models/messages/patch_op.py +++ b/scim2_models/messages/patch_op.py @@ -1,9 +1,11 @@ +from collections.abc import Iterator from enum import Enum from inspect import isclass from typing import Annotated from typing import Any from typing import Generic from typing import TypeVar +from typing import cast from pydantic import BaseModel as PydanticBaseModel from pydantic import Field @@ -79,6 +81,137 @@ def _resolved_field(resource_class: type[BaseModel], attr_name: str) -> str | No return _find_field_name(resource_class, attr_name) +_ENVELOPE_FIELDS = frozenset({"schemas"}) +"""Fields that carry the payload rather than the state it describes.""" + + +def _attribute_name(model: type[BaseModel], field_name: str) -> str: + """Return the SCIM spelling of a field, as a path segment.""" + return model.model_fields[field_name].serialization_alias or field_name + + +def _asserted_sub_attributes(entries: Any) -> set[str]: + """Return the sub-attributes the entries of a wanted state name.""" + asserted: set[str] = set() + for entry in entries or []: + if isinstance(entry, BaseModel): + asserted |= entry.model_fields_set + return asserted + + +def _projection(entries: Any, asserted: set[str]) -> list[Any]: + """Reduce the entries of a multi-valued attribute to what is worth comparing. + + :rfc:`RFC7643 §2.4 <7643#section-2.4>` gives no significance to the order of + a multi-valued attribute, so the projections are sorted before comparison. + """ + projected = [ + tuple(sorted((name, getattr(entry, name, None)) for name in asserted)) + if isinstance(entry, BaseModel) + else entry + for entry in entries or [] + ] + return sorted(projected, key=repr) + + +def _operation( + path: str, old: Any, new: Any, mutability: Mutability | None +) -> tuple["PatchOperation.Op", str, Any]: + """Return the operation writing *new* where the current state holds *old*. + + Called once a difference is established. :rfc:`RFC7644 §3.5.2.3 + <7644#section-3.5.2.3>` has a service provider treat a ``replace`` on an + unset target as an ``add``, so a single operation covers both. An immutable + attribute is the exception: :rfc:`RFC7644 §3.5.2 <7644#section-3.5.2>` lets + a client add a value to one that had none, and nothing else. + """ + if mutability == Mutability.immutable: + if old is not None: + raise MutabilityException( + attribute=path, mutability="immutable", operation="replace" + ) + return PatchOperation.Op.add, path, new + + if new is None or new == []: + return PatchOperation.Op.remove, path, None + + return PatchOperation.Op.replace_, path, new + + +def _diff_multi_valued( + path: str, old: Any, new: Any, mutability: Mutability | None +) -> Iterator[tuple["PatchOperation.Op", str, Any]]: + """Diff a multi-valued attribute, which is replaced as a whole. + + Only the sub-attributes the wanted entries name take part in the + comparison, so the sub-attributes the peer alone maintains do not read as a + difference. When the collection does change it is replaced entirely: + :rfc:`RFC7643 §2.4 <7643#section-2.4>` gives the entries no identity, so an + entry that changed cannot be told from a removed one and an added one. + """ + asserted = _asserted_sub_attributes(new) + if _projection(old, asserted) == _projection(new, asserted): + return + + yield _operation(path, old, new, mutability) + + +def _diff_sub_object( + prefix: str, + path: str, + old: Any, + new: Any, + mutability: Mutability | None, +) -> Iterator[tuple["PatchOperation.Op", str, Any]]: + """Diff a complex attribute or an extension, one sub-attribute at a time.""" + if new is not None: + yield from _diff(old, new, prefix) + return + + if old is not None: + yield _operation(path, old, None, mutability) + + +def _diff( + before: Any, after: Any, prefix: str = "" +) -> Iterator[tuple["PatchOperation.Op", str, Any]]: + """Yield the operations turning *before* into *after*. + + Only the attributes *after* names are candidates: what a wanted state never + mentions is left to the peer. Attributes are visited in declaration order, + so a diff is reproducible. + """ + model = type(after) + info = model.__scim_info__ + for field_name in model.model_fields: + if field_name not in after.model_fields_set: + continue + + if field_name in _ENVELOPE_FIELDS: + continue + + mutability = model.get_field_annotation(field_name, Mutability) + if mutability == Mutability.read_only: + continue + + old = getattr(before, field_name, None) if before is not None else None + new = getattr(after, field_name, None) + path = f"{prefix}{_attribute_name(model, field_name)}" + + if model.get_field_multiplicity(field_name): + yield from _diff_multi_valued(path, old, new, mutability) + + elif field_name in info.extensions: + urn = info.attribute_urns[field_name] + yield from _diff_sub_object(f"{urn}:", urn, old, new, mutability) + + elif field_name in info.complex_fields: + yield from _diff_sub_object(f"{path}.", path, old, new, mutability) + + elif old != new: + yield _operation(path, old, new, mutability) + + class PatchOperation(ComplexAttribute, Generic[ResourceT]): class Op(str, Enum): replace_ = "replace" @@ -362,6 +495,56 @@ def validate_operations(self, info: ValidationInfo) -> Self: return self + @classmethod + def build_from( + cls, before: ResourceT, after: ResourceT + ) -> "PatchOp[ResourceT] | None": + """Build the patch turning a resource state into another one. + + Only the attributes *after* names take part in the comparison: what a + wanted state never mentions is left to the peer, which is what + distinguishes a patch from the :meth:`~scim2_models.Resource.replace` + it stands for. An attribute named with no value is removed, as + ``title=None`` reads as "clear the title" where an unnamed ``title`` + reads as "leave it alone". + + A multi-valued attribute is replaced as a whole, and only the + sub-attributes the wanted entries name decide whether it changed. + Read-only attributes never appear in the patch. + + >>> from scim2_models import PatchOp, User + >>> patch = PatchOp.build_from(User(nick_name="Barb"), User(nick_name="Babs")) + >>> patch.model_dump()["Operations"] + [{'op': 'replace', 'path': 'nickName', 'value': 'Babs'}] + + :param before: The state the peer is believed to hold. + :param after: The state the peer should hold. + :return: The patch to send, or :data:`None` when the two states agree. + :raises MutabilityException: If an immutable attribute already holding a + value would be modified. + :raises TypeError: If the two states are not of the same resource type. + """ + if type(before) is not type(after): + raise TypeError( + "Cannot compare two states of different types: " + f"{type(before).__name__} and {type(after).__name__}" + ) + + # Subscripted through the call the syntax stands for: mypy reads the + # index of a generic as a type, not as a value. + model = type(after) + operation_class: Any = PatchOperation.__class_getitem__(model) + path_class = Path.__class_getitem__(model) + operations = [ + operation_class(op=op, path=path_class(path), value=value) + for op, path, value in _diff(before, after) + ] + if not operations: + return None + + patch_class = PatchOp.__class_getitem__(model) + return cast("PatchOp[ResourceT]", patch_class(operations=operations)) + def patch(self, resource: ResourceT, scim_policy: ScimPolicy | None = None) -> bool: """Apply all PATCH operations to the given SCIM resource in sequence. diff --git a/tests/test_patch_op_build.py b/tests/test_patch_op_build.py new file mode 100644 index 0000000..db78232 --- /dev/null +++ b/tests/test_patch_op_build.py @@ -0,0 +1,401 @@ +from typing import Annotated + +import pytest + +from scim2_models import URN +from scim2_models import Context +from scim2_models import Email +from scim2_models import EnterpriseUser +from scim2_models import Group +from scim2_models import Manager +from scim2_models import Meta +from scim2_models import MutabilityException +from scim2_models import Name +from scim2_models import PatchOp +from scim2_models import PatchOperation +from scim2_models import User +from scim2_models.annotations import Mutability +from scim2_models.resources.resource import Resource + + +def paths(patch): + """Return the path and the operation of every operation of a patch.""" + return [(operation.op.value, str(operation.path)) for operation in patch.operations] + + +def test_a_changed_attribute_becomes_a_replace(): + """RFC7644 §3.5.2.3 has a replace on an unset target behave as an add, so the diff never has to choose between the two.""" + before = User(user_name="bjensen", nick_name="Barb") + after = User(user_name="bjensen", nick_name="Babs") + + patch = PatchOp.build_from(before, after) + + assert paths(patch) == [("replace", "nickName")] + assert patch.operations[0].value == "Babs" + + +def test_an_unchanged_attribute_produces_no_operation(): + """An attribute the two states agree on is left out of the patch.""" + before = User(user_name="bjensen", nick_name="Babs") + after = User(user_name="bjensen", nick_name="Babs") + + assert PatchOp.build_from(before, after) is None + + +def test_an_attribute_set_to_none_becomes_a_remove(): + """A caller who writes title=None means "clear the title", where a caller who never names title means "leave it alone".""" + before = User(user_name="bjensen", title="CEO") + after = User(user_name="bjensen", title=None) + + patch = PatchOp.build_from(before, after) + + assert paths(patch) == [("remove", "title")] + + +def test_an_attribute_the_wanted_state_does_not_name_is_left_alone(): + """Unlike the PUT it replaces, a patch leaves an attribute the peer manages and the caller does not model untouched.""" + before = User(user_name="bjensen", nick_name="Barb", title="CEO") + after = User(user_name="bjensen", nick_name="Babs") + + patch = PatchOp.build_from(before, after) + + assert paths(patch) == [("replace", "nickName")] + + +def test_a_changed_sub_attribute_is_targeted_by_its_own_path(): + """Targeting name as a whole would replace it entirely and drop the sub-attributes the operation does not carry.""" + before = User(name=Name(given_name="Barbara", family_name="Jensen")) + after = User(name=Name(given_name="Babs", family_name="Jensen")) + + patch = PatchOp.build_from(before, after) + + assert paths(patch) == [("replace", "name.givenName")] + assert patch.operations[0].value == "Babs" + + +def test_a_complex_attribute_set_to_none_is_removed_whole(): + """Naming a complex attribute with no value removes it at its own path.""" + before = User(name=Name(given_name="Barbara")) + after = User(name=None) + + patch = PatchOp.build_from(before, after) + + assert paths(patch) == [("remove", "name")] + + +def test_a_complex_attribute_the_current_state_lacks_is_built_sub_attribute_by_sub_attribute(): + """A complex attribute missing from the current state gets one path per sub-attribute.""" + before = User(user_name="bjensen") + after = User(user_name="bjensen", name=Name(given_name="Babs")) + + patch = PatchOp.build_from(before, after) + + assert paths(patch) == [("replace", "name.givenName")] + + +def test_a_sub_attribute_the_wanted_state_does_not_name_is_left_alone(): + """The restriction to what the wanted state names reaches sub-attributes.""" + before = User(name=Name(given_name="Barbara", family_name="Jensen")) + after = User(name=Name(given_name="Babs")) + + patch = PatchOp.build_from(before, after) + + assert paths(patch) == [("replace", "name.givenName")] + + +def test_read_only_attributes_are_never_patched(): + """RFC7644 §3.5.2 forbids a client to modify a read-only attribute, and naming it would make the patch itself invalid.""" + before = User(user_name="bjensen", id="old", meta=Meta(resource_type="User")) + after = User( + user_name="bjensen", id="new", meta=Meta(resource_type="User", version="W/2") + ) + + assert PatchOp.build_from(before, after) is None + + +def test_the_schemas_attribute_is_never_patched(): + """The schemas attribute belongs to the envelope, not to the state it describes.""" + before = User[EnterpriseUser](user_name="bjensen") + before.schemas = [str(User.__schema__), str(EnterpriseUser.__schema__)] + after = User[EnterpriseUser](user_name="bjensen") + after.schemas = [str(User.__schema__)] + + assert PatchOp.build_from(before, after) is None + + +def test_an_immutable_attribute_the_current_state_lacks_is_added(): + """RFC7644 §3.5.2 allows adding a value to an immutable attribute that had none.""" + + class Immutable(Resource): + __schema__ = URN("urn:test:Immutable") + + once: Annotated[str | None, Mutability.immutable] = None + + before = Immutable() + after = Immutable(once="settled") + + patch = PatchOp.build_from(before, after) + + assert paths(patch) == [("add", "once")] + + +def test_changing_an_immutable_attribute_is_refused(): + """An immutable attribute that already holds a value cannot be modified.""" + + class Immutable(Resource): + __schema__ = URN("urn:test:Immutable") + + once: Annotated[str | None, Mutability.immutable] = None + + before = Immutable(once="settled") + after = Immutable(once="moved") + + with pytest.raises(MutabilityException): + PatchOp.build_from(before, after) + + +def test_two_states_of_different_types_cannot_be_compared(): + """Diffing unrelated models would read every attribute of one as absent from the other.""" + with pytest.raises(TypeError): + PatchOp.build_from(User(user_name="bjensen"), Group(display_name="admins")) + + +def test_the_patch_a_diff_builds_is_parameterized_by_the_resource(): + """The operations of the patch resolve their paths against the resource.""" + patch = PatchOp.build_from( + User(nick_name="Barb"), + User(nick_name="Babs"), + ) + + assert isinstance(patch, PatchOp[User]) + assert isinstance(patch.operations[0], PatchOperation[User]) + + +def test_a_multi_valued_attribute_is_replaced_whole(): + """RFC7643 §2.4 gives the entries no identity, so an entry that changed cannot be told from a removed one and an added one.""" + before = User(emails=[Email(value="barb@example.com")]) + after = User(emails=[Email(value="babs@example.com")]) + + patch = PatchOp.build_from(before, after) + + assert paths(patch) == [("replace", "emails")] + assert patch.operations[0].value == [Email(value="babs@example.com")] + + +def test_a_multi_valued_attribute_emptied_is_removed(): + """A collection the wanted state leaves empty is removed at its own path.""" + before = User(emails=[Email(value="barb@example.com")]) + after = User(emails=[]) + + patch = PatchOp.build_from(before, after) + + assert paths(patch) == [("remove", "emails")] + + +def test_only_the_sub_attributes_the_wanted_entries_name_are_compared(): + """A caller that only knows the address of an email leaves the peer free to qualify it, and re-sending the same address changes nothing.""" + before = User(emails=[Email(value="barb@example.com", type="work", primary=True)]) + after = User(emails=[Email(value="barb@example.com")]) + + assert PatchOp.build_from(before, after) is None + + +def test_the_order_of_multi_valued_entries_is_not_a_change(): + """RFC7643 §2.4 gives no significance to the order of a multi-valued attribute.""" + before = User(emails=[Email(value="a@example.com"), Email(value="b@example.com")]) + after = User(emails=[Email(value="b@example.com"), Email(value="a@example.com")]) + + assert PatchOp.build_from(before, after) is None + + +def test_a_multi_valued_attribute_of_scalars_is_compared_by_value(): + """Entries that are not complex have no sub-attribute to project on.""" + + class Tagged(Resource): + __schema__ = URN("urn:test:Tagged") + + tags: list[str] | None = None + + assert PatchOp.build_from(Tagged(tags=["a"]), Tagged(tags=["a"])) is None + + patch = PatchOp.build_from(Tagged(tags=["a"]), Tagged(tags=["a", "b"])) + assert paths(patch) == [("replace", "tags")] + + +def test_an_extension_attribute_is_targeted_by_its_qualified_path(): + """An attribute an extension declares is named by its schema URN.""" + before = User[EnterpriseUser](user_name="bjensen") + before[EnterpriseUser] = EnterpriseUser(department="Tour") + after = User[EnterpriseUser](user_name="bjensen") + after[EnterpriseUser] = EnterpriseUser(department="Chess") + + patch = PatchOp.build_from(before, after) + + assert paths(patch) == [ + ( + "replace", + "urn:ietf:params:scim:schemas:extension:enterprise:2.0:User:department", + ) + ] + + +def test_an_extension_the_current_state_lacks_is_patched_attribute_by_attribute(): + """An extension missing from the current state gets one path per attribute.""" + before = User[EnterpriseUser](user_name="bjensen") + after = User[EnterpriseUser](user_name="bjensen") + after[EnterpriseUser] = EnterpriseUser(department="Tour") + + patch = PatchOp.build_from(before, after) + + assert paths(patch) == [ + ( + "replace", + "urn:ietf:params:scim:schemas:extension:enterprise:2.0:User:department", + ) + ] + + +def test_an_extension_set_to_none_is_removed_whole(): + """Naming an extension with no value removes it at its schema URN.""" + before = User[EnterpriseUser](user_name="bjensen") + before[EnterpriseUser] = EnterpriseUser(department="Tour") + after = User[EnterpriseUser](user_name="bjensen") + after.EnterpriseUser = None + + patch = PatchOp.build_from(before, after) + + assert paths(patch) == [ + ("remove", "urn:ietf:params:scim:schemas:extension:enterprise:2.0:User") + ] + + +def test_a_sub_attribute_of_an_extension_is_targeted_by_its_own_path(): + """The dotted path of a complex attribute carries its extension URN.""" + before = User[EnterpriseUser](user_name="bjensen") + before[EnterpriseUser] = EnterpriseUser(manager=Manager(value="jan")) + after = User[EnterpriseUser](user_name="bjensen") + after[EnterpriseUser] = EnterpriseUser(manager=Manager(value="ada")) + + patch = PatchOp.build_from(before, after) + + assert paths(patch) == [ + ( + "replace", + "urn:ietf:params:scim:schemas:extension:enterprise:2.0:User:manager.value", + ) + ] + + +def test_a_read_only_sub_attribute_is_never_patched(): + """RFC7643 §4.3 declares manager.displayName read-only, where manager.value is writable.""" + before = User[EnterpriseUser](user_name="bjensen") + before[EnterpriseUser] = EnterpriseUser(manager=Manager(display_name="Jan")) + after = User[EnterpriseUser](user_name="bjensen") + after[EnterpriseUser] = EnterpriseUser(manager=Manager(display_name="Ada")) + + assert PatchOp.build_from(before, after) is None + + +def enterprise(**kwargs): + """Return a user carrying an enterprise extension.""" + user = User[EnterpriseUser](user_name="bjensen") + user[EnterpriseUser] = EnterpriseUser(**kwargs) + return user + + +@pytest.mark.parametrize( + ("before", "after"), + [ + pytest.param( + User(nick_name="Barb"), User(nick_name="Babs"), id="changed-attribute" + ), + pytest.param(User(title="CEO"), User(title=None), id="cleared-attribute"), + pytest.param( + User(name=Name(given_name="Barbara", family_name="Jensen")), + User(name=Name(given_name="Babs")), + id="changed-sub-attribute", + ), + pytest.param( + User(name=Name(given_name="Barbara")), User(name=None), id="cleared-complex" + ), + pytest.param( + User(user_name="bjensen"), + User(user_name="bjensen", name=Name(given_name="Babs")), + id="created-complex", + ), + pytest.param( + User(emails=[Email(value="barb@example.com")]), + User(emails=[Email(value="babs@example.com")]), + id="changed-collection", + ), + pytest.param( + User(emails=[Email(value="barb@example.com")]), + User(emails=[]), + id="emptied-collection", + ), + pytest.param( + enterprise(department="Tour"), + enterprise(department="Chess"), + id="changed-extension-attribute", + ), + pytest.param( + User[EnterpriseUser](user_name="bjensen"), + enterprise(department="Tour"), + id="created-extension", + ), + pytest.param( + User(nick_name="Barb", name=Name(given_name="Barbara"), title="CEO"), + User(nick_name="Babs", name=Name(given_name="Babs"), title=None), + id="several-attributes-at-once", + ), + ], +) +def test_applying_a_built_patch_settles_the_difference(before, after): + """The property the whole builder answers to: whatever the wanted state asserts, the peer holds it once the patch is applied.""" + patched = before.model_copy(deep=True) + patch = PatchOp.build_from(before, after) + + assert patch.patch(patched) is True + assert PatchOp.build_from(patched, after) is None + + +def test_applying_a_built_patch_preserves_what_the_wanted_state_ignores(): + """The attributes the wanted state never names survive the modification.""" + before = User( + user_name="bjensen", + title="CEO", + emails=[Email(value="barb@example.com", type="work", primary=True)], + ) + after = User(user_name="bjensen", nick_name="Babs") + patched = before.model_copy(deep=True) + + PatchOp.build_from(before, after).patch(patched) + + assert patched.title == "CEO" + assert patched.emails[0].type == "work" + assert patched.nick_name == "Babs" + + +def test_a_built_patch_travels_as_a_patch_request(): + """The patch a diff builds serializes into a PATCH request payload.""" + before = enterprise(department="Tour") + after = User[EnterpriseUser](user_name="bjensen", name=Name(given_name="Babs")) + after[EnterpriseUser] = EnterpriseUser(department="Chess") + + patch = PatchOp.build_from(before, after) + payload = patch.model_dump(scim_ctx=Context.RESOURCE_PATCH_REQUEST) + + assert payload == { + "schemas": ["urn:ietf:params:scim:api:messages:2.0:PatchOp"], + "Operations": [ + {"op": "replace", "path": "name.givenName", "value": "Babs"}, + { + "op": "replace", + "path": "urn:ietf:params:scim:schemas:extension:enterprise:2.0:User:department", + "value": "Chess", + }, + ], + } + assert PatchOp[User[EnterpriseUser]].model_validate( + payload, scim_ctx=Context.RESOURCE_PATCH_REQUEST + )