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.
- {hexkit-8.2.1/src/hexkit.egg-info → hexkit-8.4.0}/PKG-INFO +1 -1
- {hexkit-8.2.1 → hexkit-8.4.0}/pyproject.toml +1 -1
- {hexkit-8.2.1 → hexkit-8.4.0}/src/hexkit/protocols/dao.py +70 -4
- {hexkit-8.2.1 → hexkit-8.4.0}/src/hexkit/providers/mongodb/provider/dao.py +68 -8
- {hexkit-8.2.1 → hexkit-8.4.0}/src/hexkit/providers/mongokafka/provider/daopub.py +87 -11
- {hexkit-8.2.1 → hexkit-8.4.0}/src/hexkit/providers/testing/dao.py +88 -15
- {hexkit-8.2.1 → hexkit-8.4.0/src/hexkit.egg-info}/PKG-INFO +1 -1
- {hexkit-8.2.1 → hexkit-8.4.0}/LICENSE +0 -0
- {hexkit-8.2.1 → hexkit-8.4.0}/MANIFEST.in +0 -0
- {hexkit-8.2.1 → hexkit-8.4.0}/README.md +0 -0
- {hexkit-8.2.1 → hexkit-8.4.0}/setup.cfg +0 -0
- {hexkit-8.2.1 → hexkit-8.4.0}/src/hexkit/__init__.py +0 -0
- {hexkit-8.2.1 → hexkit-8.4.0}/src/hexkit/__main__.py +0 -0
- {hexkit-8.2.1 → hexkit-8.4.0}/src/hexkit/base.py +0 -0
- {hexkit-8.2.1 → hexkit-8.4.0}/src/hexkit/config.py +0 -0
- {hexkit-8.2.1 → hexkit-8.4.0}/src/hexkit/correlation.py +0 -0
- {hexkit-8.2.1 → hexkit-8.4.0}/src/hexkit/custom_types.py +0 -0
- {hexkit-8.2.1 → hexkit-8.4.0}/src/hexkit/log.py +0 -0
- {hexkit-8.2.1 → hexkit-8.4.0}/src/hexkit/opentelemetry.py +0 -0
- {hexkit-8.2.1 → hexkit-8.4.0}/src/hexkit/protocols/__init__.py +0 -0
- {hexkit-8.2.1 → hexkit-8.4.0}/src/hexkit/protocols/daopub.py +0 -0
- {hexkit-8.2.1 → hexkit-8.4.0}/src/hexkit/protocols/daosub.py +0 -0
- {hexkit-8.2.1 → hexkit-8.4.0}/src/hexkit/protocols/eventpub.py +0 -0
- {hexkit-8.2.1 → hexkit-8.4.0}/src/hexkit/protocols/eventsub.py +0 -0
- {hexkit-8.2.1 → hexkit-8.4.0}/src/hexkit/protocols/kvstore.py +0 -0
- {hexkit-8.2.1 → hexkit-8.4.0}/src/hexkit/protocols/objstorage.py +0 -0
- {hexkit-8.2.1 → hexkit-8.4.0}/src/hexkit/providers/__init__.py +0 -0
- {hexkit-8.2.1 → hexkit-8.4.0}/src/hexkit/providers/akafka/__init__.py +0 -0
- {hexkit-8.2.1 → hexkit-8.4.0}/src/hexkit/providers/akafka/config.py +0 -0
- {hexkit-8.2.1 → hexkit-8.4.0}/src/hexkit/providers/akafka/provider/__init__.py +0 -0
- {hexkit-8.2.1 → hexkit-8.4.0}/src/hexkit/providers/akafka/provider/daosub.py +0 -0
- {hexkit-8.2.1 → hexkit-8.4.0}/src/hexkit/providers/akafka/provider/eventpub.py +0 -0
- {hexkit-8.2.1 → hexkit-8.4.0}/src/hexkit/providers/akafka/provider/eventsub.py +0 -0
- {hexkit-8.2.1 → hexkit-8.4.0}/src/hexkit/providers/akafka/provider/utils.py +0 -0
- {hexkit-8.2.1 → hexkit-8.4.0}/src/hexkit/providers/akafka/testcontainer.py +0 -0
- {hexkit-8.2.1 → hexkit-8.4.0}/src/hexkit/providers/akafka/testutils.py +0 -0
- {hexkit-8.2.1 → hexkit-8.4.0}/src/hexkit/providers/mongodb/__init__.py +0 -0
- {hexkit-8.2.1 → hexkit-8.4.0}/src/hexkit/providers/mongodb/config.py +0 -0
- {hexkit-8.2.1 → hexkit-8.4.0}/src/hexkit/providers/mongodb/migrations/__init__.py +0 -0
- {hexkit-8.2.1 → hexkit-8.4.0}/src/hexkit/providers/mongodb/migrations/_manager.py +0 -0
- {hexkit-8.2.1 → hexkit-8.4.0}/src/hexkit/providers/mongodb/migrations/_utils.py +0 -0
- {hexkit-8.2.1 → hexkit-8.4.0}/src/hexkit/providers/mongodb/migrations/helpers.py +0 -0
- {hexkit-8.2.1 → hexkit-8.4.0}/src/hexkit/providers/mongodb/provider/__init__.py +0 -0
- {hexkit-8.2.1 → hexkit-8.4.0}/src/hexkit/providers/mongodb/provider/client.py +0 -0
- {hexkit-8.2.1 → hexkit-8.4.0}/src/hexkit/providers/mongodb/provider/kvstore.py +0 -0
- {hexkit-8.2.1 → hexkit-8.4.0}/src/hexkit/providers/mongodb/provider/utils.py +0 -0
- {hexkit-8.2.1 → hexkit-8.4.0}/src/hexkit/providers/mongodb/testutils.py +0 -0
- {hexkit-8.2.1 → hexkit-8.4.0}/src/hexkit/providers/mongokafka/__init__.py +0 -0
- {hexkit-8.2.1 → hexkit-8.4.0}/src/hexkit/providers/mongokafka/config.py +0 -0
- {hexkit-8.2.1 → hexkit-8.4.0}/src/hexkit/providers/mongokafka/provider/__init__.py +0 -0
- {hexkit-8.2.1 → hexkit-8.4.0}/src/hexkit/providers/mongokafka/provider/persistent_pub.py +0 -0
- {hexkit-8.2.1 → hexkit-8.4.0}/src/hexkit/providers/mongokafka/testutils.py +0 -0
- {hexkit-8.2.1 → hexkit-8.4.0}/src/hexkit/providers/redis/__init__.py +0 -0
- {hexkit-8.2.1 → hexkit-8.4.0}/src/hexkit/providers/redis/config.py +0 -0
- {hexkit-8.2.1 → hexkit-8.4.0}/src/hexkit/providers/redis/provider/__init__.py +0 -0
- {hexkit-8.2.1 → hexkit-8.4.0}/src/hexkit/providers/redis/provider/client.py +0 -0
- {hexkit-8.2.1 → hexkit-8.4.0}/src/hexkit/providers/redis/provider/kvstore.py +0 -0
- {hexkit-8.2.1 → hexkit-8.4.0}/src/hexkit/providers/redis/testutils.py +0 -0
- {hexkit-8.2.1 → hexkit-8.4.0}/src/hexkit/providers/s3/__init__.py +0 -0
- {hexkit-8.2.1 → hexkit-8.4.0}/src/hexkit/providers/s3/config.py +0 -0
- {hexkit-8.2.1 → hexkit-8.4.0}/src/hexkit/providers/s3/provider/__init__.py +0 -0
- {hexkit-8.2.1 → hexkit-8.4.0}/src/hexkit/providers/s3/provider/kvstore.py +0 -0
- {hexkit-8.2.1 → hexkit-8.4.0}/src/hexkit/providers/s3/provider/objstorage.py +0 -0
- {hexkit-8.2.1 → hexkit-8.4.0}/src/hexkit/providers/s3/test_files/__init__.py +0 -0
- {hexkit-8.2.1 → hexkit-8.4.0}/src/hexkit/providers/s3/test_files/test_file1.yaml +0 -0
- {hexkit-8.2.1 → hexkit-8.4.0}/src/hexkit/providers/s3/test_files/test_file2.yaml +0 -0
- {hexkit-8.2.1 → hexkit-8.4.0}/src/hexkit/providers/s3/test_files/test_file3.yaml +0 -0
- {hexkit-8.2.1 → hexkit-8.4.0}/src/hexkit/providers/s3/test_files/test_file4.yaml +0 -0
- {hexkit-8.2.1 → hexkit-8.4.0}/src/hexkit/providers/s3/testutils/__init__.py +0 -0
- {hexkit-8.2.1 → hexkit-8.4.0}/src/hexkit/providers/s3/testutils/_fixtures.py +0 -0
- {hexkit-8.2.1 → hexkit-8.4.0}/src/hexkit/providers/s3/testutils/_typical_workflow.py +0 -0
- {hexkit-8.2.1 → hexkit-8.4.0}/src/hexkit/providers/s3/testutils/_utils.py +0 -0
- {hexkit-8.2.1 → hexkit-8.4.0}/src/hexkit/providers/testing/__init__.py +0 -0
- {hexkit-8.2.1 → hexkit-8.4.0}/src/hexkit/providers/testing/eventpub.py +0 -0
- {hexkit-8.2.1 → hexkit-8.4.0}/src/hexkit/providers/testing/kvstore.py +0 -0
- {hexkit-8.2.1 → hexkit-8.4.0}/src/hexkit/providers/testing/objstorage.py +0 -0
- {hexkit-8.2.1 → hexkit-8.4.0}/src/hexkit/providers/vault/__init__.py +0 -0
- {hexkit-8.2.1 → hexkit-8.4.0}/src/hexkit/providers/vault/config.py +0 -0
- {hexkit-8.2.1 → hexkit-8.4.0}/src/hexkit/providers/vault/provider/__init__.py +0 -0
- {hexkit-8.2.1 → hexkit-8.4.0}/src/hexkit/providers/vault/provider/client.py +0 -0
- {hexkit-8.2.1 → hexkit-8.4.0}/src/hexkit/providers/vault/provider/kvstore.py +0 -0
- {hexkit-8.2.1 → hexkit-8.4.0}/src/hexkit/providers/vault/testutils.py +0 -0
- {hexkit-8.2.1 → hexkit-8.4.0}/src/hexkit/py.typed +0 -0
- {hexkit-8.2.1 → hexkit-8.4.0}/src/hexkit/utils.py +0 -0
- {hexkit-8.2.1 → hexkit-8.4.0}/src/hexkit.egg-info/SOURCES.txt +0 -0
- {hexkit-8.2.1 → hexkit-8.4.0}/src/hexkit.egg-info/dependency_links.txt +0 -0
- {hexkit-8.2.1 → hexkit-8.4.0}/src/hexkit.egg-info/requires.txt +0 -0
- {hexkit-8.2.1 → hexkit-8.4.0}/src/hexkit.egg-info/top_level.txt +0 -0
|
@@ -23,7 +23,7 @@ classifiers = [
|
|
|
23
23
|
"Intended Audience :: Developers",
|
|
24
24
|
]
|
|
25
25
|
name = "hexkit"
|
|
26
|
-
version = "8.
|
|
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(
|
|
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
|
-
|
|
241
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
334
|
-
|
|
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
|
-
|
|
337
|
-
|
|
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
|
-
|
|
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
|
-
|
|
387
|
-
|
|
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
|
-
|
|
393
|
-
|
|
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
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
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
|
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|