Skip to content

Commit a34e247

Browse files
committed
feat: page the search results with cursors
1 parent 6ed3ab7 commit a34e247

47 files changed

Lines changed: 2596 additions & 184 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎Containerfile‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,9 @@ ENV UV_COMPILE_BYTECODE=1 \
66

77
WORKDIR /app
88
COPY pyproject.toml uv.lock README.md LICENSE ./
9-
RUN uv sync --locked --no-dev --extra precis --no-install-project
9+
RUN uv sync --locked --no-dev --extra precis --extra cursor --no-install-project
1010
COPY scim2_server ./scim2_server
11-
RUN uv sync --locked --no-dev --extra precis --no-editable
11+
RUN uv sync --locked --no-dev --extra precis --extra cursor --no-editable
1212

1313
FROM python:3.14-slim-trixie@sha256:51dafde81dbdb6ebde285137a295cf18a47ca95234fe388a343719cb97305b3d
1414

‎doc/_examples/asgi_integration.py‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import json
2+
import os
23
from urllib.parse import parse_qsl
34

45
from scim2_models import SCIMException
@@ -10,7 +11,7 @@
1011
from scim2_server.service import ScimService
1112
from scim2_server.utils import load_default_provider
1213

13-
service = ScimService(load_default_provider())
14+
service = ScimService(load_default_provider(), secret=os.environ["SCIM_SECRET"])
1415
handler = AsyncScimHandler(service, AsyncInMemoryStorage())
1516

1617

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
from doc._examples.async_sqlite_storage import AsyncSQLiteStorage
2+
from scim2_server.storage import SearchPage
3+
4+
5+
class AsyncCursorSQLiteStorage(AsyncSQLiteStorage):
6+
supports_cursors = True
7+
8+
async def search(self, resource_types, search_request, *, position=None):
9+
types = {resource_type.name: resource_type for resource_type in resource_types}
10+
[(total,)] = await self._select(types, "COUNT(*)")
11+
if search_request.cursor is None:
12+
return await self._search_index(types, total, search_request)
13+
return await self._search_cursor(types, total, search_request, position)
14+
15+
async def _search_index(self, types, total, search_request):
16+
count = search_request.count
17+
page = [-1 if count is None else count, search_request.start_index_0]
18+
rows = await self._select(types, "*", "ORDER BY id LIMIT ? OFFSET ?", page)
19+
return SearchPage(total, self._to_resources(types, rows))
20+
21+
async def _search_cursor(self, types, total, search_request, position):
22+
count = search_request.count
23+
limit = -1 if count is None else count + 1
24+
if position is None or "after" in position:
25+
after = "" if position is None else position["after"]
26+
clause = "AND id > ? ORDER BY id LIMIT ?"
27+
rows = await self._select(types, "*", clause, [after, limit])
28+
resources = self._to_resources(types, rows[:count])
29+
more_after, more_before = len(rows) > len(resources), position is not None
30+
else:
31+
clause = "AND id < ? ORDER BY id DESC LIMIT ?"
32+
rows = await self._select(types, "*", clause, [position["before"], limit])
33+
resources = self._to_resources(types, rows[:count][::-1])
34+
more_after, more_before = True, len(rows) > len(resources)
35+
36+
result = SearchPage(total, resources)
37+
if resources and more_after:
38+
result.next = {"after": resources[-1].id}
39+
if resources and more_before:
40+
result.previous = {"before": resources[0].id}
41+
return result
42+
43+
async def _select(self, types, columns, clause="", parameters=()):
44+
connection = await self.connect()
45+
among = ", ".join("?" * len(types))
46+
cursor = await connection.execute(
47+
f"SELECT {columns} FROM resources WHERE resource_type IN ({among}) {clause}",
48+
[*types, *parameters],
49+
)
50+
return list(await cursor.fetchall())
51+
52+
def _to_resources(self, types, rows):
53+
return [self.row_to_resource(types[row["resource_type"]], row) for row in rows]

