Skip to content

Commit 0da2002

Browse files
committed
feat: expose the form attribute values are compared under
AttributeBinding.comparable returns the form filters compare a value under: NFC, and case folded unless the attribute is caseExact. A server or a database can keep this form to filter, sort and check uniqueness as ScimFilter.match does. SearchRequest.sort now uses it too, so it normalizes strings to NFC like filters do.
1 parent 66a579b commit 0da2002

7 files changed

Lines changed: 98 additions & 39 deletions

File tree

‎doc/changelog.rst‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,10 @@ Added
99
- Filters accept a compared sub-attribute after a value selection, as Microsoft Entra ID sends
1010
it: ``emails[type eq "work"].value eq "x"`` reads as
1111
``emails[type eq "work" and value eq "x"]``.
12+
- :meth:`AttributeBinding.comparable <scim2_models.AttributeBinding.comparable>` returns the form
13+
a value is compared under: NFC, and case folded unless the attribute is ``caseExact``. A storage
14+
can keep this form to filter, sort and check uniqueness as :meth:`~scim2_models.ScimFilter.match`
15+
does.
1216

1317
Changed
1418
^^^^^^^
@@ -22,6 +26,11 @@ Changed
2226
from ``schemas``, such as ``schemas[value eq "urn:…:enterprise:2.0:User"]``, removes the extension.
2327
Before, the operation reported a change but the extension stayed.
2428

29+
Fixed
30+
^^^^^
31+
- :meth:`SearchRequest.sort <scim2_models.SearchRequest.sort>` normalizes strings to NFC, as filters
32+
do. A composed and a decomposed ``José`` now sort together.
33+
2534
[0.10.6] - 2026-10-06
2635
---------------------
2736

‎scim2_models/messages/search_request.py‎

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -75,9 +75,7 @@ def _sort_value(resource: Any, binding: AttributeBinding | None) -> Any:
7575
value = getattr(value, binding.sub_field_name, None)
7676
# "String type attributes are case insensitive by default, unless the
7777
# attribute type is defined as a case-exact string", RFC7644 §3.4.2.3.
78-
if isinstance(value, str) and not binding.case_exact:
79-
return value.casefold()
80-
return value
78+
return binding.comparable(value)
8179

8280

8381
class SearchRequest(Message, ResponseParameters[ResourceT], Generic[ResourceT]):

‎scim2_models/path/resolution.py‎

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
from typing import Any
77
from typing import cast
88
from typing import get_args
9+
from unicodedata import normalize
910

1011
from pydantic import TypeAdapter
1112
from pydantic import ValidationError
@@ -129,6 +130,33 @@ def get_annotation(self, annotation_type: type) -> Any:
129130
return None
130131
return model.get_field_annotation(self.target_field_name, annotation_type)
131132

