Skip to content

Commit c856708

Browse files
committed
doc: explain how to compare strings with a custom key
A new how-to guide shows how to give a policy to the storage, and what each database supports.
1 parent 23071dc commit c856708

6 files changed

Lines changed: 145 additions & 40 deletions

File tree

‎doc/_examples/intranet/scim.py‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -58,14 +58,15 @@
5858
)
5959

6060

61-
def create_provider():
61+
def create_provider(policy=None):
6262
return ScimProvider.from_discovery(
6363
[*USERS.schemas(), *GROUPS.schemas()],
6464
[
6565
ResourceType.from_resource(USERS.model),
6666
ResourceType.from_resource(GROUPS.model),
6767
],
6868
config=load_default_service_provider_config(),
69+
policy=policy,
6970
)
7071

7172

‎doc/explanation.rst‎

Lines changed: 38 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -41,9 +41,10 @@ differs from a direct translation in several places:
4141
while ``emails[value ne "x"]`` holds when one email is not ``x``.
4242
- A value selection, such as ``emails[type eq "work" and primary eq true]``, applies all its
4343
conditions to the same entry.
44-
- A string compares in the form the comparison key of the policy gives it. By default, the key
45-
normalizes the string to NFC, and lowers it unless the schema declares it ``caseExact``. Each
46-
database reproduces the key its own way, as the next section describes.
44+
- A string compares in the form the comparison key of the policy gives it. By default, the
45+
comparison key normalizes the string to NFC, and lowers it unless the schema declares it
46+
``caseExact``. Each database reproduces the comparison key its own way, as the next section
47+
describes.
4748

4849
An attribute the resource type does not declare matches no resource
4950
(:rfc:`RFC 7644 §3.4.2.1 <7644#section-3.4.2.1>`). An attribute it declares but the mapping does
@@ -54,35 +55,35 @@ Strings compare as the policy says
5455
----------------------------------
5556

5657
The :attr:`comparison_key <scim2_models.ScimPolicy.comparison_key>` of a
57-
:class:`~scim2_models.ScimPolicy` gives the form strings are compared in. scim2-models applies it
58-
in Python, to filters, sorts and PATCH operations. For the reasons behind the key, read
58+
:class:`~scim2_models.ScimPolicy` gives the form strings are compared in. scim2-models applies it in
59+
Python, to filters, sorts and PATCH operations. For the reasons behind the comparison key, read
5960
:doc:`scim2_models:explanation/comparisons`. The storage follows the policy of the provider it
60-
receives, or the default policy without a provider. It reproduces the key in SQL, as far as the
61-
database allows:
62-
63-
- SQLite runs Python functions. The storage gives each connection a SQL function that calls the
64-
key, so filters, sorts and uniqueness checks compare exactly as in Python, whatever the key.
65-
The function runs once for each row, and no index serves it. The ``co``, ``sw`` and ``ew``
66-
filters use ``instr()`` and ``substr()``, since the ``LIKE`` of SQLite ignores the case of ASCII
67-
letters, even for a ``caseExact`` attribute.
61+
receives, or the default policy without a provider. It reproduces the comparison key in SQL, as far
62+
as the database allows:
63+
64+
- SQLite runs Python functions. The storage gives each connection a SQL function that calls the key,
65+
so filters, sorts and uniqueness checks compare exactly as in Python, whatever the comparison key.
66+
The function runs once for each row, and no index serves it. The ``co``, ``sw`` and ``ew`` filters
67+
use ``instr()`` and ``substr()``, since the ``LIKE`` of SQLite ignores the case of ASCII letters,
68+
even for a ``caseExact`` attribute.
6869
- PostgreSQL normalizes both sides to NFC with ``normalize()``, and lowers them with ``lower()``.
69-
This approaches the default key without reaching it. ``lower()`` follows the locale of the
70-
database, and only lowers ASCII letters under the ``C`` locale. Even under a UTF-8 locale, a
71-
few letters differ, such as ``İ`` and the final sigma. ``normalize()`` needs PostgreSQL 13 and
72-
a database encoded in UTF-8. The comparison uses the ``C`` collation, which orders strings by
73-
code point, as scim2-models does, whatever the collation of the column. An index that serves
74-
these filters is an index on the same expression, with the same collation. With a key other
75-
than the default one, the storage warns that it only approaches the key.
70+
This approaches the default comparison key without reaching it. ``lower()`` follows the locale of
71+
the database, and only lowers ASCII letters under the ``C`` locale. Even under a UTF-8 locale, a
72+
few letters differ, such as ``İ`` and the final sigma. ``normalize()`` needs PostgreSQL 13 and a
73+
database encoded in UTF-8. The comparison uses the ``C`` collation, which orders strings by code
74+
point, as scim2-models does, whatever the collation of the column. An index that serves these
75+
filters is an index on the same expression, with the same collation. With a comparison key other
76+
than the default one, the storage warns that it only approaches it.
7677
- Other databases lower both sides with their own ``lower()``. MySQL and MariaDB are not
7778
supported: their default collations ignore the case and the accents, even for a ``caseExact``
7879
attribute.
7980

