SARepo 0.1.3__py3-none-any.whl → 0.1.5__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,16 +1,50 @@
1
1
 
2
2
  from typing import Generic, List, TypeVar, Type, Optional, Any, Protocol
3
+
4
+ from SARepo.sa_repo import Spec
3
5
  from .base import Page, PageRequest
4
6
 
5
7
  T = TypeVar("T")
6
8
 
7
9
  class CrudRepository(Protocol, Generic[T]):
8
10
  model: Type[T]
9
- def getAll(self, limit: Optional[int]) -> List[T]: ...
10
- def get(self, id_: Any) -> T: ...
11
- def try_get(self, id_: Any) -> Optional[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]: ...
12
14
  def add(self, entity: T) -> T: ...
13
15
  def update(self, entity: T) -> T: ...
14
16
  def remove(self, entity: T) -> None: ...
15
17
  def delete_by_id(self, id_: Any) -> bool: ...
16
- def page(self, page: PageRequest, spec=None, order_by=None) -> Page[T]: ...
18
+ def page(self, page: PageRequest, spec: Optional[Spec] = None, order_by=None, *, include_deleted: bool = False) -> Page[T]: ...
19
+ 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]: ...
29
+ 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
+ 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: ...
SARepo/sa_repo.py CHANGED
@@ -2,11 +2,11 @@
2
2
  from typing import List, Type, Generic, TypeVar, Optional, Sequence, Any, Callable
3
3
  from sqlalchemy.orm import Session
4
4
  from sqlalchemy.ext.asyncio import AsyncSession
5
- from sqlalchemy import inspect, select, func
5
+ from sqlalchemy import inspect, select, func, text
6
6
  from .base import PageRequest, Page, NotFoundError
7
7
 
8
8
  T = TypeVar("T")
9
- Spec = Callable # aliased to match specs.Spec
9
+ Spec = Callable
10
10
 
11
11
  class SARepository(Generic[T]):
12
12
  """Synchronous repository implementation for SQLAlchemy 2.x."""
@@ -14,25 +14,52 @@ class SARepository(Generic[T]):
14
14
  self.model = model
15
15
  self.session = session
16
16
 
17
+ def _resolve_column(self, column_name: str):
18
+ try:
19
+ return getattr(self.model, column_name)
20
+ except AttributeError as e:
21
+ raise ValueError(f"Model {self.model.__name__} has no column '{column_name}'") from e
22
+
23
+ def _apply_filters(self, stmt, **filters):
24
+ if filters:
25
+ stmt = stmt.filter_by(**filters)
26
+ return stmt
27
+
17
28
  def _select(self):
18
29
  return select(self.model)
19
30
 
20
- def getAll(self, limit: Optional[int] = None) -> List[T]:
31
+ def _has_soft_delete(self) -> bool:
32
+ return hasattr(self.model, "is_deleted")
33
+
34
+ def _apply_alive_filter(self, stmt, include_deleted: bool) :
35
+ if self._has_soft_delete() and not include_deleted:
36
+ stmt = stmt.where(self.model.is_deleted == False) # noqa: E712
37
+ return stmt
38
+
39
+ def getAll(self, limit: Optional[int] = None, *, include_deleted: bool = False) -> List[T]:
21
40
  stmt = select(self.model)
41
+ stmt = self._apply_alive_filter(stmt, include_deleted)
22
42
  if limit is not None:
23
43
  stmt = stmt.limit(limit)
24
44
  result = self.session.execute(stmt)
25
- return result.scalars().all()
45
+ return result.scalars().all()
26
46
 
27
- def get(self, id_: Any) -> T:
47
+ def get(self, id_: Any, *, include_deleted: bool = False) -> T:
28
48
  obj = self.session.get(self.model, id_)
29
49
  if not obj:
30
50
  raise NotFoundError(f"{self.model.__name__}({id_}) not found")
51
+ 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") # скрываем как «нет»
31
53
  return obj
32
54
 
33
- def try_get(self, id_: Any) -> Optional[T]:
34
- return self.session.get(self.model, id_)
35
-
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):
60
+ return None
61
+ return obj
62
+
36
63
  def add(self, entity: T) -> T:
37
64
  self.session.add(entity)
38
65
  self.session.flush()
@@ -65,44 +92,185 @@ class SARepository(Generic[T]):
65
92
  self.remove(obj)
66
93
  return True
67
94
 
