hexkit 8.2.1__tar.gz → 8.4.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (88) hide show
  1. {hexkit-8.2.1/src/hexkit.egg-info → hexkit-8.4.0}/PKG-INFO +1 -1
  2. {hexkit-8.2.1 → hexkit-8.4.0}/pyproject.toml +1 -1
  3. {hexkit-8.2.1 → hexkit-8.4.0}/src/hexkit/protocols/dao.py +70 -4
  4. {hexkit-8.2.1 → hexkit-8.4.0}/src/hexkit/providers/mongodb/provider/dao.py +68 -8
  5. {hexkit-8.2.1 → hexkit-8.4.0}/src/hexkit/providers/mongokafka/provider/daopub.py +87 -11
  6. {hexkit-8.2.1 → hexkit-8.4.0}/src/hexkit/providers/testing/dao.py +88 -15
  7. {hexkit-8.2.1 → hexkit-8.4.0/src/hexkit.egg-info}/PKG-INFO +1 -1
  8. {hexkit-8.2.1 → hexkit-8.4.0}/LICENSE +0 -0
  9. {hexkit-8.2.1 → hexkit-8.4.0}/MANIFEST.in +0 -0
  10. {hexkit-8.2.1 → hexkit-8.4.0}/README.md +0 -0
  11. {hexkit-8.2.1 → hexkit-8.4.0}/setup.cfg +0 -0
  12. {hexkit-8.2.1 → hexkit-8.4.0}/src/hexkit/__init__.py +0 -0
  13. {hexkit-8.2.1 → hexkit-8.4.0}/src/hexkit/__main__.py +0 -0
  14. {hexkit-8.2.1 → hexkit-8.4.0}/src/hexkit/base.py +0 -0
  15. {hexkit-8.2.1 → hexkit-8.4.0}/src/hexkit/config.py +0 -0
  16. {hexkit-8.2.1 → hexkit-8.4.0}/src/hexkit/correlation.py +0 -0
  17. {hexkit-8.2.1 → hexkit-8.4.0}/src/hexkit/custom_types.py +0 -0
  18. {hexkit-8.2.1 → hexkit-8.4.0}/src/hexkit/log.py +0 -0
  19. {hexkit-8.2.1 → hexkit-8.4.0}/src/hexkit/opentelemetry.py +0 -0
  20. {hexkit-8.2.1 → hexkit-8.4.0}/src/hexkit/protocols/__init__.py +0 -0
  21. {hexkit-8.2.1 → hexkit-8.4.0}/src/hexkit/protocols/daopub.py +0 -0
  22. {hexkit-8.2.1 → hexkit-8.4.0}/src/hexkit/protocols/daosub.py +0 -0
  23. {hexkit-8.2.1 → hexkit-8.4.0}/src/hexkit/protocols/eventpub.py +0 -0
  24. {hexkit-8.2.1 → hexkit-8.4.0}/src/hexkit/protocols/eventsub.py +0 -0
  25. {hexkit-8.2.1 → hexkit-8.4.0}/src/hexkit/protocols/kvstore.py +0 -0
  26. {hexkit-8.2.1 → hexkit-8.4.0}/src/hexkit/protocols/objstorage.py +0 -0
  27. {hexkit-8.2.1 → hexkit-8.4.0}/src/hexkit/providers/__init__.py +0 -0
  28. {hexkit-8.2.1 → hexkit-8.4.0}/src/hexkit/providers/akafka/__init__.py +0 -0
  29. {hexkit-8.2.1 → hexkit-8.4.0}/src/hexkit/providers/akafka/config.py +0 -0
  30. {hexkit-8.2.1 → hexkit-8.4.0}/src/hexkit/providers/akafka/provider/__init__.py +0 -0
  31. {hexkit-8.2.1 → hexkit-8.4.0}/src/hexkit/providers/akafka/provider/daosub.py +0 -0
  32. {hexkit-8.2.1 → hexkit-8.4.0}/src/hexkit/providers/akafka/provider/eventpub.py +0 -0
  33. {hexkit-8.2.1 → hexkit-8.4.0}/src/hexkit/providers/akafka/provider/eventsub.py +0 -0
  34. {hexkit-8.2.1 → hexkit-8.4.0}/src/hexkit/providers/akafka/provider/utils.py +0 -0
  35. {hexkit-8.2.1 → hexkit-8.4.0}/src/hexkit/providers/akafka/testcontainer.py +0 -0
  36. {hexkit-8.2.1 → hexkit-8.4.0}/src/hexkit/providers/akafka/testutils.py +0 -0
  37. {hexkit-8.2.1 → hexkit-8.4.0}/src/hexkit/providers/mongodb/__init__.py +0 -0
  38. {hexkit-8.2.1 → hexkit-8.4.0}/src/hexkit/providers/mongodb/config.py +0 -0
  39. {hexkit-8.2.1 → hexkit-8.4.0}/src/hexkit/providers/mongodb/migrations/__init__.py +0 -0
  40. {hexkit-8.2.1 → hexkit-8.4.0}/src/hexkit/providers/mongodb/migrations/_manager.py +0 -0
  41. {hexkit-8.2.1 → hexkit-8.4.0}/src/hexkit/providers/mongodb/migrations/_utils.py +0 -0
  42. {hexkit-8.2.1 → hexkit-8.4.0}/src/hexkit/providers/mongodb/migrations/helpers.py +0 -0
  43. {hexkit-8.2.1 → hexkit-8.4.0}/src/hexkit/providers/mongodb/provider/__init__.py +0 -0
  44. {hexkit-8.2.1 → hexkit-8.4.0}/src/hexkit/providers/mongodb/provider/client.py +0 -0
  45. {hexkit-8.2.1 → hexkit-8.4.0}/src/hexkit/providers/mongodb/provider/kvstore.py +0 -0
  46. {hexkit-8.2.1 → hexkit-8.4.0}/src/hexkit/providers/mongodb/provider/utils.py +0 -0
  47. {hexkit-8.2.1 → hexkit-8.4.0}/src/hexkit/providers/mongodb/testutils.py +0 -0
  48. {hexkit-8.2.1 → hexkit-8.4.0}/src/hexkit/providers/mongokafka/__init__.py +0 -0
  49. {hexkit-8.2.1 → hexkit-8.4.0}/src/hexkit/providers/mongokafka/config.py +0 -0
  50. {hexkit-8.2.1 → hexkit-8.4.0}/src/hexkit/providers/mongokafka/provider/__init__.py +0 -0
  51. {hexkit-8.2.1 → hexkit-8.4.0}/src/hexkit/providers/mongokafka/provider/persistent_pub.py +0 -0
  52. {hexkit-8.2.1 → hexkit-8.4.0}/src/hexkit/providers/mongokafka/testutils.py +0 -0
  53. {hexkit-8.2.1 → hexkit-8.4.0}/src/hexkit/providers/redis/__init__.py +0 -0
  54. {hexkit-8.2.1 → hexkit-8.4.0}/src/hexkit/providers/redis/config.py +0 -0
  55. {hexkit-8.2.1 → hexkit-8.4.0}/src/hexkit/providers/redis/provider/__init__.py +0 -0
  56. {hexkit-8.2.1 → hexkit-8.4.0}/src/hexkit/providers/redis/provider/client.py +0 -0
  57. {hexkit-8.2.1 → hexkit-8.4.0}/src/hexkit/providers/redis/provider/kvstore.py +0 -0
  58. {hexkit-8.2.1 → hexkit-8.4.0}/src/hexkit/providers/redis/testutils.py +0 -0
  59. {hexkit-8.2.1 → hexkit-8.4.0}/src/hexkit/providers/s3/__init__.py +0 -0
  60. {hexkit-8.2.1 → hexkit-8.4.0}/src/hexkit/providers/s3/config.py +0 -0
  61. {hexkit-8.2.1 → hexkit-8.4.0}/src/hexkit/providers/s3/provider/__init__.py +0 -0
  62. {hexkit-8.2.1 → hexkit-8.4.0}/src/hexkit/providers/s3/provider/kvstore.py +0 -0
  63. {hexkit-8.2.1 → hexkit-8.4.0}/src/hexkit/providers/s3/provider/objstorage.py +0 -0
  64. {hexkit-8.2.1 → hexkit-8.4.0}/src/hexkit/providers/s3/test_files/__init__.py +0 -0
  65. {hexkit-8.2.1 → hexkit-8.4.0}/src/hexkit/providers/s3/test_files/test_file1.yaml +0 -0
  66. {hexkit-8.2.1 → hexkit-8.4.0}/src/hexkit/providers/s3/test_files/test_file2.yaml +0 -0
  67. {hexkit-8.2.1 → hexkit-8.4.0}/src/hexkit/providers/s3/test_files/test_file3.yaml +0 -0
  68. {hexkit-8.2.1 → hexkit-8.4.0}/src/hexkit/providers/s3/test_files/test_file4.yaml +0 -0
  69. {hexkit-8.2.1 → hexkit-8.4.0}/src/hexkit/providers/s3/testutils/__init__.py +0 -0
  70. {hexkit-8.2.1 → hexkit-8.4.0}/src/hexkit/providers/s3/testutils/_fixtures.py +0 -0
  71. {hexkit-8.2.1 → hexkit-8.4.0}/src/hexkit/providers/s3/testutils/_typical_workflow.py +0 -0
  72. {hexkit-8.2.1 → hexkit-8.4.0}/src/hexkit/providers/s3/testutils/_utils.py +0 -0
  73. {hexkit-8.2.1 → hexkit-8.4.0}/src/hexkit/providers/testing/__init__.py +0 -0
  74. {hexkit-8.2.1 → hexkit-8.4.0}/src/hexkit/providers/testing/eventpub.py +0 -0
  75. {hexkit-8.2.1 → hexkit-8.4.0}/src/hexkit/providers/testing/kvstore.py +0 -0
  76. {hexkit-8.2.1 → hexkit-8.4.0}/src/hexkit/providers/testing/objstorage.py +0 -0
  77. {hexkit-8.2.1 → hexkit-8.4.0}/src/hexkit/providers/vault/__init__.py +0 -0
  78. {hexkit-8.2.1 → hexkit-8.4.0}/src/hexkit/providers/vault/config.py +0 -0
  79. {hexkit-8.2.1 → hexkit-8.4.0}/src/hexkit/providers/vault/provider/__init__.py +0 -0
  80. {hexkit-8.2.1 → hexkit-8.4.0}/src/hexkit/providers/vault/provider/client.py +0 -0
  81. {hexkit-8.2.1 → hexkit-8.4.0}/src/hexkit/providers/vault/provider/kvstore.py +0 -0
  82. {hexkit-8.2.1 → hexkit-8.4.0}/src/hexkit/providers/vault/testutils.py +0 -0
  83. {hexkit-8.2.1 → hexkit-8.4.0}/src/hexkit/py.typed +0 -0
  84. {hexkit-8.2.1 → hexkit-8.4.0}/src/hexkit/utils.py +0 -0
  85. {hexkit-8.2.1 → hexkit-8.4.0}/src/hexkit.egg-info/SOURCES.txt +0 -0
  86. {hexkit-8.2.1 → hexkit-8.4.0}/src/hexkit.egg-info/dependency_links.txt +0 -0
  87. {hexkit-8.2.1 → hexkit-8.4.0}/src/hexkit.egg-info/requires.txt +0 -0
  88. {hexkit-8.2.1 → hexkit-8.4.0}/src/hexkit.egg-info/top_level.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: hexkit
