hexkit 8.2.0__tar.gz → 8.3.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.0/src/hexkit.egg-info → hexkit-8.3.0}/PKG-INFO +1 -1
  2. {hexkit-8.2.0 → hexkit-8.3.0}/pyproject.toml +1 -1
  3. {hexkit-8.2.0 → hexkit-8.3.0}/src/hexkit/protocols/dao.py +62 -4
  4. {hexkit-8.2.0 → hexkit-8.3.0}/src/hexkit/providers/mongodb/provider/dao.py +68 -8
  5. {hexkit-8.2.0 → hexkit-8.3.0}/src/hexkit/providers/mongokafka/provider/daopub.py +75 -11
  6. {hexkit-8.2.0 → hexkit-8.3.0}/src/hexkit/providers/s3/provider/objstorage.py +1 -1
  7. {hexkit-8.2.0 → hexkit-8.3.0}/src/hexkit/providers/testing/dao.py +65 -12
  8. {hexkit-8.2.0 → hexkit-8.3.0/src/hexkit.egg-info}/PKG-INFO +1 -1
  9. {hexkit-8.2.0 → hexkit-8.3.0}/LICENSE +0 -0
  10. {hexkit-8.2.0 → hexkit-8.3.0}/MANIFEST.in +0 -0
  11. {hexkit-8.2.0 → hexkit-8.3.0}/README.md +0 -0
  12. {hexkit-8.2.0 → hexkit-8.3.0}/setup.cfg +0 -0
  13. {hexkit-8.2.0 → hexkit-8.3.0}/src/hexkit/__init__.py +0 -0
  14. {hexkit-8.2.0 → hexkit-8.3.0}/src/hexkit/__main__.py +0 -0
  15. {hexkit-8.2.0 → hexkit-8.3.0}/src/hexkit/base.py +0 -0
  16. {hexkit-8.2.0 → hexkit-8.3.0}/src/hexkit/config.py +0 -0
  17. {hexkit-8.2.0 → hexkit-8.3.0}/src/hexkit/correlation.py +0 -0
  18. {hexkit-8.2.0 → hexkit-8.3.0}/src/hexkit/custom_types.py +0 -0
  19. {hexkit-8.2.0 → hexkit-8.3.0}/src/hexkit/log.py +0 -0
  20. {hexkit-8.2.0 → hexkit-8.3.0}/src/hexkit/opentelemetry.py +0 -0
  21. {hexkit-8.2.0 → hexkit-8.3.0}/src/hexkit/protocols/__init__.py +0 -0
  22. {hexkit-8.2.0 → hexkit-8.3.0}/src/hexkit/protocols/daopub.py +0 -0
  23. {hexkit-8.2.0 → hexkit-8.3.0}/src/hexkit/protocols/daosub.py +0 -0
  24. {hexkit-8.2.0 → hexkit-8.3.0}/src/hexkit/protocols/eventpub.py +0 -0
  25. {hexkit-8.2.0 → hexkit-8.3.0}/src/hexkit/protocols/eventsub.py +0 -0
  26. {hexkit-8.2.0 → hexkit-8.3.0}/src/hexkit/protocols/kvstore.py +0 -0
  27. {hexkit-8.2.0 → hexkit-8.3.0}/src/hexkit/protocols/objstorage.py +0 -0
  28. {hexkit-8.2.0 → hexkit-8.3.0}/src/hexkit/providers/__init__.py +0 -0
  29. {hexkit-8.2.0 → hexkit-8.3.0}/src/hexkit/providers/akafka/__init__.py +0 -0
  30. {hexkit-8.2.0 → hexkit-8.3.0}/src/hexkit/providers/akafka/config.py +0 -0
  31. {hexkit-8.2.0 → hexkit-8.3.0}/src/hexkit/providers/akafka/provider/__init__.py +0 -0
  32. {hexkit-8.2.0 → hexkit-8.3.0}/src/hexkit/providers/akafka/provider/daosub.py +0 -0
  33. {hexkit-8.2.0 → hexkit-8.3.0}/src/hexkit/providers/akafka/provider/eventpub.py +0 -0
  34. {hexkit-8.2.0 → hexkit-8.3.0}/src/hexkit/providers/akafka/provider/eventsub.py +0 -0
  35. {hexkit-8.2.0 → hexkit-8.3.0}/src/hexkit/providers/akafka/provider/utils.py +0 -0
  36. {hexkit-8.2.0 → hexkit-8.3.0}/src/hexkit/providers/akafka/testcontainer.py +0 -0
  37. {hexkit-8.2.0 → hexkit-8.3.0}/src/hexkit/providers/akafka/testutils.py +0 -0
  38. {hexkit-8.2.0 → hexkit-8.3.0}/src/hexkit/providers/mongodb/__init__.py +0 -0
  39. {hexkit-8.2.0 → hexkit-8.3.0}/src/hexkit/providers/mongodb/config.py +0 -0
  40. {hexkit-8.2.0 → hexkit-8.3.0}/src/hexkit/providers/mongodb/migrations/__init__.py +0 -0
  41. {hexkit-8.2.0 → hexkit-8.3.0}/src/hexkit/providers/mongodb/migrations/_manager.py +0 -0
  42. {hexkit-8.2.0 → hexkit-8.3.0}/src/hexkit/providers/mongodb/migrations/_utils.py +0 -0
  43. {hexkit-8.2.0 → hexkit-8.3.0}/src/hexkit/providers/mongodb/migrations/helpers.py +0 -0
  44. {hexkit-8.2.0 → hexkit-8.3.0}/src/hexkit/providers/mongodb/provider/__init__.py +0 -0
  45. {hexkit-8.2.0 → hexkit-8.3.0}/src/hexkit/providers/mongodb/provider/client.py +0 -0
  46. {hexkit-8.2.0 → hexkit-8.3.0}/src/hexkit/providers/mongodb/provider/kvstore.py +0 -0
  47. {hexkit-8.2.0 → hexkit-8.3.0}/src/hexkit/providers/mongodb/provider/utils.py +0 -0
  48. {hexkit-8.2.0 → hexkit-8.3.0}/src/hexkit/providers/mongodb/testutils.py +0 -0
  49. {hexkit-8.2.0 → hexkit-8.3.0}/src/hexkit/providers/mongokafka/__init__.py +0 -0
  50. {hexkit-8.2.0 → hexkit-8.3.0}/src/hexkit/providers/mongokafka/config.py +0 -0
  51. {hexkit-8.2.0 → hexkit-8.3.0}/src/hexkit/providers/mongokafka/provider/__init__.py +0 -0
  52. {hexkit-8.2.0 → hexkit-8.3.0}/src/hexkit/providers/mongokafka/provider/persistent_pub.py +0 -0
  53. {hexkit-8.2.0 → hexkit-8.3.0}/src/hexkit/providers/mongokafka/testutils.py +0 -0
  54. {hexkit-8.2.0 → hexkit-8.3.0}/src/hexkit/providers/redis/__init__.py +0 -0
  55. {hexkit-8.2.0 → hexkit-8.3.0}/src/hexkit/providers/redis/config.py +0 -0
  56. {hexkit-8.2.0 → hexkit-8.3.0}/src/hexkit/providers/redis/provider/__init__.py +0 -0
  57. {hexkit-8.2.0 → hexkit-8.3.0}/src/hexkit/providers/redis/provider/client.py +0 -0
  58. {hexkit-8.2.0 → hexkit-8.3.0}/src/hexkit/providers/redis/provider/kvstore.py +0 -0
  59. {hexkit-8.2.0 → hexkit-8.3.0}/src/hexkit/providers/redis/testutils.py +0 -0
  60. {hexkit-8.2.0 → hexkit-8.3.0}/src/hexkit/providers/s3/__init__.py +0 -0
  61. {hexkit-8.2.0 → hexkit-8.3.0}/src/hexkit/providers/s3/config.py +0 -0
  62. {hexkit-8.2.0 → hexkit-8.3.0}/src/hexkit/providers/s3/provider/__init__.py +0 -0
  63. {hexkit-8.2.0 → hexkit-8.3.0}/src/hexkit/providers/s3/provider/kvstore.py +0 -0
  64. {hexkit-8.2.0 → hexkit-8.3.0}/src/hexkit/providers/s3/test_files/__init__.py +0 -0
  65. {hexkit-8.2.0 → hexkit-8.3.0}/src/hexkit/providers/s3/test_files/test_file1.yaml +0 -0
  66. {hexkit-8.2.0 → hexkit-8.3.0}/src/hexkit/providers/s3/test_files/test_file2.yaml +0 -0
  67. {hexkit-8.2.0 → hexkit-8.3.0}/src/hexkit/providers/s3/test_files/test_file3.yaml +0 -0
  68. {hexkit-8.2.0 → hexkit-8.3.0}/src/hexkit/providers/s3/test_files/test_file4.yaml +0 -0
  69. {hexkit-8.2.0 → hexkit-8.3.0}/src/hexkit/providers/s3/testutils/__init__.py +0 -0
  70. {hexkit-8.2.0 → hexkit-8.3.0}/src/hexkit/providers/s3/testutils/_fixtures.py +0 -0
  71. {hexkit-8.2.0 → hexkit-8.3.0}/src/hexkit/providers/s3/testutils/_typical_workflow.py +0 -0
  72. {hexkit-8.2.0 → hexkit-8.3.0}/src/hexkit/providers/s3/testutils/_utils.py +0 -0
  73. {hexkit-8.2.0 → hexkit-8.3.0}/src/hexkit/providers/testing/__init__.py +0 -0
  74. {hexkit-8.2.0 → hexkit-8.3.0}/src/hexkit/providers/testing/eventpub.py +0 -0
  75. {hexkit-8.2.0 → hexkit-8.3.0}/src/hexkit/providers/testing/kvstore.py +0 -0
  76. {hexkit-8.2.0 → hexkit-8.3.0}/src/hexkit/providers/testing/objstorage.py +0 -0
  77. {hexkit-8.2.0 → hexkit-8.3.0}/src/hexkit/providers/vault/__init__.py +0 -0
  78. {hexkit-8.2.0 → hexkit-8.3.0}/src/hexkit/providers/vault/config.py +0 -0
  79. {hexkit-8.2.0 → hexkit-8.3.0}/src/hexkit/providers/vault/provider/__init__.py +0 -0
  80. {hexkit-8.2.0 → hexkit-8.3.0}/src/hexkit/providers/vault/provider/client.py +0 -0
  81. {hexkit-8.2.0 → hexkit-8.3.0}/src/hexkit/providers/vault/provider/kvstore.py +0 -0
  82. {hexkit-8.2.0 → hexkit-8.3.0}/src/hexkit/providers/vault/testutils.py +0 -0
  83. {hexkit-8.2.0 → hexkit-8.3.0}/src/hexkit/py.typed +0 -0
  84. {hexkit-8.2.0 → hexkit-8.3.0}/src/hexkit/utils.py +0 -0
  85. {hexkit-8.2.0 → hexkit-8.3.0}/src/hexkit.egg-info/SOURCES.txt +0 -0
  86. {hexkit-8.2.0 → hexkit-8.3.0}/src/hexkit.egg-info/dependency_links.txt +0 -0
  87. {hexkit-8.2.0 → hexkit-8.3.0}/src/hexkit.egg-info/requires.txt +0 -0
  88. {hexkit-8.2.0 → hexkit-8.3.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.0
3
+ Version: 8.3.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.0"
26
+ version = "8.3.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,39 @@ 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
+
140
174
  class Dao(typing.Protocol[Dto]):
141
175
  """A duck type with methods common to all DAOs."""
142
176
 
@@ -223,7 +257,14 @@ class Dao(typing.Protocol[Dto]):
223
257
  """
224
258
  ...
225
259
 
226
- def find_all(self, *, mapping: Mapping[str, Any]) -> AsyncIterator[Dto]:
260
+ def find_all(
261
+ self,
262
+ *,
263
+ mapping: Mapping[str, Any],
264
+ skip: int | None = None,
265
+ limit: int | None = None,
266
+ sort: list[str] | None = None,
267
+ ) -> "FindResult[Dto]":
227
268
  """Find all resources that match the specified mapping.
228
269
 
229
270
  The values in the mapping are used to filter the resources, these are
@@ -235,10 +276,27 @@ class Dao(typing.Protocol[Dto]):
235
276
  mapping:
236
277
  A mapping where the keys correspond to the names of resource fields
237
278
  and the values correspond to the actual values of the resource fields.
279
+ skip:
280
+ Number of matching resources to skip before yielding results.
281
+ Defaults to None (no skipping).
282
+ limit:
283
+ Maximum number of resources to yield. Defaults to None (no limit).
284
+ Specifying 0 is interpreted literally and returns no results.
285
+ sort:
286
+ A list of field names defining the sort order, where field names
287
+ prefixed with "-" indicate *descending* order. For example, if sort is
288
+ specified as `["name", "-age"]`, the results would be sorted first by
289
+ "name" in ascending order, then by "age" in descending order. When
290
+ paginating with skip/limit, providing a sort order is strongly
291
+ recommended to ensure consistent, deterministic results. Defaults to
292
+ None (no sort).
238
293
 
239
294
  Returns:
240
- An AsyncIterator of hits. All hits are in the form of the respective DTO
241
- model.
295
+ A FindResult that is async-iterable and also provides total_count().
296
+
297
+ Raises:
298
+ InvalidMappingError: If `mapping` doesn't pass validation.
299
+ ValueError: if `skip` or `limit` are less than 0.
242
300
  """
243
301
  ...
244
302
 
@@ -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,79 @@ 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
424
+ mapping_without_deleted = {**mapping, "__metadata__.deleted": False}
394
425
 
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
- )
426
+ # Convert generic sort spec to MongoDB-specific sort spec
427
+ mongodb_sort: list[tuple[str, int]] = []
428
+ for spec in sort or []:
429
+ field = spec.removeprefix("-")
430
+ order = 1 if field == spec else -1
431
+ if field == self._id_field:
432
+ field = "_id"
433
+ mongodb_sort.append((field, order))
434
+
435
+ collection = self._collection
436
+
437
+ async def _total_count() -> int:
438
+ with translate_pymongo_errors():
439
+ return await collection.count_documents(filter=mapping_without_deleted)
440
+
441
+ if limit == 0:
442
+
443
+ async def _empty_iter() -> AsyncIterator[Dto]:
444
+ return
445
+ yield # turns this into an async generator
446
+
447
+ return FindResult(
448
+ results_iterator=_empty_iter(), get_total_count=_total_count
449
+ )
450
+
451
+ async def _iter() -> AsyncIterator[Dto]:
452
+ with translate_pymongo_errors():
453
+ cursor = collection.find(filter=mapping_without_deleted)
454
+ if sort:
455
+ cursor = cursor.sort(mongodb_sort)
456
+ cursor = cursor.skip(skip)
457
+ if limit:
458
+ cursor = cursor.limit(limit)
459
+ async for document in cursor:
460
+ yield document_to_dto(
461
+ document, id_field=self._id_field, dto_model=self._dto_model
462
+ )
463
+
464
+ return FindResult(results_iterator=_iter(), get_total_count=_total_count)
401
465
 
402
466
  async def insert(self, dto: Dto) -> None:
403
467
  """Create a new resource.
@@ -431,7 +431,7 @@ class S3ObjectStorage(ObjectStorageProtocol):
431
431
  # If max_parts specified, return once amount is reached
432
432
  if max_parts and len(parts) >= max_parts:
433
433
  return parts[: max_parts + 1]
434
- return parts
434
+ return sorted(parts, key=lambda p: p["PartNumber"])
435
435
  except botocore.exceptions.ClientError as error:
436
436
  raise self._translate_s3_client_errors(
437
437
  error,
@@ -25,6 +25,7 @@ from pydantic import BaseModel
25
25
 
26
26
  from hexkit.custom_types import ID
27
27
  from hexkit.protocols.dao import (
28
+ FindResult,
28
29
  MultipleHitsFoundError,
29
30
  NoHitsFoundError,
30
31
  ResourceAlreadyExistsError,
@@ -391,27 +392,79 @@ class BaseInMemDao(Generic[DTO]):
391
392
 
392
393
  raise MultipleHitsFoundError(mapping=mapping)
393
394
 
394
- async def find_all(self, *, mapping: Mapping[str, Any]) -> AsyncIterator[DTO]:
395
+ def _resource_matches(
396
+ self,
397
+ resource: Document,
398
+ mapping: Mapping[str, Any],
399
+ predicates: list["Predicate"],
400
+ ) -> bool:
401
+ """Return whether a resource matches the given mapping or predicates."""
402
+ if self._handle_mql:
403
+ return all(p.evaluate(resource=resource) for p in predicates)
404
+ for key, expected_value in mapping.items():
405
+ if _get_nested_value(resource, key) != expected_value:
406
+ return False
407
+ return True
408
+
409
+ def find_all(
410
+ self,
411
+ *,
412
+ mapping: Mapping[str, Any],
413
+ skip: int | None = None,
414
+ limit: int | None = None,
415
+ sort: list[str] | None = None,
416
+ ) -> "FindResult[DTO]":
395
417
  """Find all resources that match the specified mapping."""
418
+ skip = skip or 0
419
+ if skip < 0:
420
+ raise ValueError("skip must be >= 0")
421
+ if limit is not None and limit < 0:
422
+ raise ValueError("limit must be >= 0")
423
+
396
424
  if "" in mapping:
397
425
  raise KeyError("Query mappings can't contain empty-string keys")
398
426
 
399
427
  _mapping = replace_id_field_in_find_mapping(mapping, self._id_field)
400
428
  predicates = build_predicates(_mapping) if self._handle_mql else []
401
429
 
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:
430
+ matching = [
431
+ resource
432
+ for resource in self.resources.values()
433
+ if self._resource_matches(resource, mapping, predicates)
434
+ ]
435
+
436
+ # Interpret the sorting specification and sort our resources accordingly
437
+ for spec in reversed(sort or []):
438
+ desc = spec.startswith("-")
439
+ field = spec.removeprefix("-")
440
+ doc_field = "_id" if field == self._id_field else field
441
+ matching.sort(key=lambda doc: doc[doc_field], reverse=desc)
442
+
443
+ total = len(matching)
444
+
445
+ async def _total_count() -> int:
446
+ return total
447
+
448
+ if limit == 0:
449
+
450
+ async def _empty_iter() -> AsyncIterator[DTO]:
451
+ return
452
+ yield # makes this an async generator
453
+
454
+ return FindResult(
455
+ results_iterator=_empty_iter(), get_total_count=_total_count
456
+ )
457
+
458
+ # Manually apply pagination params
459
+ end = (skip + limit) if limit is not None else None
460
+ page = matching[skip:end]
461
+
462
+ async def _iter() -> AsyncIterator[DTO]:
463
+ for resource in page:
413
464
  yield self._deserialize(resource)
414
465
 
466
+ return FindResult(results_iterator=_iter(), get_total_count=_total_count)
467
+
415
468
  async def insert(self, dto: DTO) -> None:
416
469
  """Insert a resource.
417
470
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: hexkit
3
- Version: 8.2.0
3
+ Version: 8.3.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