68
- def page(self, page: PageRequest, spec: Optional[Spec] = None, order_by=None) -> Page[T]:
69
- stmt = self._select()
95
+ def page(self, page: PageRequest, spec: Optional[Spec] = None, order_by=None, *, include_deleted: bool = False) -> Page[T]:
96
+ base = self._select()
70
97
  if spec:
71
- stmt = spec(stmt)
98
+ base = spec(base)
99
+ base = self._apply_alive_filter(base, include_deleted)
72
100
  if order_by is not None:
73
- stmt = stmt.order_by(order_by)
101
+ base = base.order_by(order_by)
102
+
74
103
  total = self.session.execute(
75
- select(func.count()).select_from(stmt.subquery())
104
+ select(func.count()).select_from(base.subquery())
76
105
  ).scalar_one()
106
+
77
107
  items = self.session.execute(
78
- stmt.offset(page.page * page.size).limit(page.size)
108
+ base.offset(page.page * page.size).limit(page.size)
79
109
  ).scalars().all()
80
110
  return Page(items, total, page.page, page.size)
111
+
112
+ def get_all_by_column(
113
+ self,
114
+ column_name: str,
115
+ value: Any,
116
+ *,
117
+ limit: Optional[int] = None,
118
+ order_by=None,
119
+ include_deleted: bool = False,
120
+ **extra_filters
121
+ ) -> list[T]:
122
+ col = self._resolve_column(column_name)
123
+ stmt = select(self.model).where(col == value)
124
+ stmt = self._apply_alive_filter(stmt, include_deleted)
125
+ stmt = self._apply_filters(stmt, **extra_filters)
126
+ if order_by is not None:
127
+ stmt = stmt.order_by(order_by)
128
+ if limit is not None:
129
+ stmt = stmt.limit(limit)
130
+ res = self.session.execute(stmt)
131
+ return res.scalars().all()
132
+
133
+ # Alias
134
+ def find_all_by_column(self, *args, **kwargs):
135
+ return self.get_all_by_column(*args, **kwargs)
136
+
137
+ def get_or_create(
138
+ self,
139
+ defaults: Optional[dict] = None,
140
+ **unique_filters
141
+ ) -> tuple[T, bool]:
142
+ """
143
+ Возвращает (obj, created). unique_filters определяют уникальность.
144
+ defaults дополняют поля при создании.
145
+ """
146
+ stmt = select(self.model).filter_by(**unique_filters)
147
+ if hasattr(self.model, "is_deleted"):
148
+ stmt = stmt.where(self.model.is_deleted == False) # noqa: E712
149
+ obj = self.session.execute(stmt).scalar_one_or_none()
150
+ if obj:
151
+ return obj, False
152
+ payload = {**unique_filters, **(defaults or {})}
153
+ obj = self.model(**payload) # type: ignore[call-arg]
154
+ self.session.add(obj)
155
+ self.session.flush()
156
+ self.session.refresh(obj)
157
+ return obj, True
158
+
159
+ def raw_query(self, sql: str, params: Optional[dict] = None) -> list[dict]:
160
+ """
161
+ Безопасно выполняет сырой SQL (используй плейсхолдеры :name).
162
+ Возвращает список dict (строки).
163
+ """
164
+ res = self.session.execute(text(sql), params or {})
165
+ # mapping() -> RowMapping (dict-like)
166
+ return [dict(row) for row in res.mappings().all()]
167
+
168
+ def aggregate_avg(self, column_name: str, **filters) -> Optional[float]:
169
+ col = self._resolve_column(column_name)
170
+ stmt = select(func.avg(col))
171
+ if hasattr(self.model, "is_deleted"):
172
+ stmt = stmt.where(self.model.is_deleted == False) # noqa: E712
173
+ stmt = self._apply_filters(stmt, **filters)
174
+ return self.session.execute(stmt).scalar()
175
+
176
+ def aggregate_min(self, column_name: str, **filters):
177
+ col = self._resolve_column(column_name)
178
+ stmt = select(func.min(col))
179
+ if hasattr(self.model, "is_deleted"):
180
+ stmt = stmt.where(self.model.is_deleted == False)
181
+ stmt = self._apply_filters(stmt, **filters)
182
+ return self.session.execute(stmt).scalar()
183
+
184
+ def aggregate_max(self, column_name: str, **filters):
185
+ col = self._resolve_column(column_name)
186
+ stmt = select(func.max(col))
187
+ if hasattr(self.model, "is_deleted"):
188
+ stmt = stmt.where(self.model.is_deleted == False)
189
+ stmt = self._apply_filters(stmt, **filters)
190
+ return self.session.execute(stmt).scalar()
191
+
192
+ def aggregate_sum(self, column_name: str, **filters):
193
+ col = self._resolve_column(column_name)
194
+ stmt = select(func.sum(col))
195
+ if hasattr(self.model, "is_deleted"):
196
+ stmt = stmt.where(self.model.is_deleted == False)
197
+ stmt = self._apply_filters(stmt, **filters)
198
+ return self.session.execute(stmt).scalar()
199
+
200
+ def count(self, **filters) -> int:
201
+ stmt = select(func.count()).select_from(self.model)
202
+ if hasattr(self.model, "is_deleted") and not filters.pop("include_deleted", False):
203
+ stmt = stmt.where(self.model.is_deleted == False) # noqa: E712
204
+ if filters:
205
+ stmt = stmt.filter_by(**filters)
206
+ return int(self.session.execute(stmt).scalar_one())
207
+
208
+ def restore(self, id_: Any) -> bool:
209
+ """
210
+ Для soft-delete: is_deleted=False. Возвращает True, если восстановили.
211
+ """
212
+ if not hasattr(self.model, "is_deleted"):
213
+ raise RuntimeError(f"{self.model.__name__} has no 'is_deleted' field")
214
+ obj = self.session.get(self.model, id_)
215
+ if not obj:
216
+ return False
217
+ if getattr(obj, "is_deleted", False):
218
+ setattr(obj, "is_deleted", False)
219
+ self.session.flush()
220
+ return True
221
+ return False
81
222
 