3
- Version: 8.2.1
3
+ Version: 8.4.0
4
4
  Summary: A Toolkit for Building Microservices using the Hexagonal Architecture
5
5
  Author-email: "German Human Genome Phenome Archive (GHGA)" <contact@ghga.de>
6
6
  License: Apache 2.0
@@ -23,7 +23,7 @@ classifiers = [
23
23
  "Intended Audience :: Developers",
24
24
  ]
25
25
  name = "hexkit"
26
- version = "8.2.1"
26
+ version = "8.4.0"
27
27
  description = "A Toolkit for Building Microservices using the Hexagonal Architecture"
28
28
  dependencies = [
29
29
  "opentelemetry-api >=1.39, <2",
@@ -20,7 +20,7 @@ with the database.
20
20
 
21
21
  import typing
22
22
  from abc import ABC, abstractmethod
23
- from collections.abc import AsyncIterator, Collection, Mapping
23
+ from collections.abc import AsyncIterator, Awaitable, Callable, Collection, Mapping
24
24
  from contextlib import AbstractAsyncContextManager
25
25
  from functools import partial
26
26
  from typing import Any, Generic, TypeVar
@@ -35,6 +35,7 @@ __all__ = [
35
35
  "Dao",
36
36
  "DaoFactoryProtocol",
37
37
  "FindError",
38
+ "FindResult",
38
39
  "MultipleHitsFoundError",
39
40
  "ResourceAlreadyExistsError",
40
41
  "ResourceNotFoundError",
@@ -137,6 +138,47 @@ class DbTimeoutError(DaoError):
137
138
  UUID4Field = partial(Field, default_factory=uuid4)
138
139
 
139
140
 
141
+ class FindResult(AsyncIterator[Dto]):
142
+ """An async-iterable result from find_all() that also exposes pagination metadata.
143
+
144
+ The `total_count()` method performs an additional count query on first call (lazily)
145
+ and caches the result for subsequent calls.
146
+ """
147
+
148
+ def __init__(
149
+ self,
150
+ *,
151
+ results_iterator: AsyncIterator[Dto],
152
+ get_total_count: Callable[[], Awaitable[int]],
153
+ ) -> None:
154
+ self._iterator = results_iterator
155
+ self._get_total_count = get_total_count
156
+ self._cached_total: int | None = None
157
+
158
+ def __aiter__(self) -> "FindResult[Dto]": # noqa: D105
159
+ return self
160
+
161
+ async def __anext__(self) -> Dto: # noqa: D105
162
+ return await self._iterator.__anext__()
163
+
164
+ async def total_count(self) -> int:
165
+ """Total number of matching documents, ignoring skip and limit.
166
+
167
+ The result is cached after the first call.
168
+ """
169
+ if self._cached_total is None:
170
+ self._cached_total = await self._get_total_count()
171
+ return self._cached_total
172
+
173
+ async def to_list(self) -> list[Dto]:
174
+ """Collect all results into a list.
175
+
176
+ Calling this function will consume the iterator. Subsequent cals will return
177
+ an empty list.
178
+ """
179
+ return [item async for item in self]
180
+
181
+
140
182
  class Dao(typing.Protocol[Dto]):
141
183
  """A duck type with methods common to all DAOs."""
142
184
 
@@ -223,7 +265,14 @@ class Dao(typing.Protocol[Dto]):
223
265
  """
224
266
  ...
225
267
 
226
- def find_all(self, *, mapping: Mapping[str, Any]) -> AsyncIterator[Dto]:
268
+ def find_all(
269
+ self,
270
+ *,
271
+ mapping: Mapping[str, Any],
272
+ skip: int | None = None,
273
+ limit: int | None = None,
274
+ sort: list[str] | None = None,
275
+ ) -> "FindResult[Dto]":
227
276
  """Find all resources that match the specified mapping.
228
277
 
229
278
  The values in the mapping are used to filter the resources, these are
@@ -235,10 +284,27 @@ class Dao(typing.Protocol[Dto]):
235
284
  mapping:
236
285
  A mapping where the keys correspond to the names of resource fields
237
286
  and the values correspond to the actual values of the resource fields.
287
+ skip:
288
+ Number of matching resources to skip before yielding results.
289
+ Defaults to None (no skipping).
290
+ limit:
291
+ Maximum number of resources to yield. Defaults to None (no limit).
292
+ Specifying 0 is interpreted literally and returns no results.
293
+ sort:
294
+ A list of field names defining the sort order, where field names
295
+ prefixed with "-" indicate *descending* order. For example, if sort is
296
+ specified as `["name", "-age"]`, the results would be sorted first by
297
+ "name" in ascending order, then by "age" in descending order. When
298
+ paginating with skip/limit, providing a sort order is strongly
299
+ recommended to ensure consistent, deterministic results. Defaults to
300
+ None (no sort).
238
301
 
239
302
  Returns:
240
- An AsyncIterator of hits. All hits are in the form of the respective DTO
241
- model.
303
+ A FindResult that is async-iterable and also provides total_count().
304
+
305
+ Raises:
306
+ InvalidMappingError: If `mapping` doesn't pass validation.
307
+ ValueError: if `skip` or `limit` are less than 0.
242
308
  """
243
309
  ...
244
310
 
@@ -33,6 +33,7 @@ from hexkit.protocols.dao import (
33
33
  Dao,
34
34
  DaoFactoryProtocol,
35
35
  Dto,
36
+ FindResult,
36
37
  IndexBase,
37
38
  InvalidFindMappingError,
38
39
  MultipleHitsFoundError,
@@ -65,7 +66,7 @@ def validate_find_mapping(mapping: Mapping[str, Any], *, dto_model: type[Dto]):
65
66
  by checking if the mapping keys exist as model fields.
66
67
 
67
68
  Raises:
68
- InvalidMappingError: If validation fails.
69
+ InvalidMappingError: If `mapping` doesn't pass validation.
69
70
  """
70
71
  try:
71
72
  validate_fields_in_model(model=dto_model, fields=set(mapping))
@@ -310,7 +311,14 @@ class MongoDbDao(Generic[Dto]):
310
311
  hits = self.find_all(mapping=mapping)
311
312
  return await get_single_hit(hits=hits, mapping=mapping)
312
313
 
313
- async def find_all(self, *, mapping: Mapping[str, Any]) -> AsyncIterator[Dto]:
314
+ def find_all( # noqa: C901
315
+ self,
316
+ *,
317
+ mapping: Mapping[str, Any],
318
+ skip: int | None = None,
319
+ limit: int | None = None,
320
+ sort: list[str] | None = None,
321
+ ) -> FindResult[Dto]:
314
322
  """Find all resources that match the specified mapping.
315
323
 
316
324
  The values in the mapping are used to filter the resources, these are
@@ -322,19 +330,71 @@ class MongoDbDao(Generic[Dto]):
322
330
  mapping:
323
331
  A mapping where the keys correspond to the names of resource fields
324
332
  and the values correspond to the actual values of the resource fields.
333
+ skip:
334
+ Number of matching resources to skip before yielding results.
335
+ Defaults to None (no skipping).
336
+ limit:
337
+ Maximum number of resources to yield. Defaults to None (no limit).
338
+ Specifying 0 is interpreted literally and returns no results.
339
+ sort:
340
+ A list of field names defining the sort order, where field names
341
+ prefixed with "-" indicate *descending* order. For example, if sort is
342
+ specified as `["name", "-age"]`, the results would be sorted first by
343
+ "name" in ascending order, then by "age" in descending order. When
344
+ paginating with skip/limit, providing a sort order is strongly
345
+ recommended to ensure consistent, deterministic results. Defaults to
346
+ None (no sort).
325
347
 
326
348
  Returns:
327
- An AsyncIterator of hits. All hits are in the form of the respective DTO
328
- model.
349
+ A FindResult that is async-iterable and also provides total_count().
329
350
  """
351
+ skip = skip or 0
352
+
353
+ if skip < 0:
354
+ raise ValueError("skip must be >= 0")
355
+ if limit is not None and limit < 0:
356
+ raise ValueError("limit must be >= 0")
357
+
330
358
  validate_find_mapping(mapping, dto_model=self._dto_model)
331
359
  mapping = replace_id_field_in_find_mapping(mapping, self._id_field)
332
360
 
333
- with translate_pymongo_errors():
334
- cursor = self._collection.find(filter=mapping)
361
+ # Convert generic sort spec to MongoDB-specific sort spec
362
+ mongodb_sort: list[tuple[str, int]] = []
363
+ for spec in sort or []:
364
+ field = spec.removeprefix("-")
365
+ order = 1 if field == spec else -1
366
+ if field == self._id_field:
367
+ field = "_id"
368
+ mongodb_sort.append((field, order))
369
+
370
+ collection = self._collection
371
+
372
+ async def _total_count() -> int:
373
+ with translate_pymongo_errors():
374
+ return await collection.count_documents(filter=mapping)
375
+
376
+ if limit == 0:
377
+
378
+ async def _empty_iter() -> AsyncIterator[Dto]:
379
+ return
380
+ yield # turns this into an async generator
381
+
382
+ return FindResult(
383
+ results_iterator=_empty_iter(), get_total_count=_total_count
384
+ )
335
385
 
336
- async for document in cursor:
337
- yield self._document_to_dto(document)
386
+ async def _iter() -> AsyncIterator[Dto]:
387
+ with translate_pymongo_errors():
388
+ cursor = collection.find(filter=mapping)
389
+ if sort:
390
+ cursor = cursor.sort(mongodb_sort)
391
+ cursor = cursor.skip(skip)
392
+ if limit:
393
+ cursor = cursor.limit(limit)
394
+ async for document in cursor:
395
+ yield self._document_to_dto(document)
396
+
397
+ return FindResult(results_iterator=_iter(), get_total_count=_total_count)
338
398
 
339
399
  @classmethod
340
400
  def with_transaction(cls) -> AbstractAsyncContextManager["Dao[Dto]"]:
@@ -42,6 +42,7 @@ from hexkit.custom_types import ID, JsonObject
42
42
  from hexkit.protocols.dao import (
43
43
  Dao,
44
44
  Dto,
45
+ FindResult,
45
46
  ResourceNotFoundError,
46
47
  UniqueConstraintViolationError,
47
48
  )
@@ -369,7 +370,14 @@ class MongoKafkaDaoPublisher(Generic[Dto]):
369
370
  hits = self.find_all(mapping=mapping)
370
371
  return await get_single_hit(hits=hits, mapping=mapping)
371
372
 
372
- async def find_all(self, *, mapping: Mapping[str, Any]) -> AsyncIterator[Dto]:
373
+ def find_all( # noqa: C901
374
+ self,
375
+ *,
376
+ mapping: Mapping[str, Any],
377
+ skip: int | None = None,
378
+ limit: int | None = None,
379
+ sort: list[str] | None = None,
380
+ ) -> FindResult[Dto]:
373
381
  """Find all resources that match the specified mapping.
374
382
 
375
383
  The values in the mapping are used to filter the resources, these are
@@ -381,23 +389,91 @@ class MongoKafkaDaoPublisher(Generic[Dto]):
381
389
  mapping:
382
390
  A mapping where the keys correspond to the names of resource fields
383
391
  and the values correspond to the actual values of the resource fields.
392
+ skip:
393
+ Number of matching resources to skip before yielding results.
394
+ Defaults to None (no skipping).
395
+ limit:
396
+ Maximum number of resources to yield. Defaults to None (no limit).
397
+ Specifying 0 is interpreted literally and returns no results.
398
+ sort:
399
+ A list of field names defining the sort order, where field names
400
+ prefixed with "-" indicate *descending* order. For example, if sort is
401
+ specified as `["name", "-age"]`, the results would be sorted first by
402
+ "name" in ascending order, then by "age" in descending order. When
403
+ paginating with skip/limit, providing a sort order is strongly
404
+ recommended to ensure consistent, deterministic results. Defaults to
405
+ None (no sort).
384
406
 
385
407
  Returns:
386
- An AsyncIterator of hits. All hits are in the form of the respective DTO
387
- model.
408
+ A FindResult that is async-iterable and also provides total_count().
409
+
410
+ Raises:
411
+ InvalidMappingError: If `mapping` doesn't pass validation.
412
+ ValueError: if `skip` or `limit` are less than 0.
388
413
  """
414
+ skip = skip or 0
415
+ if skip < 0:
416
+ raise ValueError("skip must be >= 0")
417
+ if limit is not None and limit < 0:
418
+ raise ValueError("limit must be >= 0")
419
+
389
420
  validate_find_mapping(mapping, dto_model=self._dto_model)
390
421
  mapping = replace_id_field_in_find_mapping(mapping, self._id_field)
391
422
 
392
- with translate_pymongo_errors():
393
- cursor = self._collection.find(filter=mapping)
423
+ # Ensure we don't retrieve deleted docs. Documents lacking outbox metadata are
424
+ # treated as valid (matching update/delete), and the $and wrapper avoids
425
+ # clobbering any caller-supplied $or in the mapping.
426
+ mapping_without_deleted = {
427
+ "$and": [
428
+ dict(mapping),
429
+ {
430
+ "$or": [
431
+ {"__metadata__": {"$exists": False}},
432
+ {"__metadata__.deleted": False},
433
+ ]
434
+ },
435
+ ]
436
+ }
394
437
 
395
- async for document in cursor:
396
- if document.get("__metadata__", {}).get("deleted", False):
397
- continue
398
- yield document_to_dto(
399
- document, id_field=self._id_field, dto_model=self._dto_model
400
- )
438
+ # Convert generic sort spec to MongoDB-specific sort spec
439
+ mongodb_sort: list[tuple[str, int]] = []
440
+ for spec in sort or []:
441
+ field = spec.removeprefix("-")
442
+ order = 1 if field == spec else -1
443
+ if field == self._id_field:
444
+ field = "_id"
445
+ mongodb_sort.append((field, order))
446
+
447
+ collection = self._collection
448
+
449
+ async def _total_count() -> int:
450
+ with translate_pymongo_errors():
451
+ return await collection.count_documents(filter=mapping_without_deleted)
452
+
453
+ if limit == 0:
454
+
455
+ async def _empty_iter() -> AsyncIterator[Dto]:
456
+ return
457
+ yield # turns this into an async generator
458
+
459
+ return FindResult(
460
+ results_iterator=_empty_iter(), get_total_count=_total_count
461
+ )
462
+
463
+ async def _iter() -> AsyncIterator[Dto]:
464
+ with translate_pymongo_errors():
465
+ cursor = collection.find(filter=mapping_without_deleted)
466
+ if sort:
467
+ cursor = cursor.sort(mongodb_sort)
468
+ cursor = cursor.skip(skip)
469
+ if limit:
470
+ cursor = cursor.limit(limit)
471
+ async for document in cursor:
472
+ yield document_to_dto(
473
+ document, id_field=self._id_field, dto_model=self._dto_model
474
+ )
475
+
476
+ return FindResult(results_iterator=_iter(), get_total_count=_total_count)
401
477
 
402
478
  async def insert(self, dto: Dto) -> None:
403
479
  """Create a new resource.
@@ -16,15 +16,17 @@
16
16
  """A mock (in-memory) DAO"""
17
17
 
18
18
  from collections.abc import AsyncIterator, Callable, Mapping
19
- from contextlib import suppress
19
+ from contextlib import AbstractAsyncContextManager, suppress
20
20
  from copy import deepcopy
21
+ from functools import partial
21
22
  from typing import Any, Generic, Protocol, TypeVar
22
- from unittest.mock import AsyncMock, Mock
23
+ from unittest.mock import AsyncMock
23
24
 
24
25
  from pydantic import BaseModel
25
26
 
26
27
  from hexkit.custom_types import ID
27
28
  from hexkit.protocols.dao import (
29
+ FindResult,
28
30
  MultipleHitsFoundError,
29
31
  NoHitsFoundError,
30
32
  ResourceAlreadyExistsError,
@@ -86,6 +88,18 @@ def _get_nested_value(resource: Document, field_path: str) -> Any:
86
88
  return value
87
89
 
88
90
 
91
+ def _sort_key(resource: Document, *, field_path: str) -> tuple[bool, Any]:
92
+ """Build a sort key for a (possibly nested) field path of a resource.
93
+
94
+ Missing values (None) are grouped together and sorted ahead of present values,
95
+ mirroring MongoDB's handling of missing/null fields. The leading boolean keeps
96
+ None values from being compared against present values (or one another), which
97
+ would otherwise raise a TypeError.
98
+ """
99
+ value = _get_nested_value(resource, field_path)
100
+ return (value is not None, value if value is not None else 0)
101
+
102
+
89
103
  class Predicate(Protocol):
90
104
  def evaluate(self, resource: dict[str, Any]) -> bool:
91
105
  """Determine if the supplied dict value satisfies the predicate."""
@@ -345,7 +359,10 @@ class BaseInMemDao(Generic[DTO]):
345
359
  _deserialize: Callable
346
360
  publish_pending = AsyncMock()
347
361
  republish = AsyncMock()
348
- with_transaction = Mock()
362
+
363
+ @classmethod
364
+ def with_transaction(cls) -> AbstractAsyncContextManager["BaseInMemDao[DTO]"]:
365
+ raise NotImplementedError()
349
366
 
350
367
  def __init__(self) -> None:
351
368
  self.resources: dict[ID, Document] = {}
@@ -391,27 +408,83 @@ class BaseInMemDao(Generic[DTO]):
391
408
 
392
409
  raise MultipleHitsFoundError(mapping=mapping)
393
410
 
394
- async def find_all(self, *, mapping: Mapping[str, Any]) -> AsyncIterator[DTO]:
411
+ def _resource_matches(
412
+ self,
413
+ resource: Document,
414
+ mapping: Mapping[str, Any],
415
+ predicates: list["Predicate"],
416
+ ) -> bool:
417
+ """Return whether a resource matches the given mapping or predicates."""
418
+ if self._handle_mql:
419
+ return all(p.evaluate(resource=resource) for p in predicates)
420
+ for key, expected_value in mapping.items():
421
+ if _get_nested_value(resource, key) != expected_value:
422
+ return False
423
+ return True
424
+
425
+ def find_all(
426
+ self,
427
+ *,
428
+ mapping: Mapping[str, Any],
429
+ skip: int | None = None,
430
+ limit: int | None = None,
431
+ sort: list[str] | None = None,
432
+ ) -> "FindResult[DTO]":
395
433
  """Find all resources that match the specified mapping."""
434
+ skip = skip or 0
435
+ if skip < 0:
436
+ raise ValueError("skip must be >= 0")
437
+ if limit is not None and limit < 0:
438
+ raise ValueError("limit must be >= 0")
439
+
396
440
  if "" in mapping:
397
441
  raise KeyError("Query mappings can't contain empty-string keys")
398
442
 
399
443
  _mapping = replace_id_field_in_find_mapping(mapping, self._id_field)
400
444
  predicates = build_predicates(_mapping) if self._handle_mql else []
401
445
 
402
- for resource in self.resources.values():
403
- if self._handle_mql:
404
- matches = all(p.evaluate(resource=resource) for p in predicates)
405
- else:
406
- matches = True
407
- for key, expected_value in mapping.items():
408
- actual_value = _get_nested_value(resource, key)
409
- if actual_value != expected_value:
410
- matches = False
411
- break
412
- if matches:
446
+ matching = [
447
+ resource
448
+ for resource in self.resources.values()
449
+ if self._resource_matches(resource, mapping, predicates)
450
+ ]
451
+
452
+ # Interpret the sorting specification and sort our resources accordingly
453
+ for spec in reversed(sort or []):
454
+ desc = spec.startswith("-")
455
+ field = spec.removeprefix("-")
456
+ # Only the exact ID field is translated to the internal "_id" field, matching
457
+ # the MongoDB provider. Other fields (including nested sub-fields of the ID,
458
+ # which MongoDB cannot sort by) are looked up as-is and resolve to None when
459
+ # absent, sorting consistently rather than raising - again like MongoDB.
460
+ doc_field = "_id" if field == self._id_field else field
461
+ matching.sort(key=partial(_sort_key, field_path=doc_field), reverse=desc)
462
+
463
+ total = len(matching)
464
+
465
+ async def _total_count() -> int:
466
+ return total
467
+
468
+ if limit == 0:
469
+
470
+ async def _empty_iter() -> AsyncIterator[DTO]:
471
+ return
472
+ yield # makes this an async generator
473
+
474
+ return FindResult(
475
+ results_iterator=_empty_iter(), get_total_count=_total_count
476
+ )
477
+
478
+ # Manually apply pagination params
479
+ end = (skip + limit) if limit is not None else None
480
+ page = matching[skip:end]
481
+
482
+ async def _iter() -> AsyncIterator[DTO]:
483
+ for resource in page:
413
484
  yield self._deserialize(resource)
414
485
 
486
+ return FindResult(results_iterator=_iter(), get_total_count=_total_count)
487
+
415
488
  async def insert(self, dto: DTO) -> None:
416
489
  """Insert a resource.
417
490
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: hexkit
3
- Version: 8.2.1
3
+ Version: 8.4.0
4
4
  Summary: A Toolkit for Building Microservices using the Hexagonal Architecture
5
5
  Author-email: "German Human Genome Phenome Archive (GHGA)" <contact@ghga.de>
6
6
  License: Apache 2.0
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes