SARepo 0.1.7__py3-none-any.whl → 0.1.8__py3-none-any.whl
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.
- SARepo/repo.py +86 -51
- SARepo/sa_repo.py +199 -69
- sarepo-0.1.8.dist-info/METADATA +157 -0
- sarepo-0.1.8.dist-info/RECORD +12 -0
- sarepo-0.1.7.dist-info/METADATA +0 -90
- sarepo-0.1.7.dist-info/RECORD +0 -12
- {sarepo-0.1.7.dist-info → sarepo-0.1.8.dist-info}/WHEEL +0 -0
- {sarepo-0.1.7.dist-info → sarepo-0.1.8.dist-info}/licenses/LICENSE +0 -0
- {sarepo-0.1.7.dist-info → sarepo-0.1.8.dist-info}/top_level.txt +0 -0
SARepo/repo.py
CHANGED
@@ -1,4 +1,6 @@
|
|
1
|
-
from
|
1
|
+
from __future__ import annotations
|
2
|
+
|
3
|
+
from typing import Any, Generic, List, Optional, Protocol, Type, TypeVar, runtime_checkable
|
2
4
|
|
3
5
|
from SARepo.sa_repo import Spec
|
4
6
|
from .base import Page, PageRequest
|
@@ -6,69 +8,102 @@ from .base import Page, PageRequest
|
|
6
8
|
T = TypeVar("T")
|
7
9
|
|
8
10
|
|
11
|
+
@runtime_checkable
|
9
12
|
class CrudRepository(Protocol, Generic[T]):
|
10
|
-
|
11
|
-
|
12
|
-
|
13
|
-
|
14
|
-
*,
|
15
|
-
include_deleted: bool = False,
|
16
|
-
order_by=None,
|
17
|
-
**filters) -> List[T]: ...
|
18
|
-
|
19
|
-
def get(self, id_: Any = None, *, include_deleted: bool = False, **filters) -> T: ...
|
13
|
+
"""
|
14
|
+
Контракт CRUD-репозитория для SQLAlchemy-подобной реализации.
|
15
|
+
Реализация должна соответствовать сигнатурам ниже.
|
16
|
+
"""
|
20
17
|
|
21
|
-
|
22
|
-
|
23
|
-
def add(self, entity: T) -> T: ...
|
24
|
-
|
25
|
-
def update(self, entity: T) -> T: ...
|
26
|
-
|
27
|
-
def remove(self, entity: Optional[T] = None, id: Optional[Any] = None) -> bool: ...
|
18
|
+
model: Type[T]
|
28
19
|
|
29
|
-
def delete_by_id(self, id_: Any) -> bool: ...
|
30
20
|
|
31
|
-
def
|
32
|
-
|
21
|
+
def getAll(
|
22
|
+
self,
|
23
|
+
limit: Optional[int] = None,
|
24
|
+
*,
|
25
|
+
include_deleted: bool = False,
|
26
|
+
order_by=None,
|
27
|
+
**filters: Any,
|
28
|
+
) -> List[T]:
|
29
|
+
...
|
30
|
+
|
31
|
+
def get(self, id_: Any = None, *, include_deleted: bool = False, **filters: Any) -> T:
|
32
|
+
"""
|
33
|
+
Должен вернуть объект T или бросить исключение (например, NotFoundError),
|
34
|
+
если объект не найден. Если хочешь Optional — измени сигнатуру.
|
35
|
+
"""
|
36
|
+
...
|
37
|
+
|
38
|
+
def try_get(self, id_: Any = None, *, include_deleted: bool = False, **filters: Any) -> Optional[T]:
|
39
|
+
...
|
40
|
+
|
41
|
+
def add(self, entity: T) -> T:
|
42
|
+
...
|
43
|
+
|
44
|
+
def update(self, entity: T) -> T:
|
45
|
+
...
|
46
|
+
|
47
|
+
def remove(self, entity: Optional[T] = None, id: Optional[Any] = None) -> bool:
|
48
|
+
...
|
49
|
+
|
50
|
+
def delete_by_id(self, id_: Any) -> bool:
|
51
|
+
...
|
52
|
+
|
53
|
+
def page(
|
54
|
+
self,
|
55
|
+
page: PageRequest,
|
56
|
+
spec: Optional[Spec] = None,
|
57
|
+
order_by=None,
|
58
|
+
*,
|
59
|
+
include_deleted: bool = False,
|
60
|
+
) -> Page[T]:
|
61
|
+
...
|
33
62
|
|
34
63
|
def get_all_by_column(
|
35
|
-
|
36
|
-
|
37
|
-
|
38
|
-
|
39
|
-
|
40
|
-
|
41
|
-
|
42
|
-
|
43
|
-
) -> list[T]:
|
64
|
+
self,
|
65
|
+
column_name: str,
|
66
|
+
value: Any,
|
67
|
+
*,
|
68
|
+
limit: Optional[int] = None,
|
69
|
+
order_by=None,
|
70
|
+
include_deleted: bool = False,
|
71
|
+
**extra_filters: Any,
|
72
|
+
) -> list[T]:
|
73
|
+
...
|
44
74
|
|
45
75
|
def find_all_by_column(
|
46
|
-
|
47
|
-
|
48
|
-
|
49
|
-
|
50
|
-
|
51
|
-
|
52
|
-
|
53
|
-
|
54
|
-
) -> list[T]:
|
76
|
+
self,
|
77
|
+
column_name: str,
|
78
|
+
value: Any,
|
79
|
+
*,
|
80
|
+
limit: Optional[int] = None,
|
81
|
+
order_by=None,
|
82
|
+
include_deleted: bool = False,
|
83
|
+
**extra_filters: Any,
|
84
|
+
) -> list[T]:
|
85
|
+
...
|
55
86
|
|
56
|
-
def get_or_create(
|
57
|
-
|
58
|
-
defaults: Optional[dict] = None,
|
59
|
-
**unique_filters
|
60
|
-
) -> tuple[T, bool]: ...
|
87
|
+
def get_or_create(self, defaults: Optional[dict] = None, **unique_filters: Any) -> tuple[T, bool]:
|
88
|
+
...
|
61
89
|
|
62
|
-
def raw_query(self, sql: str, params: Optional[dict] = None) -> list[dict]:
|
90
|
+
def raw_query(self, sql: str, params: Optional[dict] = None) -> list[dict]:
|
91
|
+
...
|
63
92
|
|
64
|
-
def aggregate_avg(self, column_name: str, **filters) -> Optional[float]:
|
93
|
+
def aggregate_avg(self, column_name: str, **filters: Any) -> Optional[float]:
|
94
|
+
...
|
65
95
|
|
66
|
-
def aggregate_min(self, column_name: str, **filters):
|
96
|
+
def aggregate_min(self, column_name: str, **filters: Any):
|
97
|
+
...
|
67
98
|
|
68
|
-
def aggregate_max(self, column_name: str, **filters):
|
99
|
+
def aggregate_max(self, column_name: str, **filters: Any):
|
100
|
+
...
|
69
101
|
|
70
|
-
def aggregate_sum(self, column_name: str, **filters):
|
102
|
+
def aggregate_sum(self, column_name: str, **filters: Any):
|
103
|
+
...
|
71
104
|
|
72
|
-
def count(self, **filters) -> int:
|
105
|
+
def count(self, **filters: Any) -> int:
|
106
|
+
...
|
73
107
|
|
74
|
-
def restore(self, id_: Any) -> bool:
|
108
|
+
def restore(self, id_: Any) -> bool:
|
109
|
+
...
|
SARepo/sa_repo.py
CHANGED
@@ -1,7 +1,7 @@
|
|
1
|
-
from typing import List, Type, Generic, TypeVar, Optional, Sequence, Any, Callable
|
1
|
+
from typing import List, Type, Generic, TypeVar, Optional, Sequence, Any, Callable, Tuple
|
2
2
|
from sqlalchemy.orm import Session
|
3
3
|
from sqlalchemy.ext.asyncio import AsyncSession
|
4
|
-
from sqlalchemy import inspect, select, func, text
|
4
|
+
from sqlalchemy import inspect, select, func, text, insert, update, and_
|
5
5
|
from .base import PageRequest, Page, NotFoundError
|
6
6
|
|
7
7
|
T = TypeVar("T")
|
@@ -9,12 +9,14 @@ Spec = Callable
|
|
9
9
|
|
10
10
|
|
11
11
|
class SARepository(Generic[T]):
|
12
|
-
"""
|
12
|
+
"""Sync repository implementation for SQLAlchemy 2.x (ORM Session)."""
|
13
13
|
|
14
14
|
def __init__(self, model: Type[T], session: Session):
|
15
15
|
self.model = model
|
16
16
|
self.session = session
|
17
17
|
|
18
|
+
# ---- helpers
|
19
|
+
|
18
20
|
def _resolve_column(self, column_name: str):
|
19
21
|
try:
|
20
22
|
return getattr(self.model, column_name)
|
@@ -26,6 +28,11 @@ class SARepository(Generic[T]):
|
|
26
28
|
stmt = stmt.filter_by(**filters)
|
27
29
|
return stmt
|
28
30
|
|
31
|
+
def _to_dto(self, obj: Any) -> Any:
|
32
|
+
if hasattr(obj, "to_read_model"):
|
33
|
+
return obj.to_read_model()
|
34
|
+
return obj
|
35
|
+
|
29
36
|
def _select(self):
|
30
37
|
return select(self.model)
|
31
38
|
|
@@ -37,14 +44,19 @@ class SARepository(Generic[T]):
|
|
37
44
|
stmt = stmt.where(self.model.is_deleted == False) # noqa: E712
|
38
45
|
return stmt
|
39
46
|
|
47
|
+
# ---- CRUD/queries
|
48
|
+
|
40
49
|
def getAll(
|
41
|
-
|
42
|
-
|
43
|
-
|
44
|
-
|
45
|
-
|
46
|
-
|
50
|
+
self,
|
51
|
+
limit: Optional[int] = None,
|
52
|
+
*,
|
53
|
+
include_deleted: bool = False,
|
54
|
+
order_by=None,
|
55
|
+
**filters,
|
47
56
|
) -> List[T]:
|
57
|
+
"""
|
58
|
+
Получить все записи с возможностью фильтрации (username='foo', age__gt=20, и т.д.)
|
59
|
+
"""
|
48
60
|
stmt = select(self.model)
|
49
61
|
stmt = self._apply_filters(stmt, **filters)
|
50
62
|
stmt = self._apply_alive_filter(stmt, include_deleted)
|
@@ -55,11 +67,17 @@ class SARepository(Generic[T]):
|
|
55
67
|
result = self.session.execute(stmt)
|
56
68
|
return result.scalars().all()
|
57
69
|
|
58
|
-
def get(self, id_: Any = None, *, include_deleted: bool = False, **filters) -> T:
|
70
|
+
def get(self, id_: Any = None, *, include_deleted: bool = False, **filters) -> Optional[T]:
|
71
|
+
"""
|
72
|
+
Получить один объект по id или по произвольным фильтрам.
|
73
|
+
repo.get(id_=1)
|
74
|
+
repo.get(username='ibrahim')
|
75
|
+
repo.get(username__ilike='%rah%') # если у тебя реализована интерпретация таких фильтров выше по стеку
|
76
|
+
"""
|
59
77
|
if id_ is not None:
|
60
78
|
obj = self.session.get(self.model, id_)
|
61
79
|
if not obj:
|
62
|
-
|
80
|
+
return None
|
63
81
|
else:
|
64
82
|
stmt = select(self.model)
|
65
83
|
stmt = self._apply_filters(stmt, **filters)
|
@@ -67,29 +85,81 @@ class SARepository(Generic[T]):
|
|
67
85
|
res = self.session.execute(stmt)
|
68
86
|
obj = res.scalars().first()
|
69
87
|
if not obj:
|
70
|
-
|
88
|
+
return None
|
89
|
+
|
71
90
|
if self._has_soft_delete() and not include_deleted and getattr(obj, "is_deleted", False):
|
72
91
|
raise NotFoundError(f"{self.model.__name__}({getattr(obj, 'id', '?')}) deleted")
|
73
92
|
return obj
|
74
93
|
|
75
94
|
def try_get(self, id_: Any = None, *, include_deleted: bool = False, **filters) -> Optional[T]:
|
95
|
+
"""Как get(), но не выбрасывает исключение при soft-deleted."""
|
76
96
|
try:
|
77
97
|
return self.get(id_=id_, include_deleted=include_deleted, **filters)
|
78
98
|
except NotFoundError:
|
79
99
|
return None
|
80
100
|
|
81
|
-
def add(self,
|
82
|
-
|
83
|
-
|
84
|
-
|
85
|
-
|
101
|
+
def add(self, data: dict) -> T:
|
102
|
+
"""
|
103
|
+
Вставка через Core insert(...).returning(model.id) — как в async-версии.
|
104
|
+
Возвращает dict с добавленным id (повторяю твою семантику).
|
105
|
+
"""
|
106
|
+
stmt = insert(self.model).values(**data).returning(self.model.id)
|
107
|
+
res = self.session.execute(stmt)
|
108
|
+
new_id = res.scalar_one()
|
109
|
+
self.session.commit()
|
110
|
+
data["id"] = new_id
|
111
|
+
return data # так же, как у тебя: возвращаем dict, а не ORM-объект
|
112
|
+
|
113
|
+
def update(
|
114
|
+
self,
|
115
|
+
data: dict,
|
116
|
+
*,
|
117
|
+
include_deleted: bool = False,
|
118
|
+
expect_one: bool = False,
|
119
|
+
**filters,
|
120
|
+
) -> Optional[T]:
|
121
|
+
"""
|
122
|
+
Обновляет запись(и) по произвольным фильтрам.
|
123
|
+
Возвращает DTO одной обновлённой строки (если ровно одна) или None.
|
124
|
+
"""
|
125
|
+
if not data:
|
126
|
+
raise ValueError("`data` не может быть пустым.")
|
127
|
+
if not filters:
|
128
|
+
raise ValueError("Нужен хотя бы один фильтр (например, id=1 или username='foo').")
|
86
129
|
|
87
|
-
|
88
|
-
self.
|
89
|
-
self.
|
90
|
-
|
130
|
+
values = {self._resolve_column(k).key: v for k, v in data.items()}
|
131
|
+
conditions = [self._resolve_column(k) == v for k, v in filters.items()]
|
132
|
+
if self._has_soft_delete() and not include_deleted:
|
133
|
+
conditions.append(self.model.is_deleted == False) # noqa: E712
|
134
|
+
|
135
|
+
stmt = (
|
136
|
+
update(self.model)
|
137
|
+
.where(and_(*conditions))
|
138
|
+
.values(**values)
|
139
|
+
.returning(self.model)
|
140
|
+
)
|
141
|
+
|
142
|
+
res = self.session.execute(stmt)
|
143
|
+
updated_obj = res.scalar_one_or_none()
|
144
|
+
|
145
|
+
if expect_one:
|
146
|
+
rowcount = res.rowcount
|
147
|
+
if rowcount != 1:
|
148
|
+
self.session.rollback()
|
149
|
+
raise ValueError(f"Ожидалась ровно 1 строка, затронуто: {rowcount}")
|
150
|
+
|
151
|
+
if updated_obj is not None:
|
152
|
+
self.session.commit()
|
153
|
+
return self._to_dto(updated_obj)
|
154
|
+
|
155
|
+
self.session.rollback()
|
156
|
+
return None
|
91
157
|
|
92
158
|
def remove(self, entity: Optional[T] = None, id: Optional[Any] = None) -> bool:
|
159
|
+
"""
|
160
|
+
Поведение идентично твоему async-коду: soft-delete, иначе физическое удаление.
|
161
|
+
Коммиты намеренно не делаю, чтобы не менять твою транзакционную модель.
|
162
|
+
"""
|
93
163
|
if entity is None and id is None:
|
94
164
|
raise ValueError("remove() requires either entity or id")
|
95
165
|
|
@@ -104,8 +174,10 @@ class SARepository(Generic[T]):
|
|
104
174
|
entity = self.session.get(self.model, pk)
|
105
175
|
if entity is None:
|
106
176
|
return False
|
177
|
+
|
107
178
|
if hasattr(entity, "is_deleted"):
|
108
179
|
setattr(entity, "is_deleted", True)
|
180
|
+
self.session.flush()
|
109
181
|
else:
|
110
182
|
self.session.delete(entity)
|
111
183
|
|
@@ -115,14 +187,23 @@ class SARepository(Generic[T]):
|
|
115
187
|
obj = self.session.get(self.model, id_)
|
116
188
|
if not obj:
|
117
189
|
return False
|
118
|
-
self.remove(obj)
|
119
|
-
|
120
|
-
|
121
|
-
|
122
|
-
|
190
|
+
return self.remove(obj)
|
191
|
+
|
192
|
+
def page(
|
193
|
+
self,
|
194
|
+
page: PageRequest,
|
195
|
+
spec: Optional[Spec] = None,
|
196
|
+
order_by=None,
|
197
|
+
*,
|
198
|
+
include_deleted: bool = False,
|
199
|
+
) -> Page[T]: # type: ignore
|
200
|
+
"""
|
201
|
+
Точно повторяю логику подсчёта total через подзапрос.
|
202
|
+
Ожидается, что Page(items, total, page.page, page.size) уже есть в твоём коде.
|
203
|
+
"""
|
123
204
|
base = self._select()
|
124
205
|
if spec:
|
125
|
-
base = spec(base)
|
206
|
+
base = spec(base) # type: ignore
|
126
207
|
base = self._apply_alive_filter(base, include_deleted)
|
127
208
|
if order_by is not None:
|
128
209
|
base = base.order_by(order_by)
|
@@ -131,21 +212,22 @@ class SARepository(Generic[T]):
|
|
131
212
|
select(func.count()).select_from(base.subquery())
|
132
213
|
).scalar_one()
|
133
214
|
|
134
|
-
|
215
|
+
res = self.session.execute(
|
135
216
|
base.offset(page.page * page.size).limit(page.size)
|
136
|
-
)
|
137
|
-
|
217
|
+
)
|
218
|
+
items = res.scalars().all()
|
219
|
+
return Page(items, total, page.page, page.size) # type: ignore
|
138
220
|
|
139
221
|
def get_all_by_column(
|
140
|
-
|
141
|
-
|
142
|
-
|
143
|
-
|
144
|
-
|
145
|
-
|
146
|
-
|
147
|
-
|
148
|
-
) ->
|
222
|
+
self,
|
223
|
+
column_name: str,
|
224
|
+
value: Any,
|
225
|
+
*,
|
226
|
+
limit: Optional[int] = None,
|
227
|
+
order_by=None,
|
228
|
+
include_deleted: bool = False,
|
229
|
+
**extra_filters,
|
230
|
+
) -> List[T]:
|
149
231
|
col = self._resolve_column(column_name)
|
150
232
|
stmt = select(self.model).where(col == value)
|
151
233
|
stmt = self._apply_alive_filter(stmt, include_deleted)
|
@@ -157,25 +239,22 @@ class SARepository(Generic[T]):
|
|
157
239
|
res = self.session.execute(stmt)
|
158
240
|
return res.scalars().all()
|
159
241
|
|
160
|
-
# Alias
|
242
|
+
# Alias — как у тебя
|
161
243
|
def find_all_by_column(self, *args, **kwargs):
|
162
244
|
return self.get_all_by_column(*args, **kwargs)
|
163
245
|
|
164
246
|
def get_or_create(
|
165
|
-
|
166
|
-
|
167
|
-
|
168
|
-
) ->
|
169
|
-
"""
|
170
|
-
Возвращает (obj, created). unique_filters определяют уникальность.
|
171
|
-
defaults дополняют поля при создании.
|
172
|
-
"""
|
247
|
+
self,
|
248
|
+
defaults: Optional[dict] = None,
|
249
|
+
**unique_filters,
|
250
|
+
) -> Tuple[T, bool]:
|
173
251
|
stmt = select(self.model).filter_by(**unique_filters)
|
174
252
|
if hasattr(self.model, "is_deleted"):
|
175
253
|
stmt = stmt.where(self.model.is_deleted == False) # noqa: E712
|
176
254
|
obj = self.session.execute(stmt).scalar_one_or_none()
|
177
255
|
if obj:
|
178
256
|
return obj, False
|
257
|
+
|
179
258
|
payload = {**unique_filters, **(defaults or {})}
|
180
259
|
obj = self.model(**payload) # type: ignore[call-arg]
|
181
260
|
self.session.add(obj)
|
@@ -184,12 +263,8 @@ class SARepository(Generic[T]):
|
|
184
263
|
return obj, True
|
185
264
|
|
186
265
|
def raw_query(self, sql: str, params: Optional[dict] = None) -> list[dict]:
|
187
|
-
"""
|
188
|
-
Безопасно выполняет сырой SQL (используй плейсхолдеры :name).
|
189
|
-
Возвращает список dict (строки).
|
190
|
-
"""
|
191
266
|
res = self.session.execute(text(sql), params or {})
|
192
|
-
#
|
267
|
+
# В SQLA 2.x для маппингов:
|
193
268
|
return [dict(row) for row in res.mappings().all()]
|
194
269
|
|
195
270
|
def aggregate_avg(self, column_name: str, **filters) -> Optional[float]:
|
@@ -225,17 +300,15 @@ class SARepository(Generic[T]):
|
|
225
300
|
return self.session.execute(stmt).scalar()
|
226
301
|
|
227
302
|
def count(self, **filters) -> int:
|
303
|
+
include_deleted = bool(filters.pop("include_deleted", False))
|
228
304
|
stmt = select(func.count()).select_from(self.model)
|
229
|
-
if hasattr(self.model, "is_deleted") and not
|
305
|
+
if hasattr(self.model, "is_deleted") and not include_deleted:
|
230
306
|
stmt = stmt.where(self.model.is_deleted == False) # noqa: E712
|
231
307
|
if filters:
|
232
308
|
stmt = stmt.filter_by(**filters)
|
233
309
|
return int(self.session.execute(stmt).scalar_one())
|
234
310
|
|
235
311
|
def restore(self, id_: Any) -> bool:
|
236
|
-
"""
|
237
|
-
Для soft-delete: is_deleted=False. Возвращает True, если восстановили.
|
238
|
-
"""
|
239
312
|
if not hasattr(self.model, "is_deleted"):
|
240
313
|
raise RuntimeError(f"{self.model.__name__} has no 'is_deleted' field")
|
241
314
|
obj = self.session.get(self.model, id_)
|
@@ -266,6 +339,11 @@ class SAAsyncRepository(Generic[T]):
|
|
266
339
|
stmt = stmt.filter_by(**filters)
|
267
340
|
return stmt
|
268
341
|
|
342
|
+
def _to_dto(self, obj: Any) -> Any:
|
343
|
+
if hasattr(obj, "to_read_model"):
|
344
|
+
return obj.to_read_model()
|
345
|
+
return obj
|
346
|
+
|
269
347
|
def _select(self):
|
270
348
|
return select(self.model)
|
271
349
|
|
@@ -298,7 +376,7 @@ class SAAsyncRepository(Generic[T]):
|
|
298
376
|
result = await self.session.execute(stmt)
|
299
377
|
return result.scalars().all()
|
300
378
|
|
301
|
-
async def get(self, id_: Any = None, *, include_deleted: bool = False, **filters) -> T:
|
379
|
+
async def get(self, id_: Any = None, *, include_deleted: bool = False, **filters) -> Optional[T]:
|
302
380
|
"""
|
303
381
|
Получить один объект по id или по произвольным фильтрам.
|
304
382
|
Пример:
|
@@ -309,7 +387,7 @@ class SAAsyncRepository(Generic[T]):
|
|
309
387
|
if id_ is not None:
|
310
388
|
obj = await self.session.get(self.model, id_)
|
311
389
|
if not obj:
|
312
|
-
|
390
|
+
return None
|
313
391
|
else:
|
314
392
|
stmt = select(self.model)
|
315
393
|
stmt = self._apply_filters(stmt, **filters)
|
@@ -317,7 +395,7 @@ class SAAsyncRepository(Generic[T]):
|
|
317
395
|
res = await self.session.execute(stmt)
|
318
396
|
obj = res.scalars().first()
|
319
397
|
if not obj:
|
320
|
-
|
398
|
+
return None
|
321
399
|
if self._has_soft_delete() and not include_deleted and getattr(obj, "is_deleted", False):
|
322
400
|
raise NotFoundError(f"{self.model.__name__}({getattr(obj, 'id', '?')}) deleted")
|
323
401
|
return obj
|
@@ -331,16 +409,68 @@ class SAAsyncRepository(Generic[T]):
|
|
331
409
|
except NotFoundError:
|
332
410
|
return None
|
333
411
|
|
334
|
-
async def add(self,
|
335
|
-
|
336
|
-
|
337
|
-
|
338
|
-
|
412
|
+
async def add(self, data: dict) -> T:
|
413
|
+
stmt = (
|
414
|
+
insert(self.model)
|
415
|
+
.values(**data)
|
416
|
+
.returning(self.model.id)
|
417
|
+
)
|
418
|
+
res = await self.session.execute(stmt)
|
419
|
+
new_id = res.scalar_one()
|
420
|
+
await self.session.commit()
|
421
|
+
data["id"] = new_id
|
422
|
+
return data
|
339
423
|
|
340
|
-
async def update(
|
341
|
-
|
342
|
-
|
343
|
-
|
424
|
+
async def update(
|
425
|
+
self,
|
426
|
+
data: dict,
|
427
|
+
*,
|
428
|
+
include_deleted: bool = False,
|
429
|
+
expect_one: bool = False,
|
430
|
+
**filters,
|
431
|
+
) -> Optional[T]:
|
432
|
+
"""
|
433
|
+
Обновляет запись(и) по произвольным фильтрам.
|
434
|
+
- data: словарь обновляемых полей -> значений
|
435
|
+
- include_deleted: если модель имеет is_deleted, включать ли удалённые
|
436
|
+
- expect_one: если True — бросит ValueError, если затронуто != 1 строк
|
437
|
+
- **filters: произвольные фильтры вида field=value
|
438
|
+
Возвращает DTO одной обновлённой строки (если ровно одна) или None.
|
439
|
+
"""
|
440
|
+
if not data:
|
441
|
+
raise ValueError("`data` не может быть пустым.")
|
442
|
+
if not filters:
|
443
|
+
raise ValueError("Нужен хотя бы один фильтр (например, id=1 или username='foo').")
|
444
|
+
|
445
|
+
values = {self._resolve_column(k).key: v for k, v in data.items()}
|
446
|
+
|
447
|
+
conditions = [self._resolve_column(k) == v for k, v in filters.items()]
|
448
|
+
|
449
|
+
if self._has_soft_delete() and not include_deleted:
|
450
|
+
conditions.append(self.model.is_deleted == False)
|
451
|
+
|
452
|
+
stmt = (
|
453
|
+
update(self.model)
|
454
|
+
.where(and_(*conditions))
|
455
|
+
.values(**values)
|
456
|
+
.returning(self.model)
|
457
|
+
)
|
458
|
+
|
459
|
+
res = await self.session.execute(stmt)
|
460
|
+
updated_obj = res.scalar_one_or_none()
|
461
|
+
|
462
|
+
if expect_one:
|
463
|
+
rowcount = res.rowcount
|
464
|
+
if rowcount != 1:
|
465
|
+
await self.session.rollback()
|
466
|
+
raise ValueError(f"Ожидалась ровно 1 строка, затронуто: {rowcount}")
|
467
|
+
|
468
|
+
if updated_obj is not None:
|
469
|
+
await self.session.commit()
|
470
|
+
return self._to_dto(updated_obj)
|
471
|
+
|
472
|
+
await self.session.rollback()
|
473
|
+
return None
|
344
474
|
|
345
475
|
async def remove(self, entity: Optional[T] = None, id: Optional[Any] = None) -> bool:
|
346
476
|
if entity is None and id is None:
|
@@ -0,0 +1,157 @@
|
|
1
|
+
Metadata-Version: 2.4
|
2
|
+
Name: SARepo
|
3
|
+
Version: 0.1.8
|
4
|
+
Summary: Minimal, explicit Repository & Unit-of-Work layer over SQLAlchemy 2.x
|
5
|
+
Author: nurbergenovv
|
6
|
+
License: MIT
|
7
|
+
Requires-Python: >=3.11
|
8
|
+
Description-Content-Type: text/markdown
|
9
|
+
License-File: LICENSE
|
10
|
+
Requires-Dist: SQLAlchemy>=2.0
|
11
|
+
Dynamic: license-file
|
12
|
+
|
13
|
+
# SARepo
|
14
|
+
|
15
|
+
**Minimal, explicit Repository + Unit of Work layer on top of SQLAlchemy 2.x (sync & async).**
|
16
|
+
No magic method names — just clean typed APIs, composable specs, pagination, and optional soft-delete.
|
17
|
+
|
18
|
+
---
|
19
|
+
|
20
|
+
## 🚀 Quick Start (Sync)
|
21
|
+
|
22
|
+
```python
|
23
|
+
from sqlalchemy import create_engine, String
|
24
|
+
from sqlalchemy.orm import sessionmaker, DeclarativeBase, Mapped, mapped_column
|
25
|
+
|
26
|
+
from SARepo.sa_repo import SARepository
|
27
|
+
from SARepo.base import PageRequest
|
28
|
+
from SARepo.specs import and_specs, ilike
|
29
|
+
|
30
|
+
|
31
|
+
class Base(DeclarativeBase):
|
32
|
+
pass
|
33
|
+
|
34
|
+
|
35
|
+
class Request(Base):
|
36
|
+
__tablename__ = "requests"
|
37
|
+
|
38
|
+
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
|
39
|
+
title: Mapped[str] = mapped_column(String(255))
|
40
|
+
status: Mapped[str] = mapped_column(String(50))
|
41
|
+
|
42
|
+
|
43
|
+
# Setup database
|
44
|
+
engine = create_engine("sqlite+pysqlite:///:memory:", echo=False)
|
45
|
+
Base.metadata.create_all(engine)
|
46
|
+
SessionLocal = sessionmaker(bind=engine, expire_on_commit=False)
|
47
|
+
|
48
|
+
# Usage
|
49
|
+
with SessionLocal() as session:
|
50
|
+
repo = SARepository(Request, session)
|
51
|
+
|
52
|
+
# Create
|
53
|
+
r = repo.add(Request(title="Hello", status="NEW"))
|
54
|
+
session.commit()
|
55
|
+
|
56
|
+
# Query + Paginate
|
57
|
+
page = repo.page(PageRequest(0, 10), spec=ilike(Request.title, "%He%"))
|
58
|
+
print(page.total, [i.title for i in page.items])
|
59
|
+
```
|
60
|
+
|
61
|
+
---
|
62
|
+
|
63
|
+
## ⚙️ Async Quick Start
|
64
|
+
|
65
|
+
```python
|
66
|
+
import asyncio
|
67
|
+
from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker
|
68
|
+
|
69
|
+
from SARepo.sa_repo import SAAsyncRepository
|
70
|
+
from SARepo.base import PageRequest
|
71
|
+
|
72
|
+
|
73
|
+
async def main():
|
74
|
+
engine = create_async_engine("sqlite+aiosqlite:///:memory:", echo=False)
|
75
|
+
async with engine.begin() as conn:
|
76
|
+
await conn.run_sync(Base.metadata.create_all)
|
77
|
+
|
78
|
+
SessionLocal = async_sessionmaker(bind=engine, expire_on_commit=False)
|
79
|
+
|
80
|
+
async with SessionLocal() as session:
|
81
|
+
repo = SAAsyncRepository(Request, session)
|
82
|
+
|
83
|
+
await repo.add(Request(title="Hello", status="NEW"))
|
84
|
+
await session.commit()
|
85
|
+
|
86
|
+
page = await repo.page(PageRequest(0, 10))
|
87
|
+
print(page.total)
|
88
|
+
|
89
|
+
|
90
|
+
asyncio.run(main())
|
91
|
+
```
|
92
|
+
|
93
|
+
---
|
94
|
+
|
95
|
+
## 🧠 Features
|
96
|
+
|
97
|
+
✅ **SARepository / SAAsyncRepository**
|
98
|
+
- Clean CRUD operations (`get`, `add`, `update`, `remove`, etc.)
|
99
|
+
- `page()` method with total count
|
100
|
+
|
101
|
+
✅ **Composable Specs**
|
102
|
+
- Combine filters easily (`eq`, `ilike`, `and_specs`, `not_deleted`)
|
103
|
+
|
104
|
+
✅ **Soft Delete**
|
105
|
+
- Automatically filters out rows with `is_deleted=True` if the model has this column
|
106
|
+
|
107
|
+
✅ **Typed Protocol Interface**
|
108
|
+
- `CrudRepository` protocol for structural typing & IDE autocompletion
|
109
|
+
|
110
|
+
✅ **Unit of Work**
|
111
|
+
- Minimal sync & async helpers for transactional operations
|
112
|
+
|
113
|
+
---
|
114
|
+
|
115
|
+
## 🧩 Example: Spec Composition
|
116
|
+
|
117
|
+
```python
|
118
|
+
from SARepo.specs import eq, ilike, and_specs
|
119
|
+
|
120
|
+
spec = and_specs(
|
121
|
+
eq(Request.status, "NEW"),
|
122
|
+
ilike(Request.title, "%Hello%")
|
123
|
+
)
|
124
|
+
|
125
|
+
page = repo.page(PageRequest(0, 10), spec=spec)
|
126
|
+
```
|
127
|
+
|
128
|
+
---
|
129
|
+
|
130
|
+
## 📜 License
|
131
|
+
|
132
|
+
**MIT License**
|
133
|
+
Copyright (c) 2025
|
134
|
+
|
135
|
+
---
|
136
|
+
|
137
|
+
## 🧭 Project Structure (Example)
|
138
|
+
|
139
|
+
```
|
140
|
+
SARepo/
|
141
|
+
│
|
142
|
+
├── sa_repo.py # Core Repository classes (sync & async)
|
143
|
+
├── base.py # Pagination, PageRequest, DTO helpers
|
144
|
+
├── specs.py # Composable query specs
|
145
|
+
├── uow.py # Optional Unit of Work layer
|
146
|
+
├── __init__.py
|
147
|
+
```
|
148
|
+
|
149
|
+
---
|
150
|
+
|
151
|
+
### ✅ Philosophy
|
152
|
+
|
153
|
+
- No ORM “magic” — only explicit SQLAlchemy 2.0 APIs
|
154
|
+
- One repository → one model
|
155
|
+
- Predictable transaction scope
|
156
|
+
- Works with Pydantic DTOs or dataclasses
|
157
|
+
- 100 % type-safe
|
@@ -0,0 +1,12 @@
|
|
1
|
+
SARepo/__init__.py,sha256=tNYbuUDloC1qVnXq7uomS3jRcaPUvy0VEZiMdYR6x1M,183
|
2
|
+
SARepo/base.py,sha256=UbAdZ9WYh_o93mrCVL3D8Q0tY_8mvm0HspO_L5m0GTQ,874
|
3
|
+
SARepo/models.py,sha256=ypSmbKAijvNH4WjSeJBgyNT8mKa_e_7-I5kiNisjGAI,570
|
4
|
+
SARepo/repo.py,sha256=bjxK5gzSrMZcYHnj688UTL2KVbg_9t8bNT2hLfWWAa8,2821
|
5
|
+
SARepo/sa_repo.py,sha256=MY8RkXfrtahb9J96VXroq_OueSrixE02noxVzbc9UCc,24007
|
6
|
+
SARepo/specs.py,sha256=e-X1cCkva3e71M37hcfbjNDMezZAaOkkRaPToUzhEeU,687
|
7
|
+
SARepo/uow.py,sha256=yPlvi7PoH9x2pLNt6hEin9zT5qG9axUKn_TkZcIuz1s,859
|
8
|
+
sarepo-0.1.8.dist-info/licenses/LICENSE,sha256=px90NOQCrnZ77qoFT8IvAiNS1CXQ2OU27uVBKbki9DM,131
|
9
|
+
sarepo-0.1.8.dist-info/METADATA,sha256=CM2j9zHkVD4TaWFFDG3DypvuxlA3aOonNSNHxrJ-Z0Y,3768
|
10
|
+
sarepo-0.1.8.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
|
11
|
+
sarepo-0.1.8.dist-info/top_level.txt,sha256=0k952UYVZLIIv3kZzFlxI0yzBMusZo3-XCQtxms_kS0,7
|
12
|
+
sarepo-0.1.8.dist-info/RECORD,,
|
sarepo-0.1.7.dist-info/METADATA
DELETED
@@ -1,90 +0,0 @@
|
|
1
|
-
Metadata-Version: 2.4
|
2
|
-
Name: SARepo
|
3
|
-
Version: 0.1.7
|
4
|
-
Summary: Minimal, explicit Repository & Unit-of-Work layer over SQLAlchemy 2.x
|
5
|
-
Author: nurbergenovv
|
6
|
-
License: MIT
|
7
|
-
Requires-Python: >=3.11
|
8
|
-
Description-Content-Type: text/markdown
|
9
|
-
License-File: LICENSE
|
10
|
-
Requires-Dist: SQLAlchemy>=2.0
|
11
|
-
Dynamic: license-file
|
12
|
-
|
13
|
-
|
14
|
-
# SARepo
|
15
|
-
|
16
|
-
Minimal, explicit **Repository** + **Unit of Work** layer on top of **SQLAlchemy 2.x** (sync & async).
|
17
|
-
No magic method-names, just clean typed APIs, composable specs, pagination, and optional soft-delete.
|
18
|
-
|
19
|
-
## Install (editable)
|
20
|
-
|
21
|
-
```bash
|
22
|
-
pip install -e .
|
23
|
-
```
|
24
|
-
|
25
|
-
## Quick Start (sync)
|
26
|
-
|
27
|
-
```python
|
28
|
-
from sqlalchemy import create_engine
|
29
|
-
from sqlalchemy.orm import sessionmaker, Mapped, mapped_column
|
30
|
-
from sqlalchemy.orm import DeclarativeBase
|
31
|
-
from sqlalchemy import String
|
32
|
-
|
33
|
-
from SARepo.sa_repo import SARepository
|
34
|
-
from SARepo.base import PageRequest
|
35
|
-
from SARepo.specs import and_specs, ilike
|
36
|
-
|
37
|
-
class Base(DeclarativeBase): pass
|
38
|
-
|
39
|
-
class Request(Base):
|
40
|
-
__tablename__ = "requests"
|
41
|
-
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
|
42
|
-
title: Mapped[str] = mapped_column(String(255))
|
43
|
-
status: Mapped[str] = mapped_column(String(50))
|
44
|
-
|
45
|
-
engine = create_engine("sqlite+pysqlite:///:memory:", echo=False)
|
46
|
-
Base.metadata.create_all(engine)
|
47
|
-
SessionLocal = sessionmaker(bind=engine, expire_on_commit=False)
|
48
|
-
|
49
|
-
with SessionLocal() as session:
|
50
|
-
repo = SARepository(Request, session)
|
51
|
-
# create
|
52
|
-
r = repo.add(Request(title="Hello", status="NEW"))
|
53
|
-
session.commit()
|
54
|
-
|
55
|
-
# search + paginate
|
56
|
-
page = repo.page(PageRequest(0, 10), spec=ilike(Request.title, "%He%"))
|
57
|
-
print(page.total, [i.title for i in page.items])
|
58
|
-
```
|
59
|
-
|
60
|
-
## Async Quick Start
|
61
|
-
|
62
|
-
```python
|
63
|
-
import asyncio
|
64
|
-
from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker
|
65
|
-
from SARepo.sa_repo import SAAsyncRepository
|
66
|
-
from SARepo.base import PageRequest
|
67
|
-
|
68
|
-
async def main():
|
69
|
-
engine = create_async_engine("sqlite+aiosqlite:///:memory:", echo=False)
|
70
|
-
async with engine.begin() as conn:
|
71
|
-
await conn.run_sync(Base.metadata.create_all)
|
72
|
-
SessionLocal = async_sessionmaker(bind=engine, expire_on_commit=False)
|
73
|
-
async with SessionLocal() as session:
|
74
|
-
repo = SAAsyncRepository(Request, session)
|
75
|
-
await repo.add(Request(title="Hello", status="NEW"))
|
76
|
-
await session.commit()
|
77
|
-
page = await repo.page(PageRequest(0, 10))
|
78
|
-
print(page.total)
|
79
|
-
|
80
|
-
asyncio.run(main())
|
81
|
-
```
|
82
|
-
|
83
|
-
## Features
|
84
|
-
- `SARepository` and `SAAsyncRepository` (CRUD, `page()` with total count)
|
85
|
-
- Composable **specs** (`eq`, `ilike`, `and_specs`, `not_deleted`)
|
86
|
-
- Optional **soft-delete** if the entity has `is_deleted: bool`
|
87
|
-
- Minimal **Unit of Work** helpers (sync & async)
|
88
|
-
|
89
|
-
## License
|
90
|
-
MIT
|
sarepo-0.1.7.dist-info/RECORD
DELETED
@@ -1,12 +0,0 @@
|
|
1
|
-
SARepo/__init__.py,sha256=tNYbuUDloC1qVnXq7uomS3jRcaPUvy0VEZiMdYR6x1M,183
|
2
|
-
SARepo/base.py,sha256=UbAdZ9WYh_o93mrCVL3D8Q0tY_8mvm0HspO_L5m0GTQ,874
|
3
|
-
SARepo/models.py,sha256=ypSmbKAijvNH4WjSeJBgyNT8mKa_e_7-I5kiNisjGAI,570
|
4
|
-
SARepo/repo.py,sha256=2MsR21GFMYstXRimFVqShC9foG7KmAuzTFtqsjK3a-Q,2129
|
5
|
-
SARepo/sa_repo.py,sha256=fUae3B1aDxN3E-oKuthi0joWnrpCAOaUFHCi9H3be8c,19052
|
6
|
-
SARepo/specs.py,sha256=e-X1cCkva3e71M37hcfbjNDMezZAaOkkRaPToUzhEeU,687
|
7
|
-
SARepo/uow.py,sha256=yPlvi7PoH9x2pLNt6hEin9zT5qG9axUKn_TkZcIuz1s,859
|
8
|
-
sarepo-0.1.7.dist-info/licenses/LICENSE,sha256=px90NOQCrnZ77qoFT8IvAiNS1CXQ2OU27uVBKbki9DM,131
|
9
|
-
sarepo-0.1.7.dist-info/METADATA,sha256=b3pMl6G85wJ8qN1LtB5dc-mGwj0vmQXWBaL66x2WazY,2691
|
10
|
-
sarepo-0.1.7.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
|
11
|
-
sarepo-0.1.7.dist-info/top_level.txt,sha256=0k952UYVZLIIv3kZzFlxI0yzBMusZo3-XCQtxms_kS0,7
|
12
|
-
sarepo-0.1.7.dist-info/RECORD,,
|
File without changes
|
File without changes
|
File without changes
|