SARepo 0.1.6__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 CHANGED
@@ -1,50 +1,109 @@
1
+ from __future__ import annotations
1
2
 
2
- from typing import Generic, List, TypeVar, Type, Optional, Any, Protocol
3
+ from typing import Any, Generic, List, Optional, Protocol, Type, TypeVar, runtime_checkable
3
4
 
4
5
  from SARepo.sa_repo import Spec
5
6
  from .base import Page, PageRequest
6
7
 
7
8
  T = TypeVar("T")
8
9
 
10
+
11
+ @runtime_checkable
9
12
  class CrudRepository(Protocol, Generic[T]):
13
+ """
14
+ Контракт CRUD-репозитория для SQLAlchemy-подобной реализации.
15
+ Реализация должна соответствовать сигнатурам ниже.
16
+ """
17
+
10
18
  model: Type[T]
11
- def getAll(self, limit: Optional[int] = None, *, include_deleted: bool = False) -> List[T]: ...
12
- def get(self, id_: Any, *, include_deleted: bool = False) -> T: ...
13
- def try_get(self, id_: Any, *, include_deleted: bool = False) -> Optional[T]: ...
14
- def add(self, entity: T) -> T: ...
15
- def update(self, entity: T) -> T: ...
16
- def remove(self, entity: Optional[T] = None, id: Optional[Any] = None) -> bool: ...
17
- def delete_by_id(self, id_: Any) -> bool: ...
18
- def page(self, page: PageRequest, spec: Optional[Spec] = None, order_by=None, *, include_deleted: bool = False) -> Page[T]: ...
19
+
20
+
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
+ ...
62
+
19
63
  def get_all_by_column(
20
- self,
21
- column_name: str,
22
- value: Any,
23
- *,
24
- limit: Optional[int] = None,
25
- order_by=None,
26
- include_deleted: bool = False,
27
- **extra_filters
28
- ) -> 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
+ ...
74
+
29
75
  def find_all_by_column(
30
- self,
31
- column_name: str,
32
- value: Any,
33
- *,
34
- limit: Optional[int] = None,
35
- order_by=None,
36
- include_deleted: bool = False,
37
- **extra_filters
38
- ) -> list[T]: ...
39
- def get_or_create(
40
76
  self,
41
- defaults: Optional[dict] = None,
42
- **unique_filters
43
- ) -> tuple[T, bool]: ...
44
- def raw_query(self, sql: str, params: Optional[dict] = None) -> list[dict]: ...
45
- def aggregate_avg(self, column_name: str, **filters) -> Optional[float]: ...
46
- def aggregate_min(self, column_name: str, **filters): ...
47
- def aggregate_max(self, column_name: str, **filters): ...
48
- def aggregate_sum(self, column_name: str, **filters): ...
49
- def count(self, **filters) -> int: ...
50
- def restore(self, id_: Any) -> bool: ...
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
+ ...
86
+
87
+ def get_or_create(self, defaults: Optional[dict] = None, **unique_filters: Any) -> tuple[T, bool]:
88
+ ...
89
+
90
+ def raw_query(self, sql: str, params: Optional[dict] = None) -> list[dict]:
91
+ ...
92
+
93
+ def aggregate_avg(self, column_name: str, **filters: Any) -> Optional[float]:
94
+ ...
95
+
96
+ def aggregate_min(self, column_name: str, **filters: Any):
97
+ ...
98
+
99
+ def aggregate_max(self, column_name: str, **filters: Any):
100
+ ...
101
+
102
+ def aggregate_sum(self, column_name: str, **filters: Any):
103
+ ...
104
+
105
+ def count(self, **filters: Any) -> int:
106
+ ...
107
+
108
+ def restore(self, id_: Any) -> bool:
109
+ ...
SARepo/sa_repo.py CHANGED
@@ -1,19 +1,22 @@
1
-
2
- from typing import List, Type, Generic, TypeVar, Optional, Sequence, Any, Callable
1
+ from typing import List, Type, Generic, TypeVar, Optional, Sequence, Any, Callable, Tuple
3
2
  from sqlalchemy.orm import Session
