Skip to content

Commit 37db360

Browse files
committed
feat: expose the value a search sorts a resource by
SearchRequest.sort_value returns the value that sort orders a resource by. A storage that pages by keyset can keep it to resume after that resource.
1 parent 14c856d commit 37db360

3 files changed

Lines changed: 78 additions & 0 deletions

File tree

‎doc/changelog.rst‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,15 @@
11
Changelog
22
=========
33

4+
[0.13.0] - Unreleased
5+
---------------------
6+
7+
Added
8+
^^^^^
9+
- :meth:`SearchRequest.sort_value <scim2_models.SearchRequest.sort_value>` returns the value
10+
:meth:`~scim2_models.SearchRequest.sort` orders a resource by. A storage that pages by keyset
11+
can resume after that resource.
12+
413
[0.12.1] - 2026-10-07
514
---------------------
615

‎scim2_models/messages/search_request.py‎

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -278,6 +278,30 @@ def sort_binding(self, model: type[BaseModel]) -> AttributeBinding | None:
278278
value_path = AttrPath(designated.attr, "value", designated.uri)
279279
return _resolve_attr_path(model, value_path, strict=False)
280280

281+
def sort_value(self, resource: ResourceT) -> Any:
282+
"""Return the value :meth:`sort` orders a resource by.
283+
284+
A storage that pages by keyset can keep it to resume after a resource.
285+
It is a :class:`str` for strings and references, in the form the
286+
:attr:`~scim2_models.ScimPolicy.comparison_key` of the policy gives.
287+
It is a :class:`bool`, :class:`int`, :class:`float` or
288+
:class:`~datetime.datetime` for the other SCIM types. A value of an
289+
attribute with a custom Python type is returned unchanged.
290+
:meth:`sort` puts the resources without a value last when ascending,
291+
and first when descending.
292+
293+
>>> from scim2_models import SearchRequest, User
294+
>>> SearchRequest(sort_by="userName").sort_value(User(user_name="BJensen"))
295+
'bjensen'
296+
297+
:param resource: The resource to read the value from.
298+
:returns: The value, or :data:`None` when :attr:`sort_by` is not set,
299+
when the resource has no value, when its type does not declare the
300+
attribute or cannot sort on it, or when the policy cannot prepare
301+
the string.
302+
"""
303+
return _sort_value(resource, self.sort_binding(type(resource)))
304+
281305
def sort(self, resources: Iterable[ResourceT]) -> list[ResourceT]:
282306
"""Order resources as :rfc:`RFC7644 §3.4.2.3 <7644#section-3.4.2.3>` describes.
283307

‎tests/test_search_request.py‎

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
from datetime import UTC
2+
from datetime import datetime
13
from typing import Annotated
24

35
import pytest
@@ -7,6 +9,7 @@
79
from scim2_models import ComplexAttribute
810
from scim2_models import EnterpriseUser
911
from scim2_models import Group
12+
from scim2_models import Meta
1013
from scim2_models import Mutability
1114
from scim2_models import Resource
1215
from scim2_models import Returned
@@ -703,6 +706,48 @@ def test_sort_binding_is_none_when_the_model_cannot_sort_on_the_attribute(
703706
assert SearchRequest(sort_by=attribute).sort_binding(model) is None
704707

705708

709+
def test_sort_value_without_sort_by_is_none():
710+
"""There is nothing to order the resource by."""
711+
assert SearchRequest().sort_value(User(user_name="bjensen")) is None
712+
713+
714+
def test_sort_value_is_the_string_sort_compares():
715+
"""A keyset storage resumes on the same form of the string as sort() orders by."""
716+
request = SearchRequest(sort_by="userName")
717+
assert request.sort_value(User(user_name="ÉMILE")) == "émile"
718+
719+
720+
def test_sort_value_reads_the_primary_entry_of_a_multivalued_attribute():
721+
"""RFC7644 §3.4.2.3 sorts on "the value of the primary attribute"."""
722+
user = User(
723+
user_name="bjensen",
724+
emails=[
725+
User.Emails(value="b@example.com"),
726+
User.Emails(value="A@example.com", primary=True),
727+
],
728+
)
729+
assert SearchRequest(sort_by="emails").sort_value(user) == "a@example.com"
730+
731+
732+
def test_sort_value_keeps_the_type_of_a_non_string_value():
733+
"""A storage gets the boolean or the date as sort() compares it."""
734+
when = datetime(2026, 10, 9, tzinfo=UTC)
735+
user = User(user_name="bjensen", active=True, meta=Meta(last_modified=when))
736+
assert SearchRequest(sort_by="active").sort_value(user) is True
737+
assert SearchRequest(sort_by="meta.lastModified").sort_value(user) == when
738+
739+
740+
def test_sort_value_is_none_without_a_value():
741+
"""sort() puts such a resource last when ascending and first when descending."""
742+
assert SearchRequest(sort_by="displayName").sort_value(User(user_name="b")) is None
743+
744+
745+
def test_sort_value_is_none_when_the_type_cannot_sort_on_the_attribute():
746+
"""A resource of a union whose type lacks the attribute is ordered as one without a value."""
747+
request = SearchRequest[User | Group](sort_by="userName")
748+
assert request.sort_value(Group(display_name="admins")) is None
749+
750+
706751
def ids(resources):
707752
"""Return the ids of the resources, in their order."""
708753
return [resource.id for resource in resources]

0 commit comments

Comments
 (0)