‎doc/_examples/async_library_storage.py‎

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616

1717
from doc._examples.library_storage import TABLES
1818
from scim2_server.storage import AsyncScimStorage
19+
from scim2_server.storage import SearchPage
1920

2021

2122
class AsyncLibraryStorage(AsyncScimStorage):
@@ -131,8 +132,12 @@ async def writing(self) -> AsyncIterator[aiosqlite.Connection]:
131132
raise UniquenessException from None
132133

133134
async def search(
134-
self, resource_types: list[ResourceType], search_request: SearchRequest[Any]
135-
) -> tuple[int, list[Resource[Any]]]:
135+
self,
136+
resource_types: list[ResourceType],
137+
search_request: SearchRequest[Any],
138+
*,
139+
position: Any = None,
140+
) -> SearchPage:
136141
connection = await self.connect()
137142
found: list[Resource[Any]] = []
138143
for resource_type in resource_types:
@@ -144,6 +149,5 @@ async def search(
144149
resource for resource in found if search_request.filter.match(resource)
145150
]
146151
found = search_request.sort(found)
147-
start = (search_request.start_index or 1) - 1
148-
stop = None if search_request.count is None else start + search_request.count
149-
return len(found), found[start:stop]
152+
page = found[search_request.start_index_0 : search_request.stop_index_0]
153+
return SearchPage(len(found), page)

‎doc/_examples/async_sqlite_storage.py‎

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919

2020
from doc._examples.sqlite_storage import TABLE
2121
from scim2_server.storage import AsyncScimStorage
22+
from scim2_server.storage import SearchPage
2223

2324

2425
class AsyncSQLiteStorage(AsyncScimStorage):
@@ -153,8 +154,12 @@ async def writing(self) -> AsyncIterator[aiosqlite.Connection]:
153154
raise UniquenessException from None
154155

155156
async def search(
156-
self, resource_types: list[ResourceType], search_request: SearchRequest[Any]
157-
) -> tuple[int, list[Resource[Any]]]:
157+
self,
158+
resource_types: list[ResourceType],
159+
search_request: SearchRequest[Any],
160+
*,
161+
position: Any = None,
162+
) -> SearchPage:
158163
connection = await self.connect()
159164
found = []
160165
for resource_type in resource_types:
@@ -171,6 +176,5 @@ async def search(
171176
resource for resource in found if search_request.filter.match(resource)
172177
]
173178
found = search_request.sort(found)
174-
start = (search_request.start_index or 1) - 1
175-
stop = None if search_request.count is None else start + search_request.count
176-
return len(found), found[start:stop]
179+
page = found[search_request.start_index_0 : search_request.stop_index_0]
180+
return SearchPage(len(found), page)

‎doc/_examples/cursor_storage.py‎

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
from doc._examples.sqlite_storage import SQLiteStorage
2+
from scim2_server.storage import SearchPage
3+
4+
5+
class CursorSQLiteStorage(SQLiteStorage):
6+
supports_cursors = True
7+
8+
def search(self, resource_types, search_request, *, position=None):
9+
types = {resource_type.name: resource_type for resource_type in resource_types}
10+
[(total,)] = self._select(types, "COUNT(*)")
11+
if search_request.cursor is None:
12+
return self._search_index(types, total, search_request)
13+
return self._search_cursor(types, total, search_request, position)
14+
15+
def _search_index(self, types, total, search_request):
16+
count = search_request.count
17+
page = [-1 if count is None else count, search_request.start_index_0]
18+
rows = self._select(types, "*", "ORDER BY id LIMIT ? OFFSET ?", page)
19+
return SearchPage(total, self._to_resources(types, rows))
20+
21+
def _search_cursor(self, types, total, search_request, position):
22+
count = search_request.count
23+
limit = -1 if count is None else count + 1
24+
if position is None or "after" in position:
25+
after = "" if position is None else position["after"]
26+
clause = "AND id > ? ORDER BY id LIMIT ?"
27+
rows = self._select(types, "*", clause, [after, limit])
28+
resources = self._to_resources(types, rows[:count])
29+
more_after, more_before = len(rows) > len(resources), position is not None
30+
else:
31+
clause = "AND id < ? ORDER BY id DESC LIMIT ?"
32+
rows = self._select(types, "*", clause, [position["before"], limit])
33+
resources = self._to_resources(types, rows[:count][::-1])
34+
more_after, more_before = True, len(rows) > len(resources)
35+
36+
result = SearchPage(total, resources)
37+
if resources and more_after:
38+
result.next = {"after": resources[-1].id}
39+
if resources and more_before:
40+
result.previous = {"before": resources[0].id}
41+
return result
42+
43+
def _select(self, types, columns, clause="", parameters=()):
44+
among = ", ".join("?" * len(types))
45+
return self.connection.execute(
46+
f"SELECT {columns} FROM resources WHERE resource_type IN ({among}) {clause}",
47+
[*types, *parameters],
48+
).fetchall()
49+
50+
def _to_resources(self, types, rows):
51+
return [self.row_to_resource(types[row["resource_type"]], row) for row in rows]

