Skip to content

Commit d49cbb9

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 4722f47 commit d49cbb9

3 files changed

Lines changed: 163 additions & 7 deletions

File tree

‎doc/guides/_examples/integrations.py‎

Lines changed: 36 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +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 ComplexAttribute
1112
from scim2_models import CaseExact
1213
from scim2_models import ETag
1314
from scim2_models import Filter
@@ -83,13 +84,7 @@ def sort_resources(resources, sort_by, sort_order=None):
8384
descending = sort_order == SearchRequest.SortOrder.descending
8485

8586
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
87+
value = sort_value(resource, sort_by)
9388
if isinstance(value, str) and not case_exact:
9489
value = value.casefold()
9590
# "if there is no data for the specified sortBy value, they are sorted
@@ -98,6 +93,40 @@ def key(resource):
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, sort_by):
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 sort_by: The ``sortBy`` query parameter, resolved by its request.
107+
"""
108+
sub_field_name = sort_by.field_name if len(sort_by.parts) > 1 else None
109+
attribute = sort_by
110+
if sub_field_name is not None:
111+
attribute = type(sort_by)(str(sort_by).rsplit(".", 1)[0])
112+
value = attribute.get(resource, strict=False)
113+
114+
if attribute.is_multivalued:
115+
entries = value or []
116+
# "resources are sorted by the value of the primary attribute, if any,
117+
# or else the first value in the list, if any."
118+
primary = next(
119+
(entry for entry in entries if getattr(entry, "primary", None)), None
120+
)
121+
value = primary if primary is not None else (entries[0] if entries else None)
122+
if sub_field_name is None and isinstance(value, ComplexAttribute):
123+
# RFC7643 §2.4 holds the significant value of a complex entry in a
124+
# ``value`` sub-attribute, where a scalar entry is the value itself.
125+
sub_field_name = "value"
126+
127+
if value is None or sub_field_name is None:
128+
return value
129+
return getattr(value, sub_field_name, None)
101130
# -- sorting-end --
102131

103132

‎doc/guides/index.rst‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,12 @@ follows: a string attribute is compared without its case unless it is annotated
5454
the value of its ``primary`` entry, or the first one; and a resource with no value for the
5555
attribute comes last when ascending, first when descending.
5656

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

‎tests/test_doc_examples.py‎

Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,13 @@
66
django = pytest.importorskip("django")
77
fastapi = pytest.importorskip("fastapi")
88

9+
from doc.guides._examples.integrations import sort_resources # noqa: E402
10+
from doc.guides._examples.integrations import sort_value # noqa: E402
11+
from scim2_models import EnterpriseUser # noqa: E402
12+
from scim2_models import InvalidPathException # noqa: E402
13+
from scim2_models import SearchRequest # noqa: E402
14+
from scim2_models import User # noqa: E402
15+
916

1017
def create_flask_app():
1118
from flask import Flask
@@ -176,6 +183,120 @@ def sorted_names(query):
176183
"Zoe@example.com",
177184
]
178185

186+
# A sub-attribute of a multi-valued attribute is read from the entry the
187+
# order picks, and only one user carries an email.
188+
assert sorted_names("sortBy=emails.value")[0] == "bjensen@example.com"
189+
190+
191+
def sorting_users(emails_by_id):
192+
"""Build users carrying the emails each id maps to."""
193+
return [
194+
User[EnterpriseUser](id=user_id, user_name=user_id, emails=emails)
195+
for user_id, emails in emails_by_id.items()
196+
]
197+
198+
199+
def sorting_order(resources, attribute, sort_order=None):
200+
"""Return the ids a ``sortBy`` puts the resources in."""
201+
request = SearchRequest[User[EnterpriseUser]](
202+
sort_by=attribute, sort_order=sort_order
203+
)
204+
return [
205+
resource.id
206+
for resource in sort_resources(resources, request.sort_by, sort_order)
207+
]
208+
209+
210+
def sorting_key(resource, attribute):
211+
"""Return the single value a ``sortBy`` orders a resource by."""
212+
request = SearchRequest[User[EnterpriseUser]](sort_by=attribute)
213+
return sort_value(resource, request.sort_by)
214+
215+
216+
@pytest.mark.parametrize("attribute", ["emails", "emails.value"])
217+
def test_sorting_reads_the_primary_entry_of_a_multivalued_attribute(attribute):
218+
"""The entry marked ``primary`` decides the order, not the first one.
219+
220+
Ordering on the first entry instead would put ``1`` ahead of ``2``, since
221+
``a@example.com`` precedes ``m@example.com``.
222+
"""
223+
resources = sorting_users(
224+
{
225+
"1": [
226+
User.Emails(value="a@example.com"),
227+
User.Emails(value="z@example.com", primary=True),
228+
],
229+
"2": [User.Emails(value="m@example.com")],
230+
}
231+
)
232+
assert sorting_order(resources, attribute) == ["2", "1"]
233+
234+
235+
def test_sorting_reads_a_sub_attribute_from_the_primary_entry():
236+
"""A path naming a sub-attribute reads it from the entry the order picked.
237+
238+
Reading the first entry instead would put ``2`` ahead of ``1``, ``other``
239+
preceding ``work``.
240+
"""
241+
resources = sorting_users(
242+
{
243+
"1": [
244+
User.Emails(value="a@example.com", type="work"),
245+
User.Emails(value="z@example.com", type="home", primary=True),
246+
],
247+
"2": [User.Emails(value="m@example.com", type="other")],
248+
}
249+
)
250+
assert sorting_order(resources, "emails.type") == ["1", "2"]
251+
252+
253+
def test_sorting_falls_back_to_the_first_entry_without_a_primary():
254+
"""An attribute marking no entry primary is ordered by its first one."""
255+
resources = sorting_users(
256+
{
257+
"1": [
258+
User.Emails(value="z@example.com"),
259+
User.Emails(value="a@example.com"),
260+
],
261+
"2": [User.Emails(value="m@example.com")],
262+
}
263+
)
264+
assert sorting_order(resources, "emails.value") == ["2", "1"]
265+
266+
267+
def test_sorting_an_unassigned_multivalued_attribute():
268+
"""A resource carrying no entry comes last ascending and first descending."""
269+
resources = sorting_users({"1": None, "2": [User.Emails(value="m@example.com")]})
270+
assert sorting_order(resources, "emails.value") == ["2", "1"]
271+
assert sorting_order(resources, "emails.value", "descending") == ["1", "2"]
272+
273+
274+
def test_sorting_a_scalar_multivalued_attribute_reads_the_entry_itself():
275+
"""A scalar entry is the value, where a complex one holds it in a sub-attribute."""
276+
resource = sorting_users({"1": [User.Emails(value="m@example.com")]})[0]
277+
assert sorting_key(resource, "schemas") == User.__schema__
278+
279+
280+
def test_sorting_an_attribute_of_an_extension_left_unset():
281+
"""An extension that is not set holds no value to order by."""
282+
resource = sorting_users({"1": None})[0]
283+
urn = "urn:ietf:params:scim:schemas:extension:enterprise:2.0:User:department"
284+
assert sorting_key(resource, urn) is None
285+
286+
resource[EnterpriseUser] = EnterpriseUser(department="Tour Operations")
287+
assert sorting_key(resource, urn) == "Tour Operations"
288+
289+
290+
def test_sorting_a_request_that_named_no_resource_type():
291+
"""The helper orders by a resolved attribute, which an unparameterised request has none of.
292+
293+
A request naming the type it serves cannot reach here: an attribute the
294+
model does not declare is refused when the request is built.
295+
"""
296+
resources = sorting_users({"1": None})
297+
with pytest.raises(InvalidPathException):
298+
sort_resources(resources, SearchRequest(sort_by="userName").sort_by)
299+
179300

180301
def test_django_example_smoke():
181302
from django.conf import settings

0 commit comments

Comments
 (0)