133+
def comparable(self, value: Any) -> Any:
134+
"""Return the form a value of the attribute is compared under.
135+
136+
Filters, sorting and uniqueness checks compare values in this form, so
137+
a storage that keeps it can compare as :meth:`~scim2_models.ScimFilter.match`
138+
does. Strings are normalized to NFC, and case folded unless the attribute
139+
is ``caseExact``. Other values are returned unchanged.
140+
141+
>>> from scim2_models import Path, User
142+
>>> binding = Path[User]("userName").resolve()
143+
>>> binding.comparable("BJensen")
144+
'bjensen'
145+
146+
:param value: A value of the attribute.
147+
:returns: The value in the form it is compared under.
148+
"""
149+
if not isinstance(value, str):
150+
return value
151+
152+
normalized = normalize("NFC", value)
153+
if self.case_exact:
154+
return normalized
155+
156+
# Case folding does not preserve the normalization form, so NFC is
157+
# applied to its result too, and every operand comes out in the same form.
158+
return normalize("NFC", normalized.casefold())
159+
132160
def _nested_in(self, urn: str) -> "AttributeBinding":
133161
"""Return the same attribute, qualified by the URN it was resolved under.
134162

‎scim2_models/path/visitor.py‎

Lines changed: 11 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,6 @@
33
from typing import Any
44
from typing import Generic
55
from typing import TypeVar
6-
from unicodedata import normalize
76

87
from ..base import BaseModel
98
from .expressions import AttrPath
@@ -102,29 +101,15 @@ def _is_present(value: Any) -> bool:
102101
return True
103102

104103

105-
def _comparable(value: Any, case_exact: bool) -> Any:
106-
"""Reduce a value to the form comparisons are performed on."""
107-
if not isinstance(value, str):
108-
return value
109-
110-
normalized = normalize("NFC", value)
111-
if case_exact:
112-
return normalized
113-
114-
# Case folding does not preserve the normalization form, so NFC is applied
115-
# to its result too, and every operand comes out in the same form.
116-
return normalize("NFC", normalized.casefold())
117-
118-
119104
def _compare(
120-
actual: Any, expected: Any, op: CompareOperator, *, case_exact: bool = False
105+
actual: Any, expected: Any, op: CompareOperator, resolved: AttributeBinding
121106
) -> bool:
122107
"""Apply a comparison operator to a single pair of values.
123108
124109
Values of incomparable types never match, rather than raising, so that a
125110
filter over a heterogeneous collection stays usable. ``actual`` is read
126111
from the resource, ``expected`` is what the filter compares it against, and
127-
``case_exact`` says whether strings compare case-sensitively.
112+
``resolved`` is the attribute, which gives the form both are compared under.
128113
"""
129114
if actual is None or expected is None:
130115
if op == CompareOperator.eq:
@@ -133,8 +118,8 @@ def _compare(
133118
return (actual is None) != (expected is None)
134119
return False
135120

136-
left = _comparable(actual, case_exact)
137-
right = _comparable(expected, case_exact)
121+
left = resolved.comparable(actual)
122+
right = resolved.comparable(expected)
138123

139124
if op in (CompareOperator.co, CompareOperator.sw, CompareOperator.ew):
140125
if not isinstance(left, str) or not isinstance(right, str):
@@ -230,21 +215,15 @@ def visit_comparison(self, node: Comparison) -> bool:
230215
actual = self._read(resolved)
231216

232217
if not isinstance(actual, list):
233-
return _compare(actual, expected, node.op, case_exact=resolved.case_exact)
218+
return _compare(actual, expected, node.op, resolved)
234219

235220
# A filter on a multi-valued attribute matches if any of its values
236221
# matches. The RFC does not say what that means for "ne", so the
237222
# universal reading is used: no value equals the operand.
238223
if node.op == CompareOperator.ne:
239-
return all(
240-
_compare(item, expected, node.op, case_exact=resolved.case_exact)
241-
for item in actual
242-
)
224+
return all(_compare(item, expected, node.op, resolved) for item in actual)
243225

244-
return any(
245-
_compare(item, expected, node.op, case_exact=resolved.case_exact)
246-
for item in actual
247-
)
226+
return any(_compare(item, expected, node.op, resolved) for item in actual)
248227

249228
def visit_present(self, node: Present) -> bool:
250229
resolved = self._resolve(node.attr_path)
@@ -300,7 +279,7 @@ def _matches_item(
300279
type(item), item, strict=self.strict, urn_prefix=resolved.urn
301280
).visit(val_filter)
302281

303-
return _ScalarEvaluator(item, case_exact=resolved.case_exact).visit(val_filter)
282+
return _ScalarEvaluator(item, resolved).visit(val_filter)
304283

305284

306285
class _ScalarEvaluator(FilterVisitor[bool]):
@@ -313,17 +292,17 @@ class _ScalarEvaluator(FilterVisitor[bool]):
313292
attribute name cannot match.
314293
"""
315294

316-
def __init__(self, value: Any, *, case_exact: bool = False):
295+
def __init__(self, value: Any, resolved: AttributeBinding):
317296
self.value = value
318-
self.case_exact = case_exact
297+
self.resolved = resolved
319298

320299
def _targets_self(self, node: Comparison | Present) -> bool:
321300
return node.attr_path.attr.lower() == "value" and not node.attr_path.sub_attr
322301