4
3
  from sqlalchemy.ext.asyncio import AsyncSession
5
- from sqlalchemy import inspect, select, func, text
4
+ from sqlalchemy import inspect, select, func, text, insert, update, and_
6
5
  from .base import PageRequest, Page, NotFoundError
7
6
 
8
7
  T = TypeVar("T")
9
8
  Spec = Callable
10
9
 
10
+
11
11
  class SARepository(Generic[T]):
12
- """Synchronous repository implementation for SQLAlchemy 2.x."""
12
+ """Sync repository implementation for SQLAlchemy 2.x (ORM Session)."""
13
+
13
14
  def __init__(self, model: Type[T], session: Session):
14
15
  self.model = model
15
16
  self.session = session
16
17
 
18
+ # ---- helpers
19
+
17
20
  def _resolve_column(self, column_name: str):
18
21
  try:
19
22
  return getattr(self.model, column_name)
@@ -25,59 +28,144 @@ class SARepository(Generic[T]):
25
28
  stmt = stmt.filter_by(**filters)
26
29
  return stmt
27
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
+
28
36
  def _select(self):
29
37
  return select(self.model)
30
38
 
31
39
  def _has_soft_delete(self) -> bool:
32
40
  return hasattr(self.model, "is_deleted")
33
41
 
34
- def _apply_alive_filter(self, stmt, include_deleted: bool) :
42
+ def _apply_alive_filter(self, stmt, include_deleted: bool):
35
43
  if self._has_soft_delete() and not include_deleted:
36
44
  stmt = stmt.where(self.model.is_deleted == False) # noqa: E712
37
45
  return stmt
38
-
39
- def getAll(self, limit: Optional[int] = None, *, include_deleted: bool = False) -> List[T]:
46
+
47
+ # ---- CRUD/queries
48
+
49
+ def getAll(
50
+ self,
51
+ limit: Optional[int] = None,
52
+ *,
53
+ include_deleted: bool = False,
54
+ order_by=None,
55
+ **filters,
56
+ ) -> List[T]:
57
+ """
58
+ Получить все записи с возможностью фильтрации (username='foo', age__gt=20, и т.д.)
59
+ """
40
60
  stmt = select(self.model)
61
+ stmt = self._apply_filters(stmt, **filters)
41
62
  stmt = self._apply_alive_filter(stmt, include_deleted)
63
+ if order_by is not None:
64
+ stmt = stmt.order_by(order_by)
42
65
  if limit is not None:
43
66
  stmt = stmt.limit(limit)
44
67
  result = self.session.execute(stmt)