82
223
  class SAAsyncRepository(Generic[T]):
83
224
  """Async repository implementation for SQLAlchemy 2.x."""
84
225
  def __init__(self, model: Type[T], session: AsyncSession):
85
226
  self.model = model
86
227
  self.session = session
87
-
88
- async def getAll(self, limit: Optional[int] = None) -> List[T]:
228
+
229
+ def _resolve_column(self, column_name: str):
230
+ try:
231
+ return getattr(self.model, column_name)
232
+ except AttributeError as e:
233
+ raise ValueError(f"Model {self.model.__name__} has no column '{column_name}'") from e
234
+
235
+ def _apply_filters(self, stmt, **filters):
236
+ if filters:
237
+ stmt = stmt.filter_by(**filters)
238
+ return stmt
239
+
240
+ def _select(self):
241
+ return select(self.model)
242
+
243
+ def _has_soft_delete(self) -> bool:
244
+ return hasattr(self.model, "is_deleted")
245
+
246
+ def _apply_alive_filter(self, stmt, include_deleted: bool) :
247
+ if self._has_soft_delete() and not include_deleted:
248
+ stmt = stmt.where(self.model.is_deleted == False) # noqa: E712
249
+ return stmt
250
+
251
+ async def getAll(self, limit: Optional[int] = None, *, include_deleted: bool = False) -> List[T]:
89
252
  stmt = select(self.model)
253
+ stmt = self._apply_alive_filter(stmt, include_deleted)
90
254
  if limit is not None:
91
255
  stmt = stmt.limit(limit)
92
256
  result = await self.session.execute(stmt)
93
257
  return result.scalars().all()
94
258
 
95
- def _select(self):
96
- return select(self.model)
97
-
98
- async def get(self, id_: Any) -> T:
259
+ async def get(self, id_: Any, *, include_deleted: bool = False) -> T:
99
260
  obj = await self.session.get(self.model, id_)
100
261
  if not obj:
101
262
  raise NotFoundError(f"{self.model.__name__}({id_}) not found")
263
+ if self._has_soft_delete() and not include_deleted and getattr(obj, "is_deleted", False):
264
+ raise NotFoundError(f"{self.model.__name__}({id_}) not found")
102
265
  return obj
103
266
 
104
- async def try_get(self, id_: Any) -> Optional[T]:
105
- return await self.session.get(self.model, id_)
267
+ async def try_get(self, id_: Any, *, include_deleted: bool = False) -> Optional[T]:
268
+ obj = await self.session.get(self.model, id_)
269
+ if not obj:
270
+ return None
271
+ if self._has_soft_delete() and not include_deleted and getattr(obj, "is_deleted", False):
272
+ return None
273
+ return obj
106
274
 
107
275
  async def add(self, entity: T) -> T:
108
276
  self.session.add(entity)
@@ -136,17 +304,120 @@ class SAAsyncRepository(Generic[T]):
136
304
  await self.remove(obj)
137
305
  return True
138
306
 
139
- async def page(self, page: PageRequest, spec: Optional[Spec] = None, order_by=None) -> Page[T]:
140
- stmt = self._select()
307
+ async def page(self, page: PageRequest, spec: Optional[Spec] = None, order_by=None, *, include_deleted: bool = False) -> Page[T]: # type: ignore
308
+ base = self._select()
141
309
  if spec:
142
- stmt = spec(stmt)
310
+ base = spec(base)
311
+ base = self._apply_alive_filter(base, include_deleted)
143
312
  if order_by is not None:
144
- stmt = stmt.order_by(order_by)
313
+ base = base.order_by(order_by)
314
+
145
315
  total = (await self.session.execute(
146
- select(func.count()).select_from(stmt.subquery())
316
+ select(func.count()).select_from(base.subquery())
147
317
  )).scalar_one()
318
+
148
319
  res = await self.session.execute(
149
- stmt.offset(page.page * page.size).limit(page.size)
320
+ base.offset(page.page * page.size).limit(page.size)
150
321
  )
151
322
  items = res.scalars().all()
152
323
  return Page(items, total, page.page, page.size)
324
+
325
+ async def get_all_by_column(
326
+ self,
327
+ column_name: str,
328
+ value: Any,
329
+ *,
330
+ limit: Optional[int] = None,
331
+ order_by=None,
332
+ include_deleted: bool = False,
333
+ **extra_filters
334
+ ) -> list[T]:
335
+ col = self._resolve_column(column_name)
336
+ stmt = select(self.model).where(col == value)
337
+ stmt = self._apply_alive_filter(stmt, include_deleted)
338
+ stmt = self._apply_filters(stmt, **extra_filters)
339
+ if order_by is not None:
340
+ stmt = stmt.order_by(order_by)
341
+ if limit is not None:
342
+ stmt = stmt.limit(limit)
343
+ res = await self.session.execute(stmt)
344
+ return res.scalars().all()
345
+
346
+ # Alias
347
+ async def find_all_by_column(self, *args, **kwargs):
348
+ return await self.get_all_by_column(*args, **kwargs)
349
+
350
+ async def get_or_create(
351
+ self,
352
+ defaults: Optional[dict] = None,
353
+ **unique_filters
354
+ ) -> tuple[T, bool]:
355
+ stmt = select(self.model).filter_by(**unique_filters)
356
+ if hasattr(self.model, "is_deleted"):
357
+ stmt = stmt.where(self.model.is_deleted == False) # noqa: E712
358
+ obj = (await self.session.execute(stmt)).scalar_one_or_none()
359
+ if obj:
360
+ return obj, False
361
+ payload = {**unique_filters, **(defaults or {})}
362
+ obj = self.model(**payload) # type: ignore[call-arg]
363
+ self.session.add(obj)
364
+ await self.session.flush()
365
+ await self.session.refresh(obj)
366
+ return obj, True
367
+
368
+ async def raw_query(self, sql: str, params: Optional[dict] = None) -> list[dict]:
369
+ res = await self.session.execute(text(sql), params or {})
370
+ return [dict(row) for row in res.mappings().all()]
371
+
372
+ async def aggregate_avg(self, column_name: str, **filters) -> Optional[float]:
373
+ col = self._resolve_column(column_name)
374
+ stmt = select(func.avg(col))
375
+ if hasattr(self.model, "is_deleted"):
376
+ stmt = stmt.where(self.model.is_deleted == False) # noqa: E712
377
+ stmt = self._apply_filters(stmt, **filters)
378
+ return (await self.session.execute(stmt)).scalar()
379
+
380
+ async def aggregate_min(self, column_name: str, **filters):
381
+ col = self._resolve_column(column_name)
382
+ stmt = select(func.min(col))
383
+ if hasattr(self.model, "is_deleted"):
384
+ stmt = stmt.where(self.model.is_deleted == False)
385
+ stmt = self._apply_filters(stmt, **filters)
386
+ return (await self.session.execute(stmt)).scalar()
387
+
388
+ async def aggregate_max(self, column_name: str, **filters):
389
+ col = self._resolve_column(column_name)
390
+ stmt = select(func.max(col))
391
+ if hasattr(self.model, "is_deleted"):
392
+ stmt = stmt.where(self.model.is_deleted == False)
393
+ stmt = self._apply_filters(stmt, **filters)
394
+ return (await self.session.execute(stmt)).scalar()
395
+
396
+ async def aggregate_sum(self, column_name: str, **filters):
397
+ col = self._resolve_column(column_name)
398
+ stmt = select(func.sum(col))
399
+ if hasattr(self.model, "is_deleted"):
400
+ stmt = stmt.where(self.model.is_deleted == False)
401
+ stmt = self._apply_filters(stmt, **filters)
402
+ return (await self.session.execute(stmt)).scalar()
403
+
404
+ async def count(self, **filters) -> int:
405
+ include_deleted = bool(filters.pop("include_deleted", False))
406
+ stmt = select(func.count()).select_from(self.model)
407
+ if hasattr(self.model, "is_deleted") and not include_deleted:
408
+ stmt = stmt.where(self.model.is_deleted == False) # noqa: E712
409
+ if filters:
410
+ stmt = stmt.filter_by(**filters)
411
+ return int((await self.session.execute(stmt)).scalar_one())
412
+
413
+ async def restore(self, id_: Any) -> bool:
414
+ if not hasattr(self.model, "is_deleted"):
415
+ raise RuntimeError(f"{self.model.__name__} has no 'is_deleted' field")
416
+ obj = await self.session.get(self.model, id_)
417
+ if not obj:
418
+ return False
419
+ if getattr(obj, "is_deleted", False):
420
+ setattr(obj, "is_deleted", False)
421
+ await self.session.flush()
422
+ return True
423
+ return False
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: SARepo
3
- Version: 0.1.3
3
+ Version: 0.1.5
4
4
  Summary: Minimal, explicit Repository & Unit-of-Work layer over SQLAlchemy 2.x