‎doc/_examples/library_storage.py‎

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@
2727
from scim2_models import User
2828

2929
from scim2_server.storage import ScimStorage
30+
from scim2_server.storage import SearchPage
3031

3132
MEMBERS = """
3233
CREATE TABLE IF NOT EXISTS members (
@@ -233,8 +234,12 @@ def writing(self) -> Iterator[None]:
233234
raise UniquenessException from None
234235

235236
def search(
236-
self, resource_types: list[ResourceType], search_request: SearchRequest[Any]
237-
) -> tuple[int, list[Resource[Any]]]:
237+
self,
238+
resource_types: list[ResourceType],
239+
search_request: SearchRequest[Any],
240+
*,
241+
position: Any = None,
242+
) -> SearchPage:
238243
found: list[Resource[Any]] = []
239244
for resource_type in resource_types:
240245
table = TABLES[resource_type.name]
@@ -245,6 +250,5 @@ def search(
245250
resource for resource in found if search_request.filter.match(resource)
246251
]
247252
found = search_request.sort(found)
248-
start = (search_request.start_index or 1) - 1
249-
stop = None if search_request.count is None else start + search_request.count
250-
return len(found), found[start:stop]
253+
page = found[search_request.start_index_0 : search_request.stop_index_0]
254+
return SearchPage(len(found), page)

‎doc/_examples/sqlite_storage.py‎

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
from scim2_models import UniquenessException
1818

1919
from scim2_server.storage import ScimStorage
20+
from scim2_server.storage import SearchPage
2021

2122
TABLE = """
2223
CREATE TABLE IF NOT EXISTS resources (
@@ -148,8 +149,12 @@ def writing(self) -> Iterator[None]:
148149
raise UniquenessException from None
149150

150151
def search(
151-
self, resource_types: list[ResourceType], search_request: SearchRequest[Any]
152-
) -> tuple[int, list[Resource[Any]]]:
152+
self,
153+
resource_types: list[ResourceType],
154+
search_request: SearchRequest[Any],
155+
*,
156+
position: Any = None,
157+
) -> SearchPage:
153158
found = []
154159
for resource_type in resource_types:
155160
rows = self.connection.execute(
@@ -162,6 +167,5 @@ def search(
162167
resource for resource in found if search_request.filter.match(resource)
163168
]
164169
found = search_request.sort(found)
165-
start = (search_request.start_index or 1) - 1
166-
stop = None if search_request.count is None else start + search_request.count
167-
return len(found), found[start:stop]
170+
page = found[search_request.start_index_0 : search_request.stop_index_0]
171+
return SearchPage(len(found), page)

‎doc/_examples/wsgi_integration.py‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import json
2+
import os
23
from urllib.parse import parse_qsl
34
from wsgiref.util import application_uri
45

@@ -11,7 +12,7 @@
1112
from scim2_server.service import ScimService
1213
from scim2_server.utils import load_default_provider
1314

14-
service = ScimService(load_default_provider())
15+
service = ScimService(load_default_provider(), secret=os.environ["SCIM_SECRET"])
1516
handler = ScimHandler(service, InMemoryStorage())
1617

1718

‎doc/changelog.rst‎

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

4+
[0.8.0] - Unreleased
5+
--------------------
6+
7+
Added
8+
^^^^^
9+
- Cursor pagination of :rfc:`9865`. A configuration that announces it in ``pagination`` gets
10+
``nextCursor`` and ``previousCursor`` in the search responses. The clients cannot read or
11+
forge the cursors. The server refuses a cursor of another query, another ``count``, or older
12+
than ``cursorTimeout``. The cursors give stable pages: a resource that exists during the whole
13+
paging is returned once, even when others are created or deleted. Cursor pagination needs the
14+
new ``secret`` parameter of :class:`~scim2_server.service.ScimService`, the ``cursor`` extra, and
15+
a storage that sets the new
16+
:attr:`~scim2_server.storage.ScimStorage.supports_cursors`, such as
17+
:class:`~scim2_server.memory.InMemoryStorage`. The server refuses to start otherwise.
18+
- The ``scim2-server`` command pages with cursors when its ``--service-provider-config``
19+
announces them. Its container image includes the ``cursor`` extra.
20+
- :class:`~scim2_server.applications.wsgi.WSGIApplication` and
21+
:class:`~scim2_server.applications.asgi.ASGIApplication` give their default service the secret
22+
of the ``SCIM2_SERVER_SECRET`` environment variable.
23+
- The ``maxPageSize`` of ``pagination`` bounds the ``count`` of every search.
24+
- :class:`~scim2_server.testing.ScimStorageContract` and
25+
:class:`~scim2_server.testing.AsyncScimStorageContract` check that the cursors of the
26+
storages that support them give stable pages.
27+
28+
Changed
29+
^^^^^^^
30+
- :meth:`ScimStorage.search <scim2_server.storage.ScimStorage.search>` returns a
31+
:class:`~scim2_server.storage.SearchPage` instead of a tuple, and takes the ``position`` of the
32+
page when a cursor pages the resources. This also applies to code that calls it directly, such
33+
as the ``search`` method of :class:`~scim2_server.memory.InMemoryStorage`.
34+
- :class:`~scim2_server.memory.InMemoryStorage` returns the resources in the order of their
35+
identifiers when the search has no ``sortBy``, instead of the order of creation. The same goes
36+
for the resources with the same sort value.
37+
- For the subclasses of :class:`~scim2_server.service.ScimService`:
38+
:meth:`~scim2_server.service.ScimService.read_search`, ``read_search_query`` and
39+
``read_search_body`` return the search request with the position of the page its cursor asks
40+
for, and :meth:`~scim2_server.service.ScimService.search_response` takes the searched resource
41+
types and a :class:`~scim2_server.storage.SearchPage` instead of the number of resources and
42+
the page.
43+
- The WSGI and ASGI applications keep the query parameters without a value, as scim2-flask does.
44+
An empty ``filter``, ``sortBy`` or ``count`` gets a 400 response instead of being ignored.
45+
- With ``pagination`` in the configuration, a search without ``count`` gets ``defaultPageSize``
46+
resources, by index as by cursor, instead of every resource up to ``maxResults``.
47+
- A search with a ``cursor`` gets a 501 response when the configuration does not announce
48+
cursor pagination, instead of ignoring the cursor.
49+
50+
Deprecated
51+
^^^^^^^^^^
52+
- A storage whose ``search`` method does not accept ``position`` raises a
53+
:class:`DeprecationWarning`. Such a storage pages by index only. scim2-server 0.9 will require
54+
``position``.
55+
456
[0.7.1] - 2026-10-07
557
--------------------
658

0 commit comments

Comments
 (0)