45
- return result.scalars().all()
46
-
47
- def get(self, id_: Any, *, include_deleted: bool = False) -> T:
48
- obj = self.session.get(self.model, id_)
49
- if not obj:
50
- raise NotFoundError(f"{self.model.__name__}({id_}) not found")
68
+ return result.scalars().all()
69
+
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
+ """
77
+ if id_ is not None:
78
+ obj = self.session.get(self.model, id_)
79
+ if not obj:
80
+ return None
81
+ else:
82
+ stmt = select(self.model)
83
+ stmt = self._apply_filters(stmt, **filters)
84
+ stmt = self._apply_alive_filter(stmt, include_deleted)
85
+ res = self.session.execute(stmt)
86
+ obj = res.scalars().first()
87
+ if not obj:
88
+ return None
89
+
51
90
  if self._has_soft_delete() and not include_deleted and getattr(obj, "is_deleted", False):
52
- raise NotFoundError(f"{self.model.__name__}({id_}) not found") # скрываем как «нет»
91
+ raise NotFoundError(f"{self.model.__name__}({getattr(obj, 'id', '?')}) deleted")
53
92
  return obj
54
93
 
55
- def try_get(self, id_: Any, *, include_deleted: bool = False) -> Optional[T]:
56
- obj = self.session.get(self.model, id_)
57
- if not obj:
58
- return None
59
- if self._has_soft_delete() and not include_deleted and getattr(obj, "is_deleted", False):
94
+ def try_get(self, id_: Any = None, *, include_deleted: bool = False, **filters) -> Optional[T]:
95
+ """Как get(), но не выбрасывает исключение при soft-deleted."""
96
+ try:
97
+ return self.get(id_=id_, include_deleted=include_deleted, **filters)
98
+ except NotFoundError:
60
99
  return None
61
- return obj
62
-
63
- def add(self, entity: T) -> T:
64
- self.session.add(entity)
65
- self.session.flush()
66
- self.session.refresh(entity)
67
- return entity
68
100
 
69
- def update(self, entity: T) -> T:
70
- self.session.flush()
71
- self.session.refresh(entity)
72
- return entity
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').")
129
+
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
73
157
 
74
158
  def remove(self, entity: Optional[T] = None, id: Optional[Any] = None) -> bool:
159
+ """
160
+ Поведение идентично твоему async-коду: soft-delete, иначе физическое удаление.
161
+ Коммиты намеренно не делаю, чтобы не менять твою транзакционную модель.
162
+ """
75
163
  if entity is None and id is None:
76
164
  raise ValueError("remove() requires either entity or id")
77
-
165
+
78
166
  if id is not None:
79
167
  return self._delete_by_id(id)
80
-
168
+
81
169
  insp = inspect(entity, raiseerr=False)
82
170
  if not (insp and (insp.persistent or insp.pending)):
83
171
  pk = getattr(entity, "id", None)
@@ -86,24 +174,36 @@ class SARepository(Generic[T]):
86
174
  entity = self.session.get(self.model, pk)
87
175
  if entity is None:
88
176
  return False
177
+
89
178
  if hasattr(entity, "is_deleted"):
90
179
  setattr(entity, "is_deleted", True)
180
+ self.session.flush()
91
181
  else:
92
182
  self.session.delete(entity)
93
-
183
+
94
184
  return True
95
185
 
96
186
  def _delete_by_id(self, id_: Any) -> bool:
97
187
  obj = self.session.get(self.model, id_)
98
188
  if not obj:
99
189
  return False
100
- self.remove(obj)
101
- return True
190
+ return self.remove(obj)
102
191
 
103
- def page(self, page: PageRequest, spec: Optional[Spec] = None, order_by=None, *, include_deleted: bool = False) -> Page[T]:
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
+ """
104
204
  base = self._select()
105
205
  if spec:
106
- base = spec(base)
206
+ base = spec(base) # type: ignore
107
207
  base = self._apply_alive_filter(base, include_deleted)
108
208
  if order_by is not None:
109
209
  base = base.order_by(order_by)
@@ -112,11 +212,12 @@ class SARepository(Generic[T]):
112
212
  select(func.count()).select_from(base.subquery())
113
213
  ).scalar_one()
114
214
 
115
- items = self.session.execute(
215
+ res = self.session.execute(
116
216
  base.offset(page.page * page.size).limit(page.size)
117
- ).scalars().all()
118
- return Page(items, total, page.page, page.size)
119
-
217
+ )
218
+ items = res.scalars().all()
219
+ return Page(items, total, page.page, page.size) # type: ignore
220
+
120
221
  def get_all_by_column(
121
222
  self,
122
223
  column_name: str,
@@ -125,8 +226,8 @@ class SARepository(Generic[T]):
125
226
  limit: Optional[int] = None,
126
227
  order_by=None,
127
228
  include_deleted: bool = False,
128
- **extra_filters
129
- ) -> list[T]:
229
+ **extra_filters,
230
+ ) -> List[T]:
130
231
  col = self._resolve_column(column_name)
131
232
  stmt = select(self.model).where(col == value)
132
233
  stmt = self._apply_alive_filter(stmt, include_deleted)
@@ -137,26 +238,23 @@ class SARepository(Generic[T]):
137
238
  stmt = stmt.limit(limit)
138
239
  res = self.session.execute(stmt)
139
240
  return res.scalars().all()
140
-
141
- # Alias
241
+
242
+ # Alias — как у тебя
142
243
  def find_all_by_column(self, *args, **kwargs):