8081
Where ``LIKE`` serves the ``co``, ``sw`` and ``ew`` filters, the ``%`` and ``_`` characters of the
8182
value are escaped, so that they match themselves.
8283

83-
A string the key refuses is equal to no other. A filter comparing an attribute with such a
84-
string holds only for ``ne``. On SQLite, a stored string the key refuses also matches ``ne``
85-
only, and sorts with the missing values.
84+
A string the comparison key refuses is equal to no other. A filter comparing an attribute with such
85+
a string holds only for ``ne``. On SQLite, a stored string the comparison key refuses also matches
86+
``ne`` only, and sorts with the missing values.
8687

8788
Sorting follows RFC 7644
8889
------------------------
@@ -132,13 +133,12 @@ refuses an entry without ``value``: storing nothing for it would lose the link.
132133
Versions protect concurrent writes
133134
----------------------------------
134135

135-
The version of a resource comes from the
136-
:ref:`version counter <sqlalchemy:mapper_version_counter>` of its SQLAlchemy model. SQLAlchemy
137-
increments the column on every write, and adds the version it read to the ``WHERE`` clause of the
138-
``UPDATE``. The storage first compares the stored version with the one the client sent in
139-
``If-Match``, and answers 412 when they differ. A write by another transaction between the read
140-
and the write matches no row: SQLAlchemy raises :exc:`~sqlalchemy.orm.exc.StaleDataError`, which the storage turns into
141-
a 412 too.
136+
The version of a resource comes from the :ref:`version counter <sqlalchemy:mapper_version_counter>`
137+
of its SQLAlchemy model. SQLAlchemy increments the column on every write, and adds the version it
138+
read to the ``WHERE`` clause of the ``UPDATE``. The storage first compares the stored version with
139+
the one the client sent in ``If-Match``, and answers 412 when they differ. A write by another
140+
transaction between the read and the write matches no row: SQLAlchemy raises
141+
:exc:`~sqlalchemy.orm.exc.StaleDataError`, which the storage turns into a 412 too.
142142

143143
Unique values are checked twice
144144
-------------------------------
@@ -153,15 +153,16 @@ taken. Any other :exc:`~sqlalchemy.exc.IntegrityError` goes through unchanged.
153153
The application owns the transaction
154154
------------------------------------
155155

156-
The storage :ref:`flushes <sqlalchemy:session_flushing>` each write, so that the database fills the identifiers and checks its
157-
constraints, and never commits. The application, or its web framework, already decides when a
158-
request ends and whether its changes are kept. Each operation runs in a :ref:`savepoint <sqlalchemy:session_begin_nested>`, so that a
159-
failed operation leaves the session usable, and a bulk request goes on after a failure.
156+
The storage :ref:`flushes <sqlalchemy:session_flushing>` each write, so that the database fills the
157+
identifiers and checks its constraints, and never commits. The application, or its web framework,
158+
already decides when a request ends and whether its changes are kept. Each operation runs in a
159+
:ref:`savepoint <sqlalchemy:session_begin_nested>`, so that a failed operation leaves the session
160+
usable, and a bulk request goes on after a failure.
160161

161162
The asynchronous storage loads every collection of a record with the record. An
162-
:class:`~sqlalchemy.ext.asyncio.AsyncSession` cannot load a relationship
163-
:ref:`lazily <sqlalchemy:asyncio_orm_avoid_lazyloads>`, on first access. Both storages load the same way, so
164-
they send the same queries.
163+
:class:`~sqlalchemy.ext.asyncio.AsyncSession` cannot load a relationship :ref:`lazily
164+
<sqlalchemy:asyncio_orm_avoid_lazyloads>`, on first access. Both storages load the same way, so they
165+
send the same queries.
165166

166167
Limits
167168
------