5
5
  Author: nurbergenovv
6
6
  License: MIT
@@ -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=N_LaqguNE3Y_9vZBPBvqQreu-6Sy9-BZFR3-pZl5eT4,1777
5
+ SARepo/sa_repo.py,sha256=iIscSTNoZGb8XMTY66_fiooWPVwGMOMYAL9BC_xS7bI,16691
6
+ SARepo/specs.py,sha256=e-X1cCkva3e71M37hcfbjNDMezZAaOkkRaPToUzhEeU,687
7
+ SARepo/uow.py,sha256=yPlvi7PoH9x2pLNt6hEin9zT5qG9axUKn_TkZcIuz1s,859
8
+ sarepo-0.1.5.dist-info/licenses/LICENSE,sha256=px90NOQCrnZ77qoFT8IvAiNS1CXQ2OU27uVBKbki9DM,131
9
+ sarepo-0.1.5.dist-info/METADATA,sha256=m9z_QHD-D1dN95nAGWvpFGAUT7hCad990Lb-nNS5two,2691
10
+ sarepo-0.1.5.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
11
+ sarepo-0.1.5.dist-info/top_level.txt,sha256=0k952UYVZLIIv3kZzFlxI0yzBMusZo3-XCQtxms_kS0,7
12
+ sarepo-0.1.5.dist-info/RECORD,,
@@ -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=5Af4SLfbKOMJdX7MOev4OcGIGQ0HzN6QDUktURjasyU,597
5
- SARepo/sa_repo.py,sha256=feuwBHAEdW_STJ6fHxYlSO0KvSruoqiB_v1sTCRy6JE,5332
6
- SARepo/specs.py,sha256=e-X1cCkva3e71M37hcfbjNDMezZAaOkkRaPToUzhEeU,687
7
- SARepo/uow.py,sha256=yPlvi7PoH9x2pLNt6hEin9zT5qG9axUKn_TkZcIuz1s,859
8
- sarepo-0.1.3.dist-info/licenses/LICENSE,sha256=px90NOQCrnZ77qoFT8IvAiNS1CXQ2OU27uVBKbki9DM,131
9
- sarepo-0.1.3.dist-info/METADATA,sha256=FXf9N-SsghCIQGucviE8GVVxh1viF0BV0FhKkMeg2HQ,2691
10
- sarepo-0.1.3.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
11
- sarepo-0.1.3.dist-info/top_level.txt,sha256=0k952UYVZLIIv3kZzFlxI0yzBMusZo3-XCQtxms_kS0,7
12
- sarepo-0.1.3.dist-info/RECORD,,
File without changes