323302
def visit_comparison(self, node: Comparison) -> bool:
324303
if not self._targets_self(node):
325304
return False
326-
return _compare(self.value, node.value, node.op, case_exact=self.case_exact)
305+
return _compare(self.value, node.value, node.op, self.resolved)
327306

328307
def visit_present(self, node: Present) -> bool:
329308
return self._targets_self(node) and _is_present(self.value)

‎tests/test_filter_semantics.py‎

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -537,9 +537,11 @@ def test_comparison_matches_a_canonically_equivalent_operand():
537537

538538
def test_a_case_exact_comparison_matches_a_canonically_equivalent_operand():
539539
"""Case exactness constrains the casing, not the normalization form."""
540-
assert _compare(COMPOSED, DECOMPOSED, CompareOperator.eq, case_exact=True)
541-
assert not _compare(
542-
COMPOSED.upper(), DECOMPOSED, CompareOperator.eq, case_exact=True
540+
assert ScimFilter[User](f'externalId eq "{DECOMPOSED}"').match(
541+
User(external_id=COMPOSED)
542+
)
543+
assert not ScimFilter[User](f'externalId eq "{DECOMPOSED}"').match(
544+
User(external_id=COMPOSED.upper())
543545
)
544546

545547

@@ -947,7 +949,11 @@ def test_presence_of_a_value(value, expected):
947949
)
948950
def test_comparing_two_values(actual, expected_value, operator, expected):
949951
"""Incomparable values never match rather than raising."""
950-
assert _compare(actual, expected_value, CompareOperator(operator)) is expected
952+
resolved = Path[User]("displayName").resolve()
953+
assert (
954+
_compare(actual, expected_value, CompareOperator(operator), resolved)
955+
is expected
956+
)
951957

952958

953959
def test_a_visitor_must_implement_every_node_type():

‎tests/test_path.py‎

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1886,3 +1886,28 @@ def test_iter_paths_keeps_results_of_different_models_apart():
18861886
in extended
18871887
)
18881888
assert not any(path.startswith("urn:") for path in plain)
1889+
1890+
1891+
def test_comparable_folds_the_case_of_a_case_insensitive_string():
1892+
"""A case-insensitive string is compared without its case."""
1893+
binding = Path[User]("userName").resolve()
1894+
assert binding.comparable("BJensen") == "bjensen"
1895+
1896+
1897+
def test_comparable_keeps_the_case_of_a_case_exact_string():
1898+
"""A case-exact string keeps its case."""
1899+
binding = Path[User]("externalId").resolve()
1900+
assert binding.comparable("BJensen") == "BJensen"
1901+
1902+
1903+
@pytest.mark.parametrize("attribute", ["userName", "externalId"])
1904+
def test_comparable_gives_composed_and_decomposed_strings_the_same_form(attribute):
1905+
"""Strings are normalized to NFC, whatever their case sensitivity."""
1906+
binding = Path[User](attribute).resolve()
1907+
assert binding.comparable("Jos\u00e9") == binding.comparable("Jose\u0301")
1908+
1909+
1910+
def test_comparable_leaves_other_values_unchanged():
1911+
"""Only strings have a comparison form of their own."""
1912+
binding = Path[User]("active").resolve()
1913+
assert binding.comparable(True) is True

‎tests/test_search_request.py‎

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -731,6 +731,20 @@ def test_sort_ignores_the_case_of_a_case_insensitive_string():
731731
]
732732

733733

734+
def test_sort_places_composed_and_decomposed_strings_together():
735+
"""Sorting compares strings in the form filters do, normalized to NFC."""
736+
users = [
737+
User(id="composed", user_name="\u00c9mile"),
738+
User(id="fabrice", user_name="Fabrice"),
739+
User(id="decomposed", user_name="E\u0301mile"),
740+
]
741+
assert ids(SearchRequest[User](sort_by="userName").sort(users)) == [
742+
"fabrice",
743+
"composed",
744+
"decomposed",
745+
]
746+
747+
734748
def test_sort_follows_the_case_of_a_case_exact_string():
735749
"""RFC7644 §3.4.2.3 sorts a case-exact string with its case, upper case first."""
736750
users = [

0 commit comments

Comments
 (0)