Skip to content

Commit 990c63c

Browse files
committed
doc: order resources by the entry a multi-valued sortBy picks
sort_resources read the primary flag off whatever get() returned, which holds entries for sortBy=emails but projected sub-attributes for sortBy=emails.value, so any path naming a sub-attribute raised an AttributeError. RFC7644 §3.4.2.3 picks the entry first, primary or else the first, and the sub-attribute is read from it, which turns sortBy=emails into the case where that sub-attribute is the value RFC7643 §2.4 reserves for it. A scalar entry is that value itself, and an extension left unset holds none.
1 parent 34078ff commit 990c63c

3 files changed

Lines changed: 160 additions & 15 deletions

File tree

doc/guides/_examples/integrations.py

Lines changed: 40 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
from scim2_models import AuthenticationScheme
99
from scim2_models import Bulk
1010
from scim2_models import ChangePassword
11-
from scim2_models import CaseExact
11+
from scim2_models import ComplexAttribute
1212
from scim2_models import ETag
1313
from scim2_models import Filter
1414
from scim2_models import InvalidPathException
@@ -22,6 +22,7 @@
2222
from scim2_models import Sort
2323
from scim2_models import UniquenessException
2424
from scim2_models import User
25+
from scim2_models.filters import attribute_host
2526

2627
# -- storage-start --
2728
records = {}
@@ -72,32 +73,58 @@ def sort_resources(resources, sort_by, sort_order=None):
7273
:param sort_order: The ``sortOrder`` query parameter, ascending by default.
7374
:raises InvalidPathException: If the attribute is unknown.
7475
"""
75-
if sort_by.field_name is None:
76+
resolved = sort_by.resolve()
77+
if resolved is None:
7678
raise InvalidPathException(
7779
path=str(sort_by), detail=f"Cannot sort on {sort_by!r}"
7880
)
7981

80-
# "String type attributes are case insensitive by default, unless the
81-
# attribute type is defined as a case-exact string."
82-
case_exact = sort_by.model.get_field_annotation(sort_by.field_name, CaseExact)
8382
descending = sort_order == SearchRequest.SortOrder.descending
8483

8584
def key(resource):
86-
value = sort_by.get(resource, strict=False)
87-
if isinstance(value, list):
88-
# "resources are sorted by the value of the primary attribute, if
89-
# any, or else the first value in the list, if any."
90-
primary = next((each for each in value if each.primary), None)
91-
entry = primary or (value[0] if value else None)
92-
value = entry.value if entry else None
93-
if isinstance(value, str) and not case_exact:
85+
value = sort_value(resource, resolved)
86+
# "String type attributes are case insensitive by default, unless the
87+
# attribute type is defined as a case-exact string."
88+
if isinstance(value, str) and not resolved.case_exact:
9489
value = value.casefold()
9590
# "if there is no data for the specified sortBy value, they are sorted
9691
# via the sortOrder parameter, i.e., they are ordered last if ascending
9792
# and first if descending", which reversing the whole key achieves.
9893
return (value is None, value if value is not None else "")
9994

10095
return sorted(resources, key=key, reverse=descending)
96+
97+
98+
def sort_value(resource, resolved):
99+
"""Return the single value a resource is ordered by.
100+
101+
A path crossing a multi-valued attribute designates the sub-attribute of
102+
every entry, where an order needs one value per resource, so the entry is
103+
picked first and the sub-attribute read from it.
104+
105+
:param resource: The resource to read.
106+
:param resolved: The attribute the ``sortBy`` designates.
107+
"""
108+
host = attribute_host(resource, resolved)
109+
value = None if host is None else getattr(host, resolved.field_name, None)
110+
sub_field_name = resolved.sub_field_name
111+
112+
if resolved.is_multivalued:
113+
entries = value or []
114+
# "resources are sorted by the value of the primary attribute, if any,
115+
# or else the first value in the list, if any."
116+
primary = next(
117+
(entry for entry in entries if getattr(entry, "primary", None)), None
118+
)
119+
value = primary if primary is not None else (entries[0] if entries else None)
120+
if sub_field_name is None and isinstance(value, ComplexAttribute):
121+
# RFC7643 §2.4 holds the significant value of a complex entry in a
122+
# ``value`` sub-attribute, where a scalar entry is the value itself.
123+
sub_field_name = "value"
124+
125+
if value is None or sub_field_name is None:
126+
return value
127+
return getattr(value, sub_field_name, None)
101128
# -- sorting-end --
102129

103130

doc/guides/index.rst

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -75,8 +75,8 @@ Ordering and paging collections
7575
A collection endpoint answers the ``sortBy``, ``sortOrder``, ``startIndex`` and ``count``
7676
parameters of :rfc:`RFC7644 §3.4.2 <7644#section-3.4.2>`. Naming the resource type the endpoint
7777
serves, with :class:`~scim2_models.SearchRequest`\ [:class:`~scim2_models.User`], resolves
78-
:attr:`~scim2_models.SearchRequest.sort_by` against that model, so the helper below reads
79-
:attr:`Path.field_name <scim2_models.Path.field_name>` instead of the attribute name a client
78+
:attr:`~scim2_models.SearchRequest.sort_by` against that model, so the helper below works from
79+
the :class:`~scim2_models.ResolvedAttribute` it designates instead of from the name a client
8080
spelled.
8181

8282
:rfc:`RFC7644 §3.4.2.3 <7644#section-3.4.2.3>` decides the order in three ways the helper
@@ -85,6 +85,12 @@ follows: a string attribute is compared without its case unless it is annotated
8585
the value of its ``primary`` entry, or the first one; and a resource with no value for the
8686
attribute comes last when ascending, first when descending.
8787

88+
The second is why the value is not read straight off the path. ``emails.value`` designates the
89+
value of *every* entry, where an order wants one value per resource, so ``sort_value`` picks
90+
the entry before reading the sub-attribute from it. That makes ``sortBy=emails`` the same query
91+
as ``sortBy=emails.value``, :rfc:`RFC7643 §2.4 <7643#section-2.4>` holding the significant value
92+
of a complex entry in its ``value`` sub-attribute, where a scalar entry is that value itself.
93+
8894
.. literalinclude:: _examples/integrations.py
8995
:language: python
9096
:caption: Ordering a collection

tests/test_doc_examples.py

Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,12 +11,14 @@
1111
from datetime import timezone # noqa: E402
1212

1313
from doc.guides._examples.integrations import sort_resources # noqa: E402
14+
from doc.guides._examples.integrations import sort_value # noqa: E402
1415
from doc.guides._examples.sqlalchemy_example import EmailRecord # noqa: E402
1516
from doc.guides._examples.sqlalchemy_example import GroupRecord # noqa: E402
1617
from doc.guides._examples.sqlalchemy_example import UserRecord # noqa: E402
1718
from doc.guides._examples.sqlalchemy_example import create_session_factory # noqa: E402
1819
from doc.guides._examples.sqlalchemy_example import query_users # noqa: E402
1920
from doc.guides._examples.sqlalchemy_example import to_scim_user # noqa: E402
21+
from scim2_models import EnterpriseUser # noqa: E402
2022
from scim2_models import InvalidFilterException # noqa: E402
2123
from scim2_models import InvalidPathException # noqa: E402
2224
from scim2_models import ScimFilter # noqa: E402
@@ -218,6 +220,116 @@ def sorted_names(query):
218220
]
219221

220222

223+
def sorting_users(emails_by_id):
224+
"""Build users carrying the emails each id maps to."""
225+
return [
226+
User[EnterpriseUser](id=user_id, user_name=user_id, emails=emails)
227+
for user_id, emails in emails_by_id.items()
228+
]
229+
230+
231+
def sorting_order(resources, attribute, sort_order=None):
232+
"""Return the ids a ``sortBy`` puts the resources in."""
233+
request = SearchRequest[User[EnterpriseUser]](
234+
sort_by=attribute, sort_order=sort_order
235+
)
236+
return [
237+
resource.id
238+
for resource in sort_resources(resources, request.sort_by, sort_order)
239+
]
240+
241+
242+
def sorting_key(resource, attribute):
243+
"""Return the single value a ``sortBy`` orders a resource by."""
244+
request = SearchRequest[User[EnterpriseUser]](sort_by=attribute)
245+
return sort_value(resource, request.sort_by.resolve())
246+
247+
248+
@pytest.mark.parametrize("attribute", ["emails", "emails.value"])
249+
def test_sorting_reads_the_primary_entry_of_a_multivalued_attribute(attribute):
250+
"""The entry marked ``primary`` decides the order, not the first one.
251+
252+
Ordering on the first entry instead would put ``1`` ahead of ``2``, since
253+
``a@example.com`` precedes ``m@example.com``.
254+
"""
255+
resources = sorting_users(
256+
{
257+
"1": [
258+
User.Emails(value="a@example.com"),
259+
User.Emails(value="z@example.com", primary=True),
260+
],
261+
"2": [User.Emails(value="m@example.com")],
262+
}
263+
)
264+
assert sorting_order(resources, attribute) == ["2", "1"]
265+
266+
267+
def test_sorting_reads_a_sub_attribute_from_the_primary_entry():
268+
"""A path naming a sub-attribute reads it from the entry the order picked.
269+
270+
Reading the first entry instead would put ``2`` ahead of ``1``, ``other``
271+
preceding ``work``.
272+
"""
273+
resources = sorting_users(
274+
{
275+
"1": [
276+
User.Emails(value="a@example.com", type="work"),
277+
User.Emails(value="z@example.com", type="home", primary=True),
278+
],
279+
"2": [User.Emails(value="m@example.com", type="other")],
280+
}
281+
)
282+
assert sorting_order(resources, "emails.type") == ["1", "2"]
283+
284+
285+
def test_sorting_falls_back_to_the_first_entry_without_a_primary():
286+
"""An attribute marking no entry primary is ordered by its first one."""
287+
resources = sorting_users(
288+
{
289+
"1": [
290+
User.Emails(value="z@example.com"),
291+
User.Emails(value="a@example.com"),
292+
],
293+
"2": [User.Emails(value="m@example.com")],
294+
}
295+
)
296+
assert sorting_order(resources, "emails.value") == ["2", "1"]
297+
298+
299+
def test_sorting_an_unassigned_multivalued_attribute():
300+
"""A resource carrying no entry comes last ascending and first descending."""
301+
resources = sorting_users({"1": None, "2": [User.Emails(value="m@example.com")]})
302+
assert sorting_order(resources, "emails.value") == ["2", "1"]
303+
assert sorting_order(resources, "emails.value", "descending") == ["1", "2"]
304+
305+
306+
def test_sorting_a_scalar_multivalued_attribute_reads_the_entry_itself():
307+
"""A scalar entry is the value, where a complex one holds it in a sub-attribute."""
308+
resource = sorting_users({"1": [User.Emails(value="m@example.com")]})[0]
309+
assert sorting_key(resource, "schemas") == User.__schema__
310+
311+
312+
def test_sorting_an_attribute_of_an_extension_left_unset():
313+
"""An extension that is not set holds no value to order by."""
314+
resource = sorting_users({"1": None})[0]
315+
urn = "urn:ietf:params:scim:schemas:extension:enterprise:2.0:User:department"
316+
assert sorting_key(resource, urn) is None
317+
318+
resource[EnterpriseUser] = EnterpriseUser(department="Tour Operations")
319+
assert sorting_key(resource, urn) == "Tour Operations"
320+
321+
322+
def test_sorting_a_request_that_named_no_resource_type():
323+
"""The helper orders by a resolved attribute, which an unparameterised request has none of.
324+
325+
A request naming the type it serves cannot reach here: an attribute the
326+
model does not declare is refused when the request is built.
327+
"""
328+
resources = sorting_users({"1": None})
329+
with pytest.raises(InvalidPathException):
330+
sort_resources(resources, SearchRequest(sort_by="userName").sort_by)
331+
332+
221333
def test_django_example_smoke():
222334
from django.conf import settings
223335

0 commit comments

Comments
 (0)