143
244
  return self.get_all_by_column(*args, **kwargs)
144
-
245
+
145
246
  def get_or_create(
146
247
  self,
147
248
  defaults: Optional[dict] = None,
148
- **unique_filters
149
- ) -> tuple[T, bool]:
150
- """
151
- Возвращает (obj, created). unique_filters определяют уникальность.
152
- defaults дополняют поля при создании.
153
- """
249
+ **unique_filters,
250
+ ) -> Tuple[T, bool]:
154
251
  stmt = select(self.model).filter_by(**unique_filters)
155
252
  if hasattr(self.model, "is_deleted"):
156
253
  stmt = stmt.where(self.model.is_deleted == False) # noqa: E712
157
254
  obj = self.session.execute(stmt).scalar_one_or_none()
158
255
  if obj:
159
256
  return obj, False
257
+
160
258
  payload = {**unique_filters, **(defaults or {})}
161
259
  obj = self.model(**payload) # type: ignore[call-arg]
162
260
  self.session.add(obj)
@@ -165,12 +263,8 @@ class SARepository(Generic[T]):
165
263
  return obj, True
166
264
 
167
265
  def raw_query(self, sql: str, params: Optional[dict] = None) -> list[dict]:
168
- """
169
- Безопасно выполняет сырой SQL (используй плейсхолдеры :name).
170
- Возвращает список dict (строки).
171
- """
172
266
  res = self.session.execute(text(sql), params or {})
173
- # mapping() -> RowMapping (dict-like)
267
+ # В SQLA 2.x для маппингов:
174
268
  return [dict(row) for row in res.mappings().all()]
175
269
 
176
270
  def aggregate_avg(self, column_name: str, **filters) -> Optional[float]:
@@ -206,17 +300,15 @@ class SARepository(Generic[T]):
206
300
  return self.session.execute(stmt).scalar()
207
301
 
208
302
  def count(self, **filters) -> int:
303
+ include_deleted = bool(filters.pop("include_deleted", False))
209
304
  stmt = select(func.count()).select_from(self.model)
210
- if hasattr(self.model, "is_deleted") and not filters.pop("include_deleted", False):
305
+ if hasattr(self.model, "is_deleted") and not include_deleted:
211
306
  stmt = stmt.where(self.model.is_deleted == False) # noqa: E712
212
307
  if filters:
213
308
  stmt = stmt.filter_by(**filters)
214
309
  return int(self.session.execute(stmt).scalar_one())
215
310
 
216
311
  def restore(self, id_: Any) -> bool:
217
- """
218
- Для soft-delete: is_deleted=False. Возвращает True, если восстановили.
219
- """
220
312
  if not hasattr(self.model, "is_deleted"):
221
313
  raise RuntimeError(f"{self.model.__name__} has no 'is_deleted' field")
222
314
  obj = self.session.get(self.model, id_)
@@ -228,12 +320,14 @@ class SARepository(Generic[T]):
228
320
  return True
229
321
  return False
230
322
 
323
+
231
324
  class SAAsyncRepository(Generic[T]):
232
325
  """Async repository implementation for SQLAlchemy 2.x."""
326
+
233
327
  def __init__(self, model: Type[T], session: AsyncSession):
234
328
  self.model = model
235
329
  self.session = session
236
-
330
+
237
331
  def _resolve_column(self, column_name: str):
238
332
  try:
239
333
  return getattr(self.model, column_name)
@@ -244,60 +338,147 @@ class SAAsyncRepository(Generic[T]):
244
338
  if filters:
245
339
  stmt = stmt.filter_by(**filters)
246
340
  return stmt
247
-
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
+
248
347
  def _select(self):
249
348
  return select(self.model)
250
-
349
+
251
350
  def _has_soft_delete(self) -> bool:
252
351
  return hasattr(self.model, "is_deleted")
253
352
 
254
- def _apply_alive_filter(self, stmt, include_deleted: bool) :
353
+ def _apply_alive_filter(self, stmt, include_deleted: bool):
255
354
  if self._has_soft_delete() and not include_deleted:
256
355
  stmt = stmt.where(self.model.is_deleted == False) # noqa: E712
257
356
  return stmt
258
357
 
259
- async def getAll(self, limit: Optional[int] = None, *, include_deleted: bool = False) -> List[T]:
358
+ async def getAll(
359
+ self,
360
+ limit: Optional[int] = None,
361
+ *,
362
+ include_deleted: bool = False,
363
+ order_by=None,
364
+ **filters
365
+ ) -> List[T]:
366
+ """
367
+ Получить все записи с возможностью фильтрации (username='foo', age__gt=20, и т.д.)
368
+ """
260
369
  stmt = select(self.model)
370
+ stmt = self._apply_filters(stmt, **filters)
261
371
  stmt = self._apply_alive_filter(stmt, include_deleted)
372
+ if order_by is not None:
373
+ stmt = stmt.order_by(order_by)
262
374
  if limit is not None:
263
375
  stmt = stmt.limit(limit)
264
376
  result = await self.session.execute(stmt)
265
377
  return result.scalars().all()
266
378
 
267
- async def get(self, id_: Any, *, include_deleted: bool = False) -> T:
268
- obj = await self.session.get(self.model, id_)
269
- if not obj:
270
- raise NotFoundError(f"{self.model.__name__}({id_}) not found")
379
+ async def get(self, id_: Any = None, *, include_deleted: bool = False, **filters) -> Optional[T]:
380
+ """
381
+ Получить один объект по id или по произвольным фильтрам.
382
+ Пример:
383
+ await repo.get(id_=1)
384
+ await repo.get(username='ibrahim')
385
+ await repo.get(username__ilike='%rah%')
386
+ """
387
+ if id_ is not None:
388
+ obj = await self.session.get(self.model, id_)
389
+ if not obj:
390
+ return None
391
+ else:
392
+ stmt = select(self.model)
393
+ stmt = self._apply_filters(stmt, **filters)
394
+ stmt = self._apply_alive_filter(stmt, include_deleted)
395
+ res = await self.session.execute(stmt)
396
+ obj = res.scalars().first()
397
+ if not obj:
398
+ return None
271
399
  if self._has_soft_delete() and not include_deleted and getattr(obj, "is_deleted", False):
272
- raise NotFoundError(f"{self.model.__name__}({id_}) not found")
400
+ raise NotFoundError(f"{self.model.__name__}({getattr(obj, 'id', '?')}) deleted")
273
401
  return obj
274
402
 
275
- async def try_get(self, id_: Any, *, include_deleted: bool = False) -> Optional[T]:
276
- obj = await self.session.get(self.model, id_)
277
- if not obj:
278
- return None
279
- if self._has_soft_delete() and not include_deleted and getattr(obj, "is_deleted", False):
403
+ async def try_get(self, id_: Any = None, *, include_deleted: bool = False, **filters) -> Optional[T]:
404
+ """
405
+ Как get(), но не выбрасывает исключение при отсутствии объекта.
406
+ """
407
+ try:
408
+ return await self.get(id_=id_, include_deleted=include_deleted, **filters)
409
+ except NotFoundError:
280
410
  return None
281
- return obj
282
411
 
283
- async def add(self, entity: T) -> T:
284
- self.session.add(entity)
285
- await self.session.flush()
286
- await self.session.refresh(entity)
287
- return entity
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
423
+
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').")
288
444
 
289
- async def update(self, entity: T) -> T:
290
- await self.session.flush()
291
- await self.session.refresh(entity)
292
- return entity
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
293
474
 
294
475
  async def remove(self, entity: Optional[T] = None, id: Optional[Any] = None) -> bool:
295
476
  if entity is None and id is None:
296
477
  raise ValueError("remove() requires either entity or id")
297
-
478
+
298
479
  if id is not None:
299
480
  return await self._delete_by_id(id)
300
-
481
+
301
482
  insp = inspect(entity, raiseerr=False)
302
483
  if not (insp and (insp.persistent or insp.pending)):
303
484
  pk = getattr(entity, "id", None)
