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.
Files changed (47) hide show
  1. {persistence_kit-1.0.0 → persistence_kit-3.0.0}/PKG-INFO +2 -1
  2. persistence_kit-3.0.0/persistence_kit/repository/dynamodb_repo/dynamodb_mapper.py +111 -0
  3. persistence_kit-3.0.0/persistence_kit/repository/dynamodb_repo/dynamodb_repo.py +267 -0
  4. persistence_kit-3.0.0/persistence_kit/repository/filter_ops.py +132 -0
  5. {persistence_kit-1.0.0 → persistence_kit-3.0.0}/persistence_kit/repository/memory_repo/memory_repo.py +2 -46
  6. {persistence_kit-1.0.0 → persistence_kit-3.0.0}/persistence_kit/repository/mongo_repo/mongo_repo.py +29 -0
  7. {persistence_kit-1.0.0 → persistence_kit-3.0.0}/persistence_kit/repository/sqlalchemy_repo/sqlalchemy_repo.py +28 -1
  8. {persistence_kit-1.0.0 → persistence_kit-3.0.0}/persistence_kit/repository_factory/factory/repository_factory.py +17 -0
  9. {persistence_kit-1.0.0 → persistence_kit-3.0.0}/persistence_kit/repository_factory/view/populating_repository.py +125 -11
  10. {persistence_kit-1.0.0 → persistence_kit-3.0.0}/persistence_kit/settings/constants.py +1 -0
  11. {persistence_kit-1.0.0 → persistence_kit-3.0.0}/persistence_kit/settings/repo_settings.py +3 -0
  12. {persistence_kit-1.0.0 → persistence_kit-3.0.0}/pyproject.toml +2 -1
  13. persistence_kit-1.0.0/persistence_kit/repository/filter_ops.py +0 -32
  14. {persistence_kit-1.0.0 → persistence_kit-3.0.0}/LICENSE +0 -0
  15. {persistence_kit-1.0.0 → persistence_kit-3.0.0}/README.md +0 -0
  16. {persistence_kit-1.0.0 → persistence_kit-3.0.0}/persistence_kit/__init__.py +0 -0
  17. {persistence_kit-1.0.0 → persistence_kit-3.0.0}/persistence_kit/api/__init__.py +0 -0
  18. {persistence_kit-1.0.0 → persistence_kit-3.0.0}/persistence_kit/api/common.py +0 -0
  19. {persistence_kit-1.0.0 → persistence_kit-3.0.0}/persistence_kit/api/error_handlers.py +0 -0
  20. {persistence_kit-1.0.0 → persistence_kit-3.0.0}/persistence_kit/api/exceptions.py +0 -0
  21. {persistence_kit-1.0.0 → persistence_kit-3.0.0}/persistence_kit/api/route_loader.py +0 -0
  22. {persistence_kit-1.0.0 → persistence_kit-3.0.0}/persistence_kit/bootstrap/__init__.py +0 -0
  23. {persistence_kit-1.0.0 → persistence_kit-3.0.0}/persistence_kit/bootstrap/configuration.py +0 -0
  24. {persistence_kit-1.0.0 → persistence_kit-3.0.0}/persistence_kit/bootstrap/seeders.py +0 -0
  25. {persistence_kit-1.0.0 → persistence_kit-3.0.0}/persistence_kit/bootstrap/startup.py +0 -0
  26. {persistence_kit-1.0.0 → persistence_kit-3.0.0}/persistence_kit/contracts/__init__.py +0 -0
  27. {persistence_kit-1.0.0 → persistence_kit-3.0.0}/persistence_kit/contracts/repository.py +0 -0
  28. {persistence_kit-1.0.0 → persistence_kit-3.0.0}/persistence_kit/contracts/view_repository.py +0 -0
  29. {persistence_kit-1.0.0 → persistence_kit-3.0.0}/persistence_kit/py.typed +0 -0
  30. {persistence_kit-1.0.0 → persistence_kit-3.0.0}/persistence_kit/repository/__init__.py +0 -0
  31. {persistence_kit-1.0.0 → persistence_kit-3.0.0}/persistence_kit/repository/memory_repo/__init__.py +0 -0
  32. {persistence_kit-1.0.0 → persistence_kit-3.0.0}/persistence_kit/repository/mongo_repo/__init__.py +0 -0
  33. {persistence_kit-1.0.0 → persistence_kit-3.0.0}/persistence_kit/repository/mongo_repo/mongo_mapper.py +0 -0
  34. {persistence_kit-1.0.0 → persistence_kit-3.0.0}/persistence_kit/repository/sqlalchemy_repo/__init__.py +0 -0
  35. {persistence_kit-1.0.0 → persistence_kit-3.0.0}/persistence_kit/repository/sqlalchemy_repo/schema_evolve.py +0 -0
  36. {persistence_kit-1.0.0 → persistence_kit-3.0.0}/persistence_kit/repository/sqlalchemy_repo/sqlalchemy_dataclass_mapper.py +0 -0
  37. {persistence_kit-1.0.0 → persistence_kit-3.0.0}/persistence_kit/repository/sqlalchemy_repo/sqlalchemy_engine.py +0 -0
  38. {persistence_kit-1.0.0 → persistence_kit-3.0.0}/persistence_kit/repository/sqlalchemy_repo/table_factory.py +0 -0
  39. {persistence_kit-1.0.0 → persistence_kit-3.0.0}/persistence_kit/repository_factory/__init__.py +0 -0
  40. {persistence_kit-1.0.0 → persistence_kit-3.0.0}/persistence_kit/repository_factory/factory/__init__.py +0 -0
  41. {persistence_kit-1.0.0 → persistence_kit-3.0.0}/persistence_kit/repository_factory/registry/__init__.py +0 -0
  42. {persistence_kit-1.0.0 → persistence_kit-3.0.0}/persistence_kit/repository_factory/registry/entity_registry.py +0 -0
  43. {persistence_kit-1.0.0 → persistence_kit-3.0.0}/persistence_kit/repository_factory/view/__init__.py +0 -0
  44. {persistence_kit-1.0.0 → persistence_kit-3.0.0}/persistence_kit/settings/__init__.py +0 -0
  45. {persistence_kit-1.0.0 → persistence_kit-3.0.0}/persistence_kit/settings/parsers.py +0 -0
  46. {persistence_kit-1.0.0 → persistence_kit-3.0.0}/persistence_kit/utils/__init__.py +0 -0
  47. {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: 1.0.0
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
- iter_range_ops,
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
- ok = True
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(
@@ -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: list[T] = []
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,
@@ -5,3 +5,4 @@ class Database(str, Enum):
5
5
  MEMORY = "memory"
6
6
  MONGO = "mongo"
7
7
  POSTGRES = "postgres"
8
+ DYNAMODB = "dynamodb"
@@ -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 = "1.0.0"
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