persistence-kit 1.0.0__tar.gz → 3.0.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.
- {persistence_kit-1.0.0 → persistence_kit-3.0.0}/PKG-INFO +2 -1
- persistence_kit-3.0.0/persistence_kit/repository/dynamodb_repo/dynamodb_mapper.py +111 -0
- persistence_kit-3.0.0/persistence_kit/repository/dynamodb_repo/dynamodb_repo.py +267 -0
- persistence_kit-3.0.0/persistence_kit/repository/filter_ops.py +132 -0
- {persistence_kit-1.0.0 → persistence_kit-3.0.0}/persistence_kit/repository/memory_repo/memory_repo.py +2 -46
- {persistence_kit-1.0.0 → persistence_kit-3.0.0}/persistence_kit/repository/mongo_repo/mongo_repo.py +29 -0
- {persistence_kit-1.0.0 → persistence_kit-3.0.0}/persistence_kit/repository/sqlalchemy_repo/sqlalchemy_repo.py +28 -1
- {persistence_kit-1.0.0 → persistence_kit-3.0.0}/persistence_kit/repository_factory/factory/repository_factory.py +17 -0
- {persistence_kit-1.0.0 → persistence_kit-3.0.0}/persistence_kit/repository_factory/view/populating_repository.py +125 -11
- {persistence_kit-1.0.0 → persistence_kit-3.0.0}/persistence_kit/settings/constants.py +1 -0
- {persistence_kit-1.0.0 → persistence_kit-3.0.0}/persistence_kit/settings/repo_settings.py +3 -0
- {persistence_kit-1.0.0 → persistence_kit-3.0.0}/pyproject.toml +2 -1
- persistence_kit-1.0.0/persistence_kit/repository/filter_ops.py +0 -32
- {persistence_kit-1.0.0 → persistence_kit-3.0.0}/LICENSE +0 -0
- {persistence_kit-1.0.0 → persistence_kit-3.0.0}/README.md +0 -0
- {persistence_kit-1.0.0 → persistence_kit-3.0.0}/persistence_kit/__init__.py +0 -0
- {persistence_kit-1.0.0 → persistence_kit-3.0.0}/persistence_kit/api/__init__.py +0 -0
- {persistence_kit-1.0.0 → persistence_kit-3.0.0}/persistence_kit/api/common.py +0 -0
- {persistence_kit-1.0.0 → persistence_kit-3.0.0}/persistence_kit/api/error_handlers.py +0 -0
- {persistence_kit-1.0.0 → persistence_kit-3.0.0}/persistence_kit/api/exceptions.py +0 -0
- {persistence_kit-1.0.0 → persistence_kit-3.0.0}/persistence_kit/api/route_loader.py +0 -0
- {persistence_kit-1.0.0 → persistence_kit-3.0.0}/persistence_kit/bootstrap/__init__.py +0 -0
- {persistence_kit-1.0.0 → persistence_kit-3.0.0}/persistence_kit/bootstrap/configuration.py +0 -0
- {persistence_kit-1.0.0 → persistence_kit-3.0.0}/persistence_kit/bootstrap/seeders.py +0 -0
- {persistence_kit-1.0.0 → persistence_kit-3.0.0}/persistence_kit/bootstrap/startup.py +0 -0
- {persistence_kit-1.0.0 → persistence_kit-3.0.0}/persistence_kit/contracts/__init__.py +0 -0
- {persistence_kit-1.0.0 → persistence_kit-3.0.0}/persistence_kit/contracts/repository.py +0 -0
- {persistence_kit-1.0.0 → persistence_kit-3.0.0}/persistence_kit/contracts/view_repository.py +0 -0
- {persistence_kit-1.0.0 → persistence_kit-3.0.0}/persistence_kit/py.typed +0 -0
- {persistence_kit-1.0.0 → persistence_kit-3.0.0}/persistence_kit/repository/__init__.py +0 -0
- {persistence_kit-1.0.0 → persistence_kit-3.0.0}/persistence_kit/repository/memory_repo/__init__.py +0 -0
- {persistence_kit-1.0.0 → persistence_kit-3.0.0}/persistence_kit/repository/mongo_repo/__init__.py +0 -0
- {persistence_kit-1.0.0 → persistence_kit-3.0.0}/persistence_kit/repository/mongo_repo/mongo_mapper.py +0 -0
- {persistence_kit-1.0.0 → persistence_kit-3.0.0}/persistence_kit/repository/sqlalchemy_repo/__init__.py +0 -0
- {persistence_kit-1.0.0 → persistence_kit-3.0.0}/persistence_kit/repository/sqlalchemy_repo/schema_evolve.py +0 -0
- {persistence_kit-1.0.0 → persistence_kit-3.0.0}/persistence_kit/repository/sqlalchemy_repo/sqlalchemy_dataclass_mapper.py +0 -0
- {persistence_kit-1.0.0 → persistence_kit-3.0.0}/persistence_kit/repository/sqlalchemy_repo/sqlalchemy_engine.py +0 -0
- {persistence_kit-1.0.0 → persistence_kit-3.0.0}/persistence_kit/repository/sqlalchemy_repo/table_factory.py +0 -0
- {persistence_kit-1.0.0 → persistence_kit-3.0.0}/persistence_kit/repository_factory/__init__.py +0 -0
- {persistence_kit-1.0.0 → persistence_kit-3.0.0}/persistence_kit/repository_factory/factory/__init__.py +0 -0
- {persistence_kit-1.0.0 → persistence_kit-3.0.0}/persistence_kit/repository_factory/registry/__init__.py +0 -0
- {persistence_kit-1.0.0 → persistence_kit-3.0.0}/persistence_kit/repository_factory/registry/entity_registry.py +0 -0
- {persistence_kit-1.0.0 → persistence_kit-3.0.0}/persistence_kit/repository_factory/view/__init__.py +0 -0
- {persistence_kit-1.0.0 → persistence_kit-3.0.0}/persistence_kit/settings/__init__.py +0 -0
- {persistence_kit-1.0.0 → persistence_kit-3.0.0}/persistence_kit/settings/parsers.py +0 -0
- {persistence_kit-1.0.0 → persistence_kit-3.0.0}/persistence_kit/utils/__init__.py +0 -0
- {persistence_kit-1.0.0 → persistence_kit-3.0.0}/persistence_kit/utils/upsert.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: persistence-kit
|
|
3
|
-
Version:
|
|
3
|
+
Version: 3.0.0
|
|
4
4
|
Summary: Reusable persistence and repository toolkit
|
|
5
5
|
License: MIT
|
|
6
6
|
License-File: LICENSE
|
|
@@ -19,6 +19,7 @@ Classifier: Programming Language :: Python :: 3.14
|
|
|
19
19
|
Classifier: Topic :: Database
|
|
20
20
|
Classifier: Typing :: Typed
|
|
21
21
|
Requires-Dist: asyncpg (>=0.30.0,<0.31.0)
|
|
22
|
+
Requires-Dist: boto3 (>=1.35.0,<2.0.0)
|
|
22
23
|
Requires-Dist: fastapi (>=0.115.0,<0.116.0)
|
|
23
24
|
Requires-Dist: motor (>=3.7.1,<4.0.0)
|
|
24
25
|
Requires-Dist: pydantic-settings (>=2.3.0,<3.0.0)
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
from dataclasses import asdict, is_dataclass, fields
|
|
2
|
+
from typing import Any, Mapping, Type, TypeVar
|
|
3
|
+
from uuid import UUID
|
|
4
|
+
from datetime import datetime, date
|
|
5
|
+
from decimal import Decimal
|
|
6
|
+
from enum import Enum
|
|
7
|
+
|
|
8
|
+
T = TypeVar("T")
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def _serialize_value(value: Any) -> Any:
|
|
12
|
+
if value is None:
|
|
13
|
+
return None
|
|
14
|
+
if isinstance(value, UUID):
|
|
15
|
+
return str(value)
|
|
16
|
+
if isinstance(value, datetime):
|
|
17
|
+
return value.isoformat()
|
|
18
|
+
if isinstance(value, date):
|
|
19
|
+
return value.isoformat()
|
|
20
|
+
if isinstance(value, Enum):
|
|
21
|
+
return value.value
|
|
22
|
+
if isinstance(value, float):
|
|
23
|
+
return Decimal(str(value))
|
|
24
|
+
if isinstance(value, dict):
|
|
25
|
+
return {k: _serialize_value(v) for k, v in value.items()}
|
|
26
|
+
if isinstance(value, (list, tuple)):
|
|
27
|
+
return [_serialize_value(v) for v in value]
|
|
28
|
+
return value
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _deserialize_value(value: Any, target_type: type | None = None) -> Any:
|
|
32
|
+
if value is None:
|
|
33
|
+
return None
|
|
34
|
+
if isinstance(value, Decimal):
|
|
35
|
+
if value == int(value):
|
|
36
|
+
return int(value)
|
|
37
|
+
return float(value)
|
|
38
|
+
if isinstance(value, dict) and target_type is None:
|
|
39
|
+
return {k: _deserialize_value(v) for k, v in value.items()}
|
|
40
|
+
if isinstance(value, list):
|
|
41
|
+
return [_deserialize_value(v) for v in value]
|
|
42
|
+
return value
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class DynamoMapper:
|
|
46
|
+
def __init__(
|
|
47
|
+
self,
|
|
48
|
+
entity_type: Type[T],
|
|
49
|
+
table_name: str,
|
|
50
|
+
unique_fields: dict[str, str] | None = None,
|
|
51
|
+
) -> None:
|
|
52
|
+
if not is_dataclass(entity_type):
|
|
53
|
+
raise TypeError("Entity type must be a dataclass")
|
|
54
|
+
self._entity_type = entity_type
|
|
55
|
+
self._table_name = table_name
|
|
56
|
+
self._unique_fields = dict(unique_fields or {})
|
|
57
|
+
self._field_names = {f.name for f in fields(entity_type)}
|
|
58
|
+
|
|
59
|
+
def table_name(self) -> str:
|
|
60
|
+
return self._table_name
|
|
61
|
+
|
|
62
|
+
def id_of(self, entity: T) -> Any:
|
|
63
|
+
return getattr(entity, "id")
|
|
64
|
+
|
|
65
|
+
def to_item(self, entity: T) -> dict[str, Any]:
|
|
66
|
+
d = asdict(entity)
|
|
67
|
+
result: dict[str, Any] = {}
|
|
68
|
+
for k, v in d.items():
|
|
69
|
+
if k not in self._field_names:
|
|
70
|
+
continue
|
|
71
|
+
serialized = _serialize_value(v)
|
|
72
|
+
if serialized is not None:
|
|
73
|
+
result[k] = serialized
|
|
74
|
+
return result
|
|
75
|
+
|
|
76
|
+
def from_item(self, item: Mapping[str, Any]) -> T:
|
|
77
|
+
data: dict[str, Any] = {}
|
|
78
|
+
for f in fields(self._entity_type):
|
|
79
|
+
if f.name in item:
|
|
80
|
+
val = item[f.name]
|
|
81
|
+
if f.type in ("UUID", "uuid.UUID") or (
|
|
82
|
+
isinstance(f.type, str) and "UUID" in f.type
|
|
83
|
+
):
|
|
84
|
+
data[f.name] = UUID(val) if isinstance(val, str) else val
|
|
85
|
+
elif f.type in ("datetime", "datetime.datetime") or (
|
|
86
|
+
isinstance(f.type, str) and "datetime" in f.type.lower()
|
|
87
|
+
and "date" != f.type.lower()
|
|
88
|
+
):
|
|
89
|
+
data[f.name] = (
|
|
90
|
+
datetime.fromisoformat(val)
|
|
91
|
+
if isinstance(val, str)
|
|
92
|
+
else _deserialize_value(val)
|
|
93
|
+
)
|
|
94
|
+
elif f.type in ("date", "datetime.date"):
|
|
95
|
+
data[f.name] = (
|
|
96
|
+
date.fromisoformat(val)
|
|
97
|
+
if isinstance(val, str)
|
|
98
|
+
else _deserialize_value(val)
|
|
99
|
+
)
|
|
100
|
+
else:
|
|
101
|
+
data[f.name] = _deserialize_value(val)
|
|
102
|
+
return self._entity_type(**data)
|
|
103
|
+
|
|
104
|
+
def unique_fields(self) -> dict[str, str]:
|
|
105
|
+
return self._unique_fields
|
|
106
|
+
|
|
107
|
+
def has_attr(self, name: str) -> bool:
|
|
108
|
+
return name in self._field_names
|
|
109
|
+
|
|
110
|
+
def attr_to_storage(self, name: str) -> str:
|
|
111
|
+
return name
|
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Optional, Sequence, TypeVar, Generic, Hashable, Mapping, Any
|
|
4
|
+
try:
|
|
5
|
+
from typing import override
|
|
6
|
+
except ImportError:
|
|
7
|
+
from typing_extensions import override
|
|
8
|
+
|
|
9
|
+
from decimal import Decimal
|
|
10
|
+
|
|
11
|
+
import boto3
|
|
12
|
+
from boto3.dynamodb.conditions import Key, Attr, ConditionBase
|
|
13
|
+
from persistence_kit.contracts.repository import Repository
|
|
14
|
+
from persistence_kit.repository.dynamodb_repo.dynamodb_mapper import DynamoMapper, _serialize_value
|
|
15
|
+
from persistence_kit.repository.filter_ops import (
|
|
16
|
+
is_logical_key,
|
|
17
|
+
iter_criteria_groups,
|
|
18
|
+
is_multi_value,
|
|
19
|
+
is_range_dict,
|
|
20
|
+
iter_range_ops,
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
T = TypeVar("T")
|
|
24
|
+
TId = TypeVar("TId", bound=Hashable)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _range_to_condition(attr: Attr, value: Mapping[str, Any]) -> ConditionBase | None:
|
|
28
|
+
conditions: list[ConditionBase] = []
|
|
29
|
+
for op, v in iter_range_ops(value):
|
|
30
|
+
v = _serialize_value(v)
|
|
31
|
+
if op == "between":
|
|
32
|
+
conditions.append(attr.between(_serialize_value(v[0]), _serialize_value(v[1])))
|
|
33
|
+
elif op == "gte":
|
|
34
|
+
conditions.append(attr.gte(v))
|
|
35
|
+
elif op == "gt":
|
|
36
|
+
conditions.append(attr.gt(v))
|
|
37
|
+
elif op == "lte":
|
|
38
|
+
conditions.append(attr.lte(v))
|
|
39
|
+
elif op == "lt":
|
|
40
|
+
conditions.append(attr.lt(v))
|
|
41
|
+
elif op == "in":
|
|
42
|
+
conditions.append(attr.is_in([_serialize_value(i) for i in v]))
|
|
43
|
+
elif op == "eq":
|
|
44
|
+
conditions.append(attr.eq(v))
|
|
45
|
+
elif op == "ne":
|
|
46
|
+
conditions.append(attr.ne(v))
|
|
47
|
+
elif op == "contains":
|
|
48
|
+
conditions.append(attr.contains(str(v)))
|
|
49
|
+
elif op == "icontains":
|
|
50
|
+
conditions.append(attr.contains(str(v).lower()) | attr.contains(str(v).upper()) | attr.contains(str(v)))
|
|
51
|
+
elif op == "startswith" or op == "istartswith":
|
|
52
|
+
conditions.append(attr.begins_with(str(v)))
|
|
53
|
+
elif op == "endswith" or op == "iendswith":
|
|
54
|
+
conditions.append(attr.contains(str(v)))
|
|
55
|
+
if not conditions:
|
|
56
|
+
return None
|
|
57
|
+
result = conditions[0]
|
|
58
|
+
for c in conditions[1:]:
|
|
59
|
+
result = result & c
|
|
60
|
+
return result
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _build_filter(
|
|
64
|
+
mapper: DynamoMapper,
|
|
65
|
+
criteria: Mapping[str, Hashable | list[Hashable] | Mapping[str, Any]],
|
|
66
|
+
) -> ConditionBase | None:
|
|
67
|
+
if not criteria:
|
|
68
|
+
return None
|
|
69
|
+
for v in criteria.values():
|
|
70
|
+
if is_multi_value(v) and not v:
|
|
71
|
+
return False
|
|
72
|
+
if is_range_dict(v) and v.get("in") == []:
|
|
73
|
+
return False
|
|
74
|
+
|
|
75
|
+
conditions: list[ConditionBase] = []
|
|
76
|
+
for k, v in criteria.items():
|
|
77
|
+
if is_logical_key(k):
|
|
78
|
+
group_conditions: list[ConditionBase] = []
|
|
79
|
+
for group in iter_criteria_groups(v):
|
|
80
|
+
rendered = _build_filter(mapper, group)
|
|
81
|
+
if rendered is False:
|
|
82
|
+
continue
|
|
83
|
+
if rendered is not None:
|
|
84
|
+
group_conditions.append(rendered)
|
|
85
|
+
if not group_conditions:
|
|
86
|
+
return False
|
|
87
|
+
combined = group_conditions[0]
|
|
88
|
+
for gc in group_conditions[1:]:
|
|
89
|
+
if k == "or":
|
|
90
|
+
combined = combined | gc
|
|
91
|
+
else:
|
|
92
|
+
combined = combined & gc
|
|
93
|
+
conditions.append(combined)
|
|
94
|
+
continue
|
|
95
|
+
|
|
96
|
+
field = mapper.attr_to_storage(k)
|
|
97
|
+
attr = Attr(field)
|
|
98
|
+
if v is None:
|
|
99
|
+
conditions.append(attr.not_exists() | attr.eq(None))
|
|
100
|
+
elif is_multi_value(v):
|
|
101
|
+
conditions.append(attr.is_in([_serialize_value(i) for i in v]))
|
|
102
|
+
elif is_range_dict(v):
|
|
103
|
+
range_cond = _range_to_condition(attr, v)
|
|
104
|
+
if range_cond is None:
|
|
105
|
+
return False
|
|
106
|
+
conditions.append(range_cond)
|
|
107
|
+
else:
|
|
108
|
+
conditions.append(attr.eq(_serialize_value(v)))
|
|
109
|
+
|
|
110
|
+
if not conditions:
|
|
111
|
+
return None
|
|
112
|
+
result = conditions[0]
|
|
113
|
+
for c in conditions[1:]:
|
|
114
|
+
result = result & c
|
|
115
|
+
return result
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
class DynamoRepository(Repository[T, TId], Generic[T, TId]):
|
|
119
|
+
def __init__(
|
|
120
|
+
self,
|
|
121
|
+
table_name: str,
|
|
122
|
+
mapper: DynamoMapper,
|
|
123
|
+
region: str = "us-east-1",
|
|
124
|
+
) -> None:
|
|
125
|
+
self._mapper = mapper
|
|
126
|
+
self._dynamodb = boto3.resource("dynamodb", region_name=region)
|
|
127
|
+
self._table = self._dynamodb.Table(table_name)
|
|
128
|
+
|
|
129
|
+
@override
|
|
130
|
+
async def add(self, entity: T) -> None:
|
|
131
|
+
item = self._mapper.to_item(entity)
|
|
132
|
+
self._table.put_item(
|
|
133
|
+
Item=item,
|
|
134
|
+
ConditionExpression=Attr("id").not_exists(),
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
@override
|
|
138
|
+
async def get(self, entity_id: TId) -> Optional[T]:
|
|
139
|
+
response = self._table.get_item(Key={"id": _serialize_value(entity_id)})
|
|
140
|
+
item = response.get("Item")
|
|
141
|
+
return self._mapper.from_item(item) if item else None
|
|
142
|
+
|
|
143
|
+
@override
|
|
144
|
+
async def list(
|
|
145
|
+
self,
|
|
146
|
+
*,
|
|
147
|
+
offset: int = 0,
|
|
148
|
+
limit: int = 50,
|
|
149
|
+
sort_by: str | None = None,
|
|
150
|
+
sort_desc: bool = False,
|
|
151
|
+
) -> Sequence[T]:
|
|
152
|
+
items = self._scan_all()
|
|
153
|
+
entities = [self._mapper.from_item(i) for i in items]
|
|
154
|
+
if sort_by is not None:
|
|
155
|
+
if not self._mapper.has_attr(sort_by):
|
|
156
|
+
raise ValueError(f"Invalid sort attribute: {sort_by}")
|
|
157
|
+
entities.sort(key=lambda e: getattr(e, sort_by, None) or "", reverse=sort_desc)
|
|
158
|
+
return entities[offset : offset + limit]
|
|
159
|
+
|
|
160
|
+
@override
|
|
161
|
+
async def update(self, entity: T) -> None:
|
|
162
|
+
item = self._mapper.to_item(entity)
|
|
163
|
+
self._table.put_item(Item=item)
|
|
164
|
+
|
|
165
|
+
@override
|
|
166
|
+
async def delete(self, entity_id: TId) -> None:
|
|
167
|
+
self._table.delete_item(Key={"id": _serialize_value(entity_id)})
|
|
168
|
+
|
|
169
|
+
@override
|
|
170
|
+
async def get_by_index(self, index: str, value: Hashable) -> Optional[T]:
|
|
171
|
+
field = self._mapper.unique_fields().get(index)
|
|
172
|
+
if not field:
|
|
173
|
+
return None
|
|
174
|
+
response = self._table.scan(
|
|
175
|
+
FilterExpression=Attr(field).eq(_serialize_value(value)),
|
|
176
|
+
Limit=1,
|
|
177
|
+
)
|
|
178
|
+
items = response.get("Items", [])
|
|
179
|
+
return self._mapper.from_item(items[0]) if items else None
|
|
180
|
+
|
|
181
|
+
@override
|
|
182
|
+
async def count(self) -> int:
|
|
183
|
+
return self._table.item_count
|
|
184
|
+
|
|
185
|
+
@override
|
|
186
|
+
async def count_by_fields(
|
|
187
|
+
self,
|
|
188
|
+
criteria: Mapping[str, Hashable | list[Hashable] | Mapping[str, Any]],
|
|
189
|
+
) -> int:
|
|
190
|
+
if not criteria:
|
|
191
|
+
return await self.count()
|
|
192
|
+
filter_expr = _build_filter(self._mapper, criteria)
|
|
193
|
+
if filter_expr is False:
|
|
194
|
+
return 0
|
|
195
|
+
items = self._scan_with_filter(filter_expr)
|
|
196
|
+
return len(items)
|
|
197
|
+
|
|
198
|
+
@override
|
|
199
|
+
async def list_by_fields(
|
|
200
|
+
self,
|
|
201
|
+
criteria: Mapping[str, Hashable | list[Hashable] | Mapping[str, Any]],
|
|
202
|
+
*,
|
|
203
|
+
offset: int = 0,
|
|
204
|
+
limit: Optional[int] = 50,
|
|
205
|
+
sort_by: str | None = None,
|
|
206
|
+
sort_desc: bool = False,
|
|
207
|
+
) -> Sequence[T]:
|
|
208
|
+
if not criteria:
|
|
209
|
+
return []
|
|
210
|
+
filter_expr = _build_filter(self._mapper, criteria)
|
|
211
|
+
if filter_expr is False:
|
|
212
|
+
return []
|
|
213
|
+
items = self._scan_with_filter(filter_expr)
|
|
214
|
+
entities = [self._mapper.from_item(i) for i in items]
|
|
215
|
+
if sort_by is not None:
|
|
216
|
+
if not self._mapper.has_attr(sort_by):
|
|
217
|
+
raise ValueError(f"Invalid sort attribute: {sort_by}")
|
|
218
|
+
entities.sort(key=lambda e: getattr(e, sort_by, None) or "", reverse=sort_desc)
|
|
219
|
+
end = offset + limit if limit is not None else None
|
|
220
|
+
return entities[offset:end]
|
|
221
|
+
|
|
222
|
+
@override
|
|
223
|
+
async def distinct_values(
|
|
224
|
+
self,
|
|
225
|
+
field: str,
|
|
226
|
+
criteria: Mapping[str, Hashable | list[Hashable] | Mapping[str, Any]] | None = None,
|
|
227
|
+
) -> Sequence[Any]:
|
|
228
|
+
if not self._mapper.has_attr(field):
|
|
229
|
+
raise ValueError(f"Invalid distinct attribute: {field}")
|
|
230
|
+
storage_field = self._mapper.attr_to_storage(field)
|
|
231
|
+
if criteria:
|
|
232
|
+
filter_expr = _build_filter(self._mapper, criteria)
|
|
233
|
+
if filter_expr is False:
|
|
234
|
+
return []
|
|
235
|
+
items = self._scan_with_filter(filter_expr)
|
|
236
|
+
else:
|
|
237
|
+
items = self._scan_all()
|
|
238
|
+
seen: set = set()
|
|
239
|
+
result: list[Any] = []
|
|
240
|
+
for item in items:
|
|
241
|
+
val = item.get(storage_field)
|
|
242
|
+
if val is not None and val not in seen:
|
|
243
|
+
seen.add(val)
|
|
244
|
+
result.append(val)
|
|
245
|
+
return result
|
|
246
|
+
|
|
247
|
+
def _scan_all(self) -> list[dict]:
|
|
248
|
+
items: list[dict] = []
|
|
249
|
+
response = self._table.scan()
|
|
250
|
+
items.extend(response.get("Items", []))
|
|
251
|
+
while "LastEvaluatedKey" in response:
|
|
252
|
+
response = self._table.scan(ExclusiveStartKey=response["LastEvaluatedKey"])
|
|
253
|
+
items.extend(response.get("Items", []))
|
|
254
|
+
return items
|
|
255
|
+
|
|
256
|
+
def _scan_with_filter(self, filter_expr: ConditionBase | None) -> list[dict]:
|
|
257
|
+
items: list[dict] = []
|
|
258
|
+
kwargs: dict[str, Any] = {}
|
|
259
|
+
if filter_expr is not None:
|
|
260
|
+
kwargs["FilterExpression"] = filter_expr
|
|
261
|
+
response = self._table.scan(**kwargs)
|
|
262
|
+
items.extend(response.get("Items", []))
|
|
263
|
+
while "LastEvaluatedKey" in response:
|
|
264
|
+
kwargs["ExclusiveStartKey"] = response["LastEvaluatedKey"]
|
|
265
|
+
response = self._table.scan(**kwargs)
|
|
266
|
+
items.extend(response.get("Items", []))
|
|
267
|
+
return items
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any, Callable, Iterable, Mapping
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
_SIMPLE_OPS = {"gte", "gt", "lte", "lt", "eq", "ne"}
|
|
7
|
+
_STRING_OPS = {"contains", "icontains", "startswith", "istartswith", "endswith", "iendswith"}
|
|
8
|
+
_LOGICAL_KEYS = {"or", "and"}
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def is_multi_value(value: Any) -> bool:
|
|
12
|
+
return isinstance(value, list)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def is_range_dict(value: Any) -> bool:
|
|
16
|
+
return isinstance(value, dict)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def is_logical_key(value: Any) -> bool:
|
|
20
|
+
return value in _LOGICAL_KEYS
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def iter_criteria_groups(value: Any) -> Iterable[Mapping[str, Any]]:
|
|
24
|
+
if not isinstance(value, list) or not value:
|
|
25
|
+
raise ValueError("Logical operators expect a non-empty list of criteria")
|
|
26
|
+
for item in value:
|
|
27
|
+
if not isinstance(item, Mapping):
|
|
28
|
+
raise ValueError("Logical operators expect criteria mappings")
|
|
29
|
+
return value
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def iter_range_ops(value: Mapping[str, Any]) -> Iterable[tuple[str, Any]]:
|
|
33
|
+
if not value:
|
|
34
|
+
return []
|
|
35
|
+
items = list(value.items())
|
|
36
|
+
for op, v in items:
|
|
37
|
+
if op == "between":
|
|
38
|
+
if not isinstance(v, list) or len(v) != 2:
|
|
39
|
+
raise ValueError("between expects a list with exactly two values")
|
|
40
|
+
elif op == "in":
|
|
41
|
+
if not isinstance(v, list):
|
|
42
|
+
raise ValueError("in expects a list")
|
|
43
|
+
elif op in _SIMPLE_OPS:
|
|
44
|
+
continue
|
|
45
|
+
elif op in _STRING_OPS:
|
|
46
|
+
continue
|
|
47
|
+
else:
|
|
48
|
+
raise ValueError(f"Unsupported operator: {op}")
|
|
49
|
+
return items
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _matches_string_op(current: Any, op: str, expected: Any) -> bool:
|
|
53
|
+
if current is None:
|
|
54
|
+
return False
|
|
55
|
+
current_text = str(current)
|
|
56
|
+
expected_text = str(expected)
|
|
57
|
+
if op == "contains":
|
|
58
|
+
return expected_text in current_text
|
|
59
|
+
if op == "icontains":
|
|
60
|
+
return expected_text.lower() in current_text.lower()
|
|
61
|
+
if op == "startswith":
|
|
62
|
+
return current_text.startswith(expected_text)
|
|
63
|
+
if op == "istartswith":
|
|
64
|
+
return current_text.lower().startswith(expected_text.lower())
|
|
65
|
+
if op == "endswith":
|
|
66
|
+
return current_text.endswith(expected_text)
|
|
67
|
+
if op == "iendswith":
|
|
68
|
+
return current_text.lower().endswith(expected_text.lower())
|
|
69
|
+
raise ValueError(f"Unsupported operator: {op}")
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def match_value(current: Any, expected: Any) -> bool:
|
|
73
|
+
if expected is None:
|
|
74
|
+
return current is None
|
|
75
|
+
if is_multi_value(expected):
|
|
76
|
+
return current in expected
|
|
77
|
+
if is_range_dict(expected):
|
|
78
|
+
ops = list(iter_range_ops(expected))
|
|
79
|
+
if not ops:
|
|
80
|
+
return False
|
|
81
|
+
for op, value in ops:
|
|
82
|
+
if op == "between":
|
|
83
|
+
lo, hi = value
|
|
84
|
+
if current is None or current < lo or current > hi:
|
|
85
|
+
return False
|
|
86
|
+
elif op == "gte":
|
|
87
|
+
if current is None or current < value:
|
|
88
|
+
return False
|
|
89
|
+
elif op == "gt":
|
|
90
|
+
if current is None or current <= value:
|
|
91
|
+
return False
|
|
92
|
+
elif op == "lte":
|
|
93
|
+
if current is None or current > value:
|
|
94
|
+
return False
|
|
95
|
+
elif op == "lt":
|
|
96
|
+
if current is None or current >= value:
|
|
97
|
+
return False
|
|
98
|
+
elif op == "in":
|
|
99
|
+
if current not in value:
|
|
100
|
+
return False
|
|
101
|
+
elif op == "eq":
|
|
102
|
+
if current != value:
|
|
103
|
+
return False
|
|
104
|
+
elif op == "ne":
|
|
105
|
+
if current == value:
|
|
106
|
+
return False
|
|
107
|
+
else:
|
|
108
|
+
if not _matches_string_op(current, op, value):
|
|
109
|
+
return False
|
|
110
|
+
return True
|
|
111
|
+
return current == expected
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def match_criteria(
|
|
115
|
+
criteria: Mapping[str, Any],
|
|
116
|
+
resolve_value: Callable[[str], Any],
|
|
117
|
+
) -> bool:
|
|
118
|
+
if not criteria:
|
|
119
|
+
return False
|
|
120
|
+
for field, expected in criteria.items():
|
|
121
|
+
if is_logical_key(field):
|
|
122
|
+
groups = list(iter_criteria_groups(expected))
|
|
123
|
+
if field == "or":
|
|
124
|
+
if not any(match_criteria(group, resolve_value) for group in groups):
|
|
125
|
+
return False
|
|
126
|
+
else:
|
|
127
|
+
if not all(match_criteria(group, resolve_value) for group in groups):
|
|
128
|
+
return False
|
|
129
|
+
continue
|
|
130
|
+
if not match_value(resolve_value(field), expected):
|
|
131
|
+
return False
|
|
132
|
+
return True
|
|
@@ -10,7 +10,7 @@ from persistence_kit.contracts.repository import Repository
|
|
|
10
10
|
from persistence_kit.repository.filter_ops import (
|
|
11
11
|
is_multi_value,
|
|
12
12
|
is_range_dict,
|
|
13
|
-
|
|
13
|
+
match_criteria,
|
|
14
14
|
)
|
|
15
15
|
|
|
16
16
|
T = TypeVar("T")
|
|
@@ -21,44 +21,6 @@ def _get_field_value(entity: Any, field: str) -> Any:
|
|
|
21
21
|
return entity.get(field)
|
|
22
22
|
return getattr(entity, field, None)
|
|
23
23
|
|
|
24
|
-
def _match_value(val: Any, cond: Any) -> bool:
|
|
25
|
-
if cond is None:
|
|
26
|
-
return val is None
|
|
27
|
-
if is_multi_value(cond):
|
|
28
|
-
return val in cond
|
|
29
|
-
if is_range_dict(cond):
|
|
30
|
-
ops = list(iter_range_ops(cond))
|
|
31
|
-
if not ops:
|
|
32
|
-
return False
|
|
33
|
-
for op, v in ops:
|
|
34
|
-
if op == "between":
|
|
35
|
-
lo, hi = v
|
|
36
|
-
if val is None or val < lo or val > hi:
|
|
37
|
-
return False
|
|
38
|
-
elif op == "gte":
|
|
39
|
-
if val is None or val < v:
|
|
40
|
-
return False
|
|
41
|
-
elif op == "gt":
|
|
42
|
-
if val is None or val <= v:
|
|
43
|
-
return False
|
|
44
|
-
elif op == "lte":
|
|
45
|
-
if val is None or val > v:
|
|
46
|
-
return False
|
|
47
|
-
elif op == "lt":
|
|
48
|
-
if val is None or val >= v:
|
|
49
|
-
return False
|
|
50
|
-
elif op == "in":
|
|
51
|
-
if val not in v:
|
|
52
|
-
return False
|
|
53
|
-
elif op == "eq":
|
|
54
|
-
if val != v:
|
|
55
|
-
return False
|
|
56
|
-
elif op == "ne":
|
|
57
|
-
if val == v:
|
|
58
|
-
return False
|
|
59
|
-
return True
|
|
60
|
-
return val == cond
|
|
61
|
-
|
|
62
24
|
class MemoryRepository(Repository[T, TId], Generic[T, TId]):
|
|
63
25
|
def __init__(
|
|
64
26
|
self,
|
|
@@ -157,13 +119,7 @@ class MemoryRepository(Repository[T, TId], Generic[T, TId]):
|
|
|
157
119
|
return []
|
|
158
120
|
matched: list[T] = []
|
|
159
121
|
for ent in self._items.values():
|
|
160
|
-
|
|
161
|
-
for k, v in criteria.items():
|
|
162
|
-
val = _get_field_value(ent, k)
|
|
163
|
-
if not _match_value(val, v):
|
|
164
|
-
ok = False
|
|
165
|
-
break
|
|
166
|
-
if ok:
|
|
122
|
+
if match_criteria(criteria, lambda field: _get_field_value(ent, field)):
|
|
167
123
|
matched.append(ent)
|
|
168
124
|
if sort_by is not None:
|
|
169
125
|
matched.sort(
|
{persistence_kit-1.0.0 → persistence_kit-3.0.0}/persistence_kit/repository/mongo_repo/mongo_repo.py
RENAMED
|
@@ -7,9 +7,12 @@ except ImportError:
|
|
|
7
7
|
from typing_extensions import override
|
|
8
8
|
|
|
9
9
|
import asyncio
|
|
10
|
+
import re
|
|
10
11
|
from motor.motor_asyncio import AsyncIOMotorDatabase, AsyncIOMotorCollection
|
|
11
12
|
from persistence_kit.contracts.repository import Repository
|
|
12
13
|
from persistence_kit.repository.filter_ops import (
|
|
14
|
+
is_logical_key,
|
|
15
|
+
iter_criteria_groups,
|
|
13
16
|
is_multi_value,
|
|
14
17
|
is_range_dict,
|
|
15
18
|
iter_range_ops,
|
|
@@ -51,6 +54,21 @@ def _range_to_mongo(value: Mapping[str, Any]) -> Mapping[str, Any]:
|
|
|
51
54
|
query["$eq"] = v
|
|
52
55
|
elif op == "ne":
|
|
53
56
|
query["$ne"] = v
|
|
57
|
+
elif op == "contains":
|
|
58
|
+
query["$regex"] = re.escape(str(v))
|
|
59
|
+
elif op == "icontains":
|
|
60
|
+
query["$regex"] = re.escape(str(v))
|
|
61
|
+
query["$options"] = "i"
|
|
62
|
+
elif op == "startswith":
|
|
63
|
+
query["$regex"] = f"^{re.escape(str(v))}"
|
|
64
|
+
elif op == "istartswith":
|
|
65
|
+
query["$regex"] = f"^{re.escape(str(v))}"
|
|
66
|
+
query["$options"] = "i"
|
|
67
|
+
elif op == "endswith":
|
|
68
|
+
query["$regex"] = f"{re.escape(str(v))}$"
|
|
69
|
+
elif op == "iendswith":
|
|
70
|
+
query["$regex"] = f"{re.escape(str(v))}$"
|
|
71
|
+
query["$options"] = "i"
|
|
54
72
|
return query
|
|
55
73
|
|
|
56
74
|
|
|
@@ -68,6 +86,17 @@ def _build_query(
|
|
|
68
86
|
|
|
69
87
|
query: dict[str, Any] = {}
|
|
70
88
|
for k, v in criteria.items():
|
|
89
|
+
if is_logical_key(k):
|
|
90
|
+
rendered_groups: list[dict[str, Any]] = []
|
|
91
|
+
for group in iter_criteria_groups(v):
|
|
92
|
+
rendered = _build_query(mapper, group)
|
|
93
|
+
if rendered is None:
|
|
94
|
+
continue
|
|
95
|
+
rendered_groups.append(rendered)
|
|
96
|
+
if not rendered_groups:
|
|
97
|
+
return None
|
|
98
|
+
query[f"${k}"] = rendered_groups
|
|
99
|
+
continue
|
|
71
100
|
f = _normalize_field(mapper, k)
|
|
72
101
|
if v is None:
|
|
73
102
|
query[f] = {"$eq": None}
|
|
@@ -6,11 +6,13 @@ try:
|
|
|
6
6
|
except ImportError:
|
|
7
7
|
from typing_extensions import override
|
|
8
8
|
|
|
9
|
-
from sqlalchemy import Table, select, insert, update as sql_update, delete as sql_delete, Index, and_, func, distinct
|
|
9
|
+
from sqlalchemy import Table, select, insert, update as sql_update, delete as sql_delete, Index, and_, or_, func, distinct
|
|
10
10
|
from sqlalchemy.ext.asyncio import AsyncEngine
|
|
11
11
|
|
|
12
12
|
from persistence_kit.contracts.repository import Repository
|
|
13
13
|
from persistence_kit.repository.filter_ops import (
|
|
14
|
+
is_logical_key,
|
|
15
|
+
iter_criteria_groups,
|
|
14
16
|
is_multi_value,
|
|
15
17
|
is_range_dict,
|
|
16
18
|
iter_range_ops,
|
|
@@ -52,6 +54,19 @@ def _build_where_clauses(
|
|
|
52
54
|
|
|
53
55
|
clauses: list[Any] = []
|
|
54
56
|
for field, value in criteria.items():
|
|
57
|
+
if is_logical_key(field):
|
|
58
|
+
groups: list[Any] = []
|
|
59
|
+
for group in iter_criteria_groups(value):
|
|
60
|
+
nested = _build_where_clauses(table, mapper, group)
|
|
61
|
+
if nested is None:
|
|
62
|
+
continue
|
|
63
|
+
if not nested:
|
|
64
|
+
continue
|
|
65
|
+
groups.append(and_(*nested))
|
|
66
|
+
if not groups:
|
|
67
|
+
return None
|
|
68
|
+
clauses.append(or_(*groups) if field == "or" else and_(*groups))
|
|
69
|
+
continue
|
|
55
70
|
if not mapper.has_attr(field):
|
|
56
71
|
raise ValueError(
|
|
57
72
|
f"Field '{field}' is not a valid attribute for {mapper.entity_type().__name__}"
|
|
@@ -86,6 +101,18 @@ def _build_where_clauses(
|
|
|
86
101
|
clauses.append(col == v)
|
|
87
102
|
elif op == "ne":
|
|
88
103
|
clauses.append(col != v)
|
|
104
|
+
elif op == "contains":
|
|
105
|
+
clauses.append(col.contains(str(v)))
|
|
106
|
+
elif op == "icontains":
|
|
107
|
+
clauses.append(col.ilike(f"%{v}%"))
|
|
108
|
+
elif op == "startswith":
|
|
109
|
+
clauses.append(col.startswith(str(v)))
|
|
110
|
+
elif op == "istartswith":
|
|
111
|
+
clauses.append(col.ilike(f"{v}%"))
|
|
112
|
+
elif op == "endswith":
|
|
113
|
+
clauses.append(col.endswith(str(v)))
|
|
114
|
+
elif op == "iendswith":
|
|
115
|
+
clauses.append(col.ilike(f"%{v}"))
|
|
89
116
|
else:
|
|
90
117
|
clauses.append(col == value)
|
|
91
118
|
return clauses
|
|
@@ -10,6 +10,8 @@ from persistence_kit.contracts.view_repository import ViewRepository
|
|
|
10
10
|
from persistence_kit.settings.constants import Database
|
|
11
11
|
from persistence_kit.settings.repo_settings import RepoSettings
|
|
12
12
|
from persistence_kit.repository.memory_repo.memory_repo import MemoryRepository
|
|
13
|
+
from persistence_kit.repository.dynamodb_repo.dynamodb_mapper import DynamoMapper
|
|
14
|
+
from persistence_kit.repository.dynamodb_repo.dynamodb_repo import DynamoRepository
|
|
13
15
|
from persistence_kit.repository.mongo_repo.mongo_mapper import DataclassMapper
|
|
14
16
|
from persistence_kit.repository.mongo_repo.mongo_repo import MongoRepository
|
|
15
17
|
from persistence_kit.repository.sqlalchemy_repo.sqlalchemy_dataclass_mapper import SqlDataclassMapper
|
|
@@ -81,6 +83,21 @@ def _repo_cached(entity_key: str, resolved: Database) -> Repository[Any, UUID]:
|
|
|
81
83
|
)
|
|
82
84
|
return MongoRepository[Any, UUID](db, mapper)
|
|
83
85
|
|
|
86
|
+
if resolved is Database.DYNAMODB:
|
|
87
|
+
prefix = settings.dynamodb_table_prefix
|
|
88
|
+
table_name = f"{prefix}{config['collection']}" if prefix else config["collection"]
|
|
89
|
+
dynamo_mapper = DynamoMapper(
|
|
90
|
+
entity_type,
|
|
91
|
+
table_name,
|
|
92
|
+
unique_fields={
|
|
93
|
+
key: (value if isinstance(value, str) else key)
|
|
94
|
+
for key, value in (config.get("unique") or {}).items()
|
|
95
|
+
},
|
|
96
|
+
)
|
|
97
|
+
return DynamoRepository[Any, UUID](
|
|
98
|
+
table_name, dynamo_mapper, region=settings.dynamodb_region
|
|
99
|
+
)
|
|
100
|
+
|
|
84
101
|
if resolved is Database.POSTGRES:
|
|
85
102
|
engine = get_engine()
|
|
86
103
|
table_name = config["collection"]
|
|
@@ -5,6 +5,11 @@ from typing import Any, Callable, Generic, Hashable, Iterable, Mapping, Optional
|
|
|
5
5
|
|
|
6
6
|
from persistence_kit.contracts.repository import Repository
|
|
7
7
|
from persistence_kit.contracts.view_repository import ViewRepository
|
|
8
|
+
from persistence_kit.repository.filter_ops import (
|
|
9
|
+
is_logical_key,
|
|
10
|
+
iter_criteria_groups,
|
|
11
|
+
match_criteria,
|
|
12
|
+
)
|
|
8
13
|
from persistence_kit.repository_factory.registry.entity_registry import get_entity_config
|
|
9
14
|
|
|
10
15
|
T = TypeVar("T")
|
|
@@ -30,6 +35,55 @@ def _field_from_entity(entity: Any, field: str, base: dict | None = None) -> Any
|
|
|
30
35
|
return payload.get(field)
|
|
31
36
|
|
|
32
37
|
|
|
38
|
+
def _get_nested_value(payload: Mapping[str, Any], path: str) -> Any:
|
|
39
|
+
current: Any = payload
|
|
40
|
+
for part in path.split("."):
|
|
41
|
+
if not isinstance(current, Mapping):
|
|
42
|
+
return None
|
|
43
|
+
current = current.get(part)
|
|
44
|
+
if current is None:
|
|
45
|
+
return None
|
|
46
|
+
return current
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _criteria_has_nested_fields(criteria: Mapping[str, Any]) -> bool:
|
|
50
|
+
for field, expected in criteria.items():
|
|
51
|
+
if is_logical_key(field):
|
|
52
|
+
for group in iter_criteria_groups(expected):
|
|
53
|
+
if _criteria_has_nested_fields(group):
|
|
54
|
+
return True
|
|
55
|
+
continue
|
|
56
|
+
if "." in field:
|
|
57
|
+
return True
|
|
58
|
+
return False
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _collect_support_includes(criteria: Mapping[str, Any]) -> set[str]:
|
|
62
|
+
required: set[str] = set()
|
|
63
|
+
for field, expected in criteria.items():
|
|
64
|
+
if is_logical_key(field):
|
|
65
|
+
for group in iter_criteria_groups(expected):
|
|
66
|
+
required.update(_collect_support_includes(group))
|
|
67
|
+
continue
|
|
68
|
+
if "." not in field:
|
|
69
|
+
continue
|
|
70
|
+
relation_path, _, _ = field.rpartition(".")
|
|
71
|
+
if relation_path:
|
|
72
|
+
required.add(relation_path)
|
|
73
|
+
return required
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _merge_includes(include: Iterable[str], extra: Iterable[str]) -> list[str]:
|
|
77
|
+
merged: list[str] = []
|
|
78
|
+
seen: set[str] = set()
|
|
79
|
+
for value in list(include) + list(extra):
|
|
80
|
+
if value in seen:
|
|
81
|
+
continue
|
|
82
|
+
seen.add(value)
|
|
83
|
+
merged.append(value)
|
|
84
|
+
return merged
|
|
85
|
+
|
|
86
|
+
|
|
33
87
|
class PopulatingRepository(ViewRepository[T, TId], Repository[T, TId], Generic[T, TId]):
|
|
34
88
|
def __init__(
|
|
35
89
|
self,
|
|
@@ -78,6 +132,16 @@ class PopulatingRepository(ViewRepository[T, TId], Repository[T, TId], Generic[T
|
|
|
78
132
|
self,
|
|
79
133
|
criteria: Mapping[str, Hashable | list[Hashable] | Mapping[str, Any]],
|
|
80
134
|
) -> int:
|
|
135
|
+
if criteria and _criteria_has_nested_fields(criteria):
|
|
136
|
+
support_includes = _collect_support_includes(criteria)
|
|
137
|
+
items = await self._list_all_entities()
|
|
138
|
+
lookup_cache: dict[tuple[str, str, Hashable], Any] = {}
|
|
139
|
+
count = 0
|
|
140
|
+
for item in items:
|
|
141
|
+
row = await self._populate(item, support_includes, lookup_cache)
|
|
142
|
+
if self._row_matches_criteria(row, criteria):
|
|
143
|
+
count += 1
|
|
144
|
+
return count
|
|
81
145
|
return await self._inner.count_by_fields(criteria)
|
|
82
146
|
|
|
83
147
|
async def distinct_values(
|
|
@@ -87,6 +151,42 @@ class PopulatingRepository(ViewRepository[T, TId], Repository[T, TId], Generic[T
|
|
|
87
151
|
) -> Sequence[Any]:
|
|
88
152
|
return await self._inner.distinct_values(field, criteria)
|
|
89
153
|
|
|
154
|
+
async def _list_all_entities(self) -> list[T]:
|
|
155
|
+
all_items: list[T] = []
|
|
156
|
+
cursor = 0
|
|
157
|
+
page_size = 1000
|
|
158
|
+
while True:
|
|
159
|
+
batch = await self._inner.list(offset=cursor, limit=page_size)
|
|
160
|
+
if not batch:
|
|
161
|
+
break
|
|
162
|
+
all_items.extend(batch)
|
|
163
|
+
if len(batch) < page_size:
|
|
164
|
+
break
|
|
165
|
+
cursor += page_size
|
|
166
|
+
return all_items
|
|
167
|
+
|
|
168
|
+
def _sort_populated_rows(
|
|
169
|
+
self,
|
|
170
|
+
rows: list[tuple[T, dict]],
|
|
171
|
+
sort_by: str | None,
|
|
172
|
+
*,
|
|
173
|
+
sort_desc: bool,
|
|
174
|
+
) -> list[tuple[T, dict]]:
|
|
175
|
+
if sort_by is None:
|
|
176
|
+
return rows
|
|
177
|
+
return sorted(
|
|
178
|
+
rows,
|
|
179
|
+
key=lambda pair: _normalize_sort_value(_get_nested_value(pair[1], sort_by)),
|
|
180
|
+
reverse=sort_desc,
|
|
181
|
+
)
|
|
182
|
+
|
|
183
|
+
def _row_matches_criteria(
|
|
184
|
+
self,
|
|
185
|
+
row: Mapping[str, Any],
|
|
186
|
+
criteria: Mapping[str, Hashable | list[Hashable] | Mapping[str, Any]],
|
|
187
|
+
) -> bool:
|
|
188
|
+
return match_criteria(criteria, lambda field: _get_nested_value(row, field))
|
|
189
|
+
|
|
90
190
|
async def get_with(self, entity_id: TId, include: Iterable[str]) -> Optional[dict]:
|
|
91
191
|
ent = await self.get(entity_id)
|
|
92
192
|
if not ent:
|
|
@@ -115,17 +215,7 @@ class PopulatingRepository(ViewRepository[T, TId], Repository[T, TId], Generic[T
|
|
|
115
215
|
) -> list[dict]:
|
|
116
216
|
lookup_cache: dict[tuple[str, str, Hashable], Any] = {}
|
|
117
217
|
if sort_by and "." in sort_by:
|
|
118
|
-
all_items
|
|
119
|
-
cursor = 0
|
|
120
|
-
page_size = 1000
|
|
121
|
-
while True:
|
|
122
|
-
batch = await self._inner.list(offset=cursor, limit=page_size)
|
|
123
|
-
if not batch:
|
|
124
|
-
break
|
|
125
|
-
all_items.extend(batch)
|
|
126
|
-
if len(batch) < page_size:
|
|
127
|
-
break
|
|
128
|
-
cursor += page_size
|
|
218
|
+
all_items = await self._list_all_entities()
|
|
129
219
|
ordered = await self._sort_entities_by_nested(
|
|
130
220
|
all_items,
|
|
131
221
|
sort_by,
|
|
@@ -157,6 +247,30 @@ class PopulatingRepository(ViewRepository[T, TId], Repository[T, TId], Generic[T
|
|
|
157
247
|
sort_desc: bool = False,
|
|
158
248
|
) -> list[dict]:
|
|
159
249
|
lookup_cache: dict[tuple[str, str, Hashable], Any] = {}
|
|
250
|
+
requested_includes = list(include)
|
|
251
|
+
if criteria and _criteria_has_nested_fields(criteria):
|
|
252
|
+
support_includes = _merge_includes(requested_includes, _collect_support_includes(criteria))
|
|
253
|
+
if sort_by and "." in sort_by:
|
|
254
|
+
relation_path, _, _ = sort_by.rpartition(".")
|
|
255
|
+
support_includes = _merge_includes(support_includes, [relation_path])
|
|
256
|
+
items = await self._list_all_entities()
|
|
257
|
+
hydrated: list[tuple[T, dict]] = []
|
|
258
|
+
for item in items:
|
|
259
|
+
support_row = await self._populate(item, support_includes, lookup_cache)
|
|
260
|
+
if self._row_matches_criteria(support_row, criteria):
|
|
261
|
+
hydrated.append((item, support_row))
|
|
262
|
+
hydrated = self._sort_populated_rows(
|
|
263
|
+
hydrated,
|
|
264
|
+
sort_by,
|
|
265
|
+
sort_desc=sort_desc,
|
|
266
|
+
)
|
|
267
|
+
if offset:
|
|
268
|
+
hydrated = hydrated[offset:]
|
|
269
|
+
if limit is not None:
|
|
270
|
+
hydrated = hydrated[:limit]
|
|
271
|
+
if support_includes == requested_includes:
|
|
272
|
+
return [row for _, row in hydrated]
|
|
273
|
+
return [await self._populate(item, requested_includes, lookup_cache) for item, _ in hydrated]
|
|
160
274
|
if sort_by and "." in sort_by:
|
|
161
275
|
items = await self._inner.list_by_fields(
|
|
162
276
|
criteria,
|
|
@@ -18,6 +18,9 @@ class RepoSettings(BaseSettings):
|
|
|
18
18
|
postgres_port: int | None = 5432
|
|
19
19
|
postgres_db: str | None = None
|
|
20
20
|
|
|
21
|
+
dynamodb_table_prefix: str = ""
|
|
22
|
+
dynamodb_region: str = "us-east-1"
|
|
23
|
+
|
|
21
24
|
@field_validator("cors_origins", mode="before")
|
|
22
25
|
def split_origins(cls, value):
|
|
23
26
|
return split_csv_list(value)
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[tool.poetry]
|
|
2
2
|
name = "persistence-kit"
|
|
3
|
-
version = "
|
|
3
|
+
version = "3.0.0"
|
|
4
4
|
description = "Reusable persistence and repository toolkit"
|
|
5
5
|
authors = ["Andres Felipe Serrano Barrios <andresfserrano1@gmail.com>"]
|
|
6
6
|
readme = "README.md"
|
|
@@ -31,6 +31,7 @@ asyncpg = "^0.30.0"
|
|
|
31
31
|
typing-extensions = "^4.12.0"
|
|
32
32
|
pydantic-settings = "^2.3.0"
|
|
33
33
|
fastapi = "^0.115.0"
|
|
34
|
+
boto3 = "^1.35.0"
|
|
34
35
|
|
|
35
36
|
[tool.poetry.group.dev.dependencies]
|
|
36
37
|
pytest = "^8.3.0"
|
|
@@ -1,32 +0,0 @@
|
|
|
1
|
-
from __future__ import annotations
|
|
2
|
-
|
|
3
|
-
from typing import Any, Iterable, Mapping
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
_SIMPLE_OPS = {"gte", "gt", "lte", "lt", "eq", "ne"}
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
def is_multi_value(value: Any) -> bool:
|
|
10
|
-
return isinstance(value, list)
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
def is_range_dict(value: Any) -> bool:
|
|
14
|
-
return isinstance(value, dict)
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
def iter_range_ops(value: Mapping[str, Any]) -> Iterable[tuple[str, Any]]:
|
|
18
|
-
if not value:
|
|
19
|
-
return []
|
|
20
|
-
items = list(value.items())
|
|
21
|
-
for op, v in items:
|
|
22
|
-
if op == "between":
|
|
23
|
-
if not isinstance(v, list) or len(v) != 2:
|
|
24
|
-
raise ValueError("between expects a list with exactly two values")
|
|
25
|
-
elif op == "in":
|
|
26
|
-
if not isinstance(v, list):
|
|
27
|
-
raise ValueError("in expects a list")
|
|
28
|
-
elif op in _SIMPLE_OPS:
|
|
29
|
-
continue
|
|
30
|
-
else:
|
|
31
|
-
raise ValueError(f"Unsupported operator: {op}")
|
|
32
|
-
return items
|
|
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
|
{persistence_kit-1.0.0 → persistence_kit-3.0.0}/persistence_kit/contracts/view_repository.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{persistence_kit-1.0.0 → persistence_kit-3.0.0}/persistence_kit/repository/memory_repo/__init__.py
RENAMED
|
File without changes
|
{persistence_kit-1.0.0 → persistence_kit-3.0.0}/persistence_kit/repository/mongo_repo/__init__.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{persistence_kit-1.0.0 → persistence_kit-3.0.0}/persistence_kit/repository_factory/__init__.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{persistence_kit-1.0.0 → persistence_kit-3.0.0}/persistence_kit/repository_factory/view/__init__.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|