‎doc/how-to/compare-strings.rst‎

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
Compare strings with a custom comparison key
2+
============================================
3+
4+
This guide makes the storage compare strings with a comparison key other than the default one, such
5+
as the PRECIS rules that :rfc:`RFC 7644 §5 <7644#section-5>` requires for ``userName``. A comparison
6+
key is a function that returns the form a string is compared in: two strings are equal when their
7+
forms are equal. The guide is for developers who serve a mapping with scim2-server, as in the
8+
:doc:`../overview`. It does not cover the rules a comparison key must follow: for them, read
9+
:doc:`scim2_models:how-to/compare-values`.
10+
11+
Give the policy to the storage
12+
------------------------------
13+
14+
The storage compares strings with the :attr:`~scim2_models.ScimPolicy.comparison_key` of the policy
15+
of its provider. Build the provider with the policy, and pass the same provider to the storage and
16+
to the application. The intranet of the :doc:`../overview` builds its provider with
17+
``create_provider``, which takes the policy:
18+
19+
.. doctest::
20+
21+
>>> from unicodedata import normalize
22+
>>> from scim2_models import ScimPolicy
23+
>>> from scim2_models import default_comparison_key
24+
>>> from intranet.scim import create_provider
25+
26+
>>> def compatibility_key(binding, value):
27+
... return default_comparison_key(binding, normalize("NFKC", value))
28+
29+
>>> provider = create_provider(ScimPolicy(comparison_key=compatibility_key))
30+
31+
Without a provider, the storage compares strings with the default comparison key.
32+
33+
The comparison key of this example applies the compatibility normalization of Unicode, ``NFKC``,
34+
before the default comparison key. It replaces the characters that only differ by their presentation
35+
with their plain form:
36+
37+
- the fullwidth letters of East Asian keyboards: ``BJENSEN`` becomes ``BJENSEN``;
38+
- the ligatures: ``fi`` becomes ``fi``;
39+
- the superscripts and the subscripts: ``x²`` becomes ``x2``.
40+
41+
The stored values keep their form: only the comparisons change. More values then compare equal. A
42+
filter on ``BJENSEN`` finds the user ``bjensen``, and a second user named ``bjensen`` is refused:
43+
44+
.. doctest::
45+
:hide:
46+
47+
>>> from scim2_server.applications.wsgi import WSGIApplication
48+
>>> from sqlalchemy import create_engine
49+
>>> from sqlalchemy.orm import Session
50+
>>> from werkzeug.test import Client
51+
>>> from intranet.models import Base
52+
>>> from intranet.scim import create_storage
53+
>>> engine = create_engine("sqlite://")
54+
>>> Base.metadata.create_all(engine)
55+
>>> session = Session(engine)
56+
>>> client = Client(WSGIApplication(create_storage(session, provider), provider))
57+
58+
.. doctest::
59+
60+
>>> client.post("/v2/Users", json={"userName": "bjensen"}).status_code
61+
201
62+
>>> response = client.get("/v2/Users", query_string={"filter": 'userName eq "BJENSEN"'})
63+
>>> response.json["totalResults"]
64+
1
65+
>>> client.post("/v2/Users", json={"userName": "bjensen"}).status_code
66+
409
67+
68+
.. doctest::
69+
:hide:
70+
71+
>>> session.close()
72+
>>> engine.dispose()
73+
74+
The comparison key of this example applies to every string attribute, such as ``displayName``, where
75+
``x²`` and ``x2`` become equal too. To limit it to some attributes, test the URN of the binding,
76+
``binding.urn``, and return ``default_comparison_key(binding, value)`` for the others.
77+
78+
Check what the database supports
79+
--------------------------------
80+
81+
The comparisons happen in two places:
82+
83+
- :doc:`scim2-models <scim2_models:index>` and :doc:`scim2-server <scim2_server:index>` compare in
84+
Python, with the comparison key itself. They do so for the path filters of PATCH requests, such as
85+
``emails[value eq "…"]``, and to find the values a PATCH request adds or removes.
86+
- The storage compares in SQL, for the filters and the sorts of searches, and for the uniqueness
87+
checks.
88+
89+
Both give the same results only when the database follows the comparison key:
90+
91+
- On SQLite, the storage calls the comparison key for each row, and follows it exactly. No index
92+
serves these filters.
93+
- On PostgreSQL, the storage cannot call the comparison key. It compares the strings in the form of
94+
the default comparison key, and warns when the policy sets another one. With the comparison key of
95+
this guide, a search on ``BJENSEN`` finds nothing, and ``bjensen`` can be created next to
96+
``bjensen``. A PATCH request still compares in Python, and takes the two names as equal.
97+
98+
On PostgreSQL, keep the default comparison key. Even then, a few letters differ between Python and
99+
the database, such as ``İ`` and the final sigma. For the details of each database, read
100+
:doc:`../explanation`.

‎doc/how-to/index.rst‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,3 +10,4 @@ the :doc:`../overview`.
1010
map-a-data-model
1111
publish-the-stored-attributes
1212
manage-the-transactions
13+
compare-strings

‎doc/index.rst‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -40,8 +40,8 @@ Start with the page that matches the need:
4040

4141
- To build a SCIM server over the tables of an existing application, follow the
4242
:doc:`overview`.
43-
- To map other data models, publish the stored attributes, or commit the changes of each
44-
request, read the :doc:`how-to guides <how-to/index>`.
43+
- To map other data models, publish the stored attributes, commit the changes of each request,
44+
or compare strings with a custom comparison key, read the :doc:`how-to guides <how-to/index>`.
4545
- For the reasons behind the behavior of the storage, read :doc:`explanation`.
4646
- For the public API, the supported searches and the errors, read the :doc:`reference <reference/index>`.
4747

‎doc/overview.rst‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -249,4 +249,6 @@ The server now serves the accounts and the teams of the intranet:
249249
:doc:`how-to/publish-the-stored-attributes`.
250250
- To commit the changes of each request, in a synchronous or an asynchronous application, read
251251
:doc:`how-to/manage-the-transactions`.
252+
- To compare strings with the PRECIS rules or another custom comparison key, read
253+
:doc:`how-to/compare-strings`.
252254
- For the reasons behind the behavior of the storage, read :doc:`explanation`.

0 commit comments

Comments
 (0)