@@ -310,10 +491,9 @@ class SAAsyncRepository(Generic[T]):
310
491
  setattr(entity, "is_deleted", True)
311
492
  else:
312
493
  await self.session.delete(entity)
313
-
494
+
314
495
  return True
315
496
 
316
-
317
497
  async def _delete_by_id(self, id_: Any) -> bool:
318
498
  obj = await self.session.get(self.model, id_)
319
499
  if not obj:
@@ -321,7 +501,8 @@ class SAAsyncRepository(Generic[T]):
321
501
  await self.remove(obj)
322
502
  return True
323
503
 
324
- async def page(self, page: PageRequest, spec: Optional[Spec] = None, order_by=None, *, include_deleted: bool = False) -> Page[T]: # type: ignore
504
+ async def page(self, page: PageRequest, spec: Optional[Spec] = None, order_by=None, *,
505
+ include_deleted: bool = False) -> Page[T]: # type: ignore
325
506
  base = self._select()
326
507
  if spec:
327
508
  base = spec(base)
@@ -340,14 +521,14 @@ class SAAsyncRepository(Generic[T]):
340
521
  return Page(items, total, page.page, page.size)
341
522
 
342
523
  async def get_all_by_column(
343
- self,
344
- column_name: str,
345
- value: Any,
346
- *,
347
- limit: Optional[int] = None,
348
- order_by=None,
349
- include_deleted: bool = False,
350
- **extra_filters
524
+ self,
525
+ column_name: str,
526
+ value: Any,
527
+ *,
528
+ limit: Optional[int] = None,
529
+ order_by=None,
530
+ include_deleted: bool = False,
531
+ **extra_filters
351
532
  ) -> list[T]:
352
533
  col = self._resolve_column(column_name)
353
534
  stmt = select(self.model).where(col == value)
@@ -363,11 +544,11 @@ class SAAsyncRepository(Generic[T]):
363
544
  # Alias
364
545
  async def find_all_by_column(self, *args, **kwargs):
365
546
  return await self.get_all_by_column(*args, **kwargs)
366
-
547
+
367
548
  async def get_or_create(
368
- self,
369
- defaults: Optional[dict] = None,
370
- **unique_filters
549
+ self,
550
+ defaults: Optional[dict] = None,
551
+ **unique_filters
371
552
  ) -> tuple[T, bool]:
372
553
  stmt = select(self.model).filter_by(**unique_filters)
373
554
  if hasattr(self.model, "is_deleted"):
@@ -437,4 +618,4 @@ class SAAsyncRepository(Generic[T]):
437
618
  setattr(obj, "is_deleted", False)
438
619
  await self.session.flush()
439
620
  return True
440
- return False
621
+ return False
@@ -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,,
@@ -1,90 +0,0 @@
1
- Metadata-Version: 2.4
2
- Name: SARepo
3
- Version: 0.1.6
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
@@ -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=q99K9odCVuxJ9HIKwgVpu3p9-hyzxmGlE-wkiCp9tbg,1820
5
- SARepo/sa_repo.py,sha256=iPJan94ALJ3N58aU1D6rgO6uhqLDUxcHXvORpQrAt9c,17258
6
- SARepo/specs.py,sha256=e-X1cCkva3e71M37hcfbjNDMezZAaOkkRaPToUzhEeU,687
7
- SARepo/uow.py,sha256=yPlvi7PoH9x2pLNt6hEin9zT5qG9axUKn_TkZcIuz1s,859
8
- sarepo-0.1.6.dist-info/licenses/LICENSE,sha256=px90NOQCrnZ77qoFT8IvAiNS1CXQ2OU27uVBKbki9DM,131
9
- sarepo-0.1.6.dist-info/METADATA,sha256=4Yfl0Eh0h8Sb2Lrs_DxZRfPJuZzGTg7-I9blRPva9aw,2691
10
- sarepo-0.1.6.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
11
- sarepo-0.1.6.dist-info/top_level.txt,sha256=0k952UYVZLIIv3kZzFlxI0yzBMusZo3-XCQtxms_kS0,7
12
- sarepo-0.1.6.dist-info/RECORD,,
File without changes