persistence-kit 2.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-2.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-2.0.0 → persistence_kit-3.0.0}/persistence_kit/repository_factory/factory/repository_factory.py +17 -0
- {persistence_kit-2.0.0 → persistence_kit-3.0.0}/persistence_kit/settings/constants.py +1 -0
- {persistence_kit-2.0.0 → persistence_kit-3.0.0}/persistence_kit/settings/repo_settings.py +3 -0
- {persistence_kit-2.0.0 → persistence_kit-3.0.0}/pyproject.toml +2 -1
- {persistence_kit-2.0.0 → persistence_kit-3.0.0}/LICENSE +0 -0
- {persistence_kit-2.0.0 → persistence_kit-3.0.0}/README.md +0 -0
- {persistence_kit-2.0.0 → persistence_kit-3.0.0}/persistence_kit/__init__.py +0 -0
- {persistence_kit-2.0.0 → persistence_kit-3.0.0}/persistence_kit/api/__init__.py +0 -0
- {persistence_kit-2.0.0 → persistence_kit-3.0.0}/persistence_kit/api/common.py +0 -0
- {persistence_kit-2.0.0 → persistence_kit-3.0.0}/persistence_kit/api/error_handlers.py +0 -0
- {persistence_kit-2.0.0 → persistence_kit-3.0.0}/persistence_kit/api/exceptions.py +0 -0
- {persistence_kit-2.0.0 → persistence_kit-3.0.0}/persistence_kit/api/route_loader.py +0 -0
- {persistence_kit-2.0.0 → persistence_kit-3.0.0}/persistence_kit/bootstrap/__init__.py +0 -0
- {persistence_kit-2.0.0 → persistence_kit-3.0.0}/persistence_kit/bootstrap/configuration.py +0 -0
- {persistence_kit-2.0.0 → persistence_kit-3.0.0}/persistence_kit/bootstrap/seeders.py +0 -0
- {persistence_kit-2.0.0 → persistence_kit-3.0.0}/persistence_kit/bootstrap/startup.py +0 -0
- {persistence_kit-2.0.0 → persistence_kit-3.0.0}/persistence_kit/contracts/__init__.py +0 -0
- {persistence_kit-2.0.0 → persistence_kit-3.0.0}/persistence_kit/contracts/repository.py +0 -0
- {persistence_kit-2.0.0 → persistence_kit-3.0.0}/persistence_kit/contracts/view_repository.py +0 -0
- {persistence_kit-2.0.0 → persistence_kit-3.0.0}/persistence_kit/py.typed +0 -0
- {persistence_kit-2.0.0 → persistence_kit-3.0.0}/persistence_kit/repository/__init__.py +0 -0
- {persistence_kit-2.0.0 → persistence_kit-3.0.0}/persistence_kit/repository/filter_ops.py +0 -0
- {persistence_kit-2.0.0 → persistence_kit-3.0.0}/persistence_kit/repository/memory_repo/__init__.py +0 -0
- {persistence_kit-2.0.0 → persistence_kit-3.0.0}/persistence_kit/repository/memory_repo/memory_repo.py +0 -0
- {persistence_kit-2.0.0 → persistence_kit-3.0.0}/persistence_kit/repository/mongo_repo/__init__.py +0 -0
- {persistence_kit-2.0.0 → persistence_kit-3.0.0}/persistence_kit/repository/mongo_repo/mongo_mapper.py +0 -0
- {persistence_kit-2.0.0 → persistence_kit-3.0.0}/persistence_kit/repository/mongo_repo/mongo_repo.py +0 -0
- {persistence_kit-2.0.0 → persistence_kit-3.0.0}/persistence_kit/repository/sqlalchemy_repo/__init__.py +0 -0
- {persistence_kit-2.0.0 → persistence_kit-3.0.0}/persistence_kit/repository/sqlalchemy_repo/schema_evolve.py +0 -0
- {persistence_kit-2.0.0 → persistence_kit-3.0.0}/persistence_kit/repository/sqlalchemy_repo/sqlalchemy_dataclass_mapper.py +0 -0
- {persistence_kit-2.0.0 → persistence_kit-3.0.0}/persistence_kit/repository/sqlalchemy_repo/sqlalchemy_engine.py +0 -0
- {persistence_kit-2.0.0 → persistence_kit-3.0.0}/persistence_kit/repository/sqlalchemy_repo/sqlalchemy_repo.py +0 -0
- {persistence_kit-2.0.0 → persistence_kit-3.0.0}/persistence_kit/repository/sqlalchemy_repo/table_factory.py +0 -0
- {persistence_kit-2.0.0 → persistence_kit-3.0.0}/persistence_kit/repository_factory/__init__.py +0 -0
- {persistence_kit-2.0.0 → persistence_kit-3.0.0}/persistence_kit/repository_factory/factory/__init__.py +0 -0
- {persistence_kit-2.0.0 → persistence_kit-3.0.0}/persistence_kit/repository_factory/registry/__init__.py +0 -0
- {persistence_kit-2.0.0 → persistence_kit-3.0.0}/persistence_kit/repository_factory/registry/entity_registry.py +0 -0
- {persistence_kit-2.0.0 → persistence_kit-3.0.0}/persistence_kit/repository_factory/view/__init__.py +0 -0
- {persistence_kit-2.0.0 → persistence_kit-3.0.0}/persistence_kit/repository_factory/view/populating_repository.py +0 -0
- {persistence_kit-2.0.0 → persistence_kit-3.0.0}/persistence_kit/settings/__init__.py +0 -0
- {persistence_kit-2.0.0 → persistence_kit-3.0.0}/persistence_kit/settings/parsers.py +0 -0
- {persistence_kit-2.0.0 → persistence_kit-3.0.0}/persistence_kit/utils/__init__.py +0 -0
- {persistence_kit-2.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
|
|
@@ -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"]
|
|
@@ -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"
|
|
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-2.0.0 → persistence_kit-3.0.0}/persistence_kit/contracts/view_repository.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{persistence_kit-2.0.0 → persistence_kit-3.0.0}/persistence_kit/repository/memory_repo/__init__.py
RENAMED
|
File without changes
|
|
File without changes
|
{persistence_kit-2.0.0 → persistence_kit-3.0.0}/persistence_kit/repository/mongo_repo/__init__.py
RENAMED
|
File without changes
|
|
File without changes
|
{persistence_kit-2.0.0 → persistence_kit-3.0.0}/persistence_kit/repository/mongo_repo/mongo_repo.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-2.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-2.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
|
|
File without changes
|