|
3 | 3 | from typing import Any |
4 | 4 |
|
5 | 5 | from scim2_models import AttributeBinding |
| 6 | +from scim2_models import InvalidCursorException |
6 | 7 | from scim2_models import InvalidFilterException |
7 | 8 | from scim2_models import InvalidPathException |
8 | 9 | from scim2_models import ResponseParameters |
|
29 | 30 | from sqlalchemy import literal |
30 | 31 | from sqlalchemy import not_ |
31 | 32 | from sqlalchemy import null |
32 | | -from sqlalchemy import nulls_first |
33 | 33 | from sqlalchemy import nulls_last |
34 | 34 | from sqlalchemy import or_ |
35 | 35 | from sqlalchemy import select |
|
40 | 40 | from .comparison import _SUBSTRINGS |
41 | 41 | from .comparison import _Comparator |
42 | 42 | from .conversion import _as_stored |
| 43 | +from .keyset import _Anchor |
| 44 | +from .keyset import _beyond |
| 45 | +from .keyset import _keyset_order |
43 | 46 | from .mapping import ResourceMapping |
44 | 47 | from .mapping import _Collection |
45 | 48 | from .mapping import _Column |
@@ -270,28 +273,6 @@ def _sort_key( |
270 | 273 | return expression |
271 | 274 |
|
272 | 275 |
|
273 | | -def _sorted(expression: Any, search_request: SearchRequest[Any]) -> Any: |
274 | | - """Per RFC 7644 §3.4.2.3, resources without a value come last when ascending and first when descending.""" |
275 | | - if search_request.sort_order == SearchRequest.SortOrder.descending: |
276 | | - return nulls_first(expression.desc()) |
277 | | - return nulls_last(expression.asc()) |
278 | | - |
279 | | - |
280 | | -def _order_by( |
281 | | - mapping: ResourceMapping, |
282 | | - search_request: SearchRequest[Any], |
283 | | - comparator: _Comparator, |
284 | | -) -> list[Any]: |
285 | | - """Return the ORDER BY terms of a search, the record identifier last. |
286 | | -
|
287 | | - Without the identifier, records sharing a sort value could change pages |
288 | | - from a request to the next. |
289 | | - """ |
290 | | - key = _sort_key(mapping, search_request, comparator) |
291 | | - terms = [] if key is None else [_sorted(key, search_request)] |
292 | | - return [*terms, mapping._id.expression] |
293 | | - |
294 | | - |
295 | 276 | def _first_entry(collection: _Collection, expression: Any) -> Any: |
296 | 277 | """Return the value of the primary entry of a collection, or else of its first one.""" |
297 | 278 | relationship = collection.relationship_property |
@@ -321,39 +302,74 @@ def _where( |
321 | 302 | return _FilterTranslator(mapping, scim_filter, comparator).visit(scim_filter.ast) |
322 | 303 |
|
323 | 304 |
|
| 305 | +def _cursor_page(page: Select[Any], search_request: SearchRequest[Any]) -> Select[Any]: |
| 306 | + """Limit a cursor page to count + 1 rows, the last one telling whether a page follows.""" |
| 307 | + if search_request.count is None: |
| 308 | + return page |
| 309 | + return page.limit(search_request.count + 1) |
| 310 | + |
| 311 | + |
| 312 | +def _index_page(page: Select[Any], search_request: SearchRequest[Any]) -> Select[Any]: |
| 313 | + page = page.offset(search_request.start_index_0 or 0) |
| 314 | + if search_request.count is None: |
| 315 | + return page |
| 316 | + return page.limit(search_request.count) |
| 317 | + |
| 318 | + |
324 | 319 | def _search_statements( |
325 | 320 | mapping: ResourceMapping, |
326 | 321 | search_request: SearchRequest[Any], |
327 | 322 | comparator: _Comparator, |
328 | | -) -> tuple[Select[tuple[int]], Select[Any]]: |
329 | | - """Return the statement counting the matching records, and the one selecting a page of them.""" |
| 323 | + anchor: _Anchor | None = None, |
| 324 | +) -> tuple[Select[tuple[int]], Select[tuple[Any, Any, str]]]: |
| 325 | + """Return the statement counting the matching records, and the one selecting a page of them. |
| 326 | +
|
| 327 | + The page lists each record with its sort value and its identifier as |
| 328 | + text. The records are ordered by sort value, then by identifier: without |
| 329 | + the identifier, records sharing a sort value could change pages from a |
| 330 | + request to the next. A cursor page is read from the anchor, in the reverse |
| 331 | + order for a previous page. |
| 332 | + """ |
330 | 333 | condition = _where(mapping, search_request, comparator) |
331 | 334 | count = select(func.count()).select_from(mapping.record).where(condition) |
| 335 | + key = _sort_key(mapping, search_request, comparator) |
| 336 | + record_id = mapping._id.expression |
| 337 | + descending = search_request.sort_order == SearchRequest.SortOrder.descending |
| 338 | + forward = anchor is None or anchor.forward |
332 | 339 | page = ( |
333 | | - select(mapping.record) |
| 340 | + select( |
| 341 | + mapping.record, |
| 342 | + (null() if key is None else key).label("key"), |
| 343 | + cast(record_id, String).label("id"), |
| 344 | + ) |
334 | 345 | .where(condition) |
335 | | - .order_by(*_order_by(mapping, search_request, comparator)) |
| 346 | + .order_by(*_keyset_order(key, [record_id], descending, forward)) |
336 | 347 | .options( |
337 | 348 | *mapping._loader_options(mapping._returned_collections(search_request)) |
338 | 349 | ) |
339 | | - .offset(search_request.start_index_0 or 0) |
340 | 350 | ) |
341 | | - if search_request.count is not None: |
342 | | - page = page.limit(search_request.count) |
343 | | - return count, page |
| 351 | + if search_request.cursor is None: |
| 352 | + return count, _index_page(page, search_request) |
| 353 | + if anchor is not None: |
| 354 | + value = _as_key(mapping._id, anchor.ties[0]) |
| 355 | + if value is None: |
| 356 | + raise InvalidCursorException |
| 357 | + page = page.where(_beyond(key, [record_id], anchor, [value], descending)) |
| 358 | + return count, _cursor_page(page, search_request) |
344 | 359 |
|
345 | 360 |
|
346 | 361 | def _root_statements( |
347 | 362 | mappings: list[ResourceMapping], |
348 | 363 | search_request: SearchRequest[Any], |
349 | 364 | comparator: _Comparator, |
350 | | -) -> tuple[Select[tuple[int]], Select[tuple[int, str]]]: |
| 365 | + anchor: _Anchor | None = None, |
| 366 | +) -> tuple[Select[tuple[int]], Select[tuple[int, str, Any]]]: |
351 | 367 | """Return the statement counting the matching records of several mappings, and the one selecting a page of them. |
352 | 368 |
|
353 | | - The page lists the position of the mapping and the identifier of each |
354 | | - record, as text, so that the identifiers of every table fit in one column. |
355 | | - A mapping whose model does not declare the sort attribute sorts its |
356 | | - records as having no value. |
| 369 | + The page lists the position of the mapping, the identifier of each |
| 370 | + record as text, so that the identifiers of every table fit in one column, |
| 371 | + and its sort value. A mapping whose model does not declare the sort |
| 372 | + attribute sorts its records as having no value. |
357 | 373 | """ |
358 | 374 | conditions = [_where(mapping, search_request, comparator) for mapping in mappings] |
359 | 375 | keys = [_sort_key(mapping, search_request, comparator) for mapping in mappings] |
@@ -382,15 +398,20 @@ def _root_statements( |
382 | 398 | columns.append(key.label("key")) |
383 | 399 | branches.append(select(*columns).where(condition)) |
384 | 400 | rows = union_all(*branches).subquery() |
385 | | - terms = [] if key_type is None else [_sorted(rows.c.key, search_request)] |
386 | | - page = ( |
387 | | - select(rows.c.position, rows.c.id) |
388 | | - .order_by(*terms, rows.c.position, rows.c.id) |
389 | | - .offset(search_request.start_index_0 or 0) |
390 | | - ) |
391 | | - if search_request.count is not None: |
392 | | - page = page.limit(search_request.count) |
393 | | - return count, page |
| 401 | + row_key = None if key_type is None else rows.c.key |
| 402 | + ties = [rows.c.position, rows.c.id] |
| 403 | + descending = search_request.sort_order == SearchRequest.SortOrder.descending |
| 404 | + forward = anchor is None or anchor.forward |
| 405 | + page = select( |
| 406 | + rows.c.position, rows.c.id, (null() if row_key is None else row_key) |
| 407 | + ).order_by(*_keyset_order(row_key, ties, descending, forward)) |
| 408 | + if search_request.cursor is None: |
| 409 | + return count, _index_page(page, search_request) |
| 410 | + if anchor is not None: |
| 411 | + if anchor.ties[0] not in range(len(mappings)): |
| 412 | + raise InvalidCursorException |
| 413 | + page = page.where(_beyond(row_key, ties, anchor, anchor.ties, descending)) |
| 414 | + return count, _cursor_page(page, search_request) |
394 | 415 |
|
395 | 416 |
|
396 | 417 | def _records_statement( |
|
0 commit comments