SARepo 0.1.2__py3-none-any.whl → 0.1.4__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
@@ -12,4 +12,37 @@ class CrudRepository(Protocol, Generic[T]):
12
12
  def add(self, entity: T) -> T: ...
13
13
  def update(self, entity: T) -> T: ...
14
14
  def remove(self, entity: T) -> None: ...
15
+ def delete_by_id(self, id_: Any) -> bool: ...
15
16
  def page(self, page: PageRequest, spec=None, order_by=None) -> Page[T]: ...
17
+ def get_all_by_column(
18
+ self,
19
+ column_name: str,
20
+ value: Any,
21
+ *,
22
+ limit: Optional[int] = None,
23
+ order_by=None,
24
+ include_deleted: bool = False,
25
+ **extra_filters
26
+ ) -> list[T]: ...
27
+ def find_all_by_column(
28
+ self,
29
+ column_name: str,
30
+ value: Any,
31
+ *,
32
+ limit: Optional[int] = None,
33
+ order_by=None,
34
+ include_deleted: bool = False,
35
+ **extra_filters
36
+ ) -> list[T]: ...
37
+ def get_or_create(
38
+ self,
39
+ defaults: Optional[dict] = None,
40
+ **unique_filters
41
+ ) -> tuple[T, bool]: ...
42
+ def raw_query(self, sql: str, params: Optional[dict] = None) -> list[dict]: ...
43
+ def aggregate_avg(self, column_name: str, **filters) -> Optional[float]: ...
44
+ def aggregate_min(self, column_name: str, **filters): ...
45
+ def aggregate_max(self, column_name: str, **filters): ...
46
+ def aggregate_sum(self, column_name: str, **filters): ...
47
+ def count(self, **filters) -> int: ...
48
+ 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 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,6 +14,17 @@ 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
 
@@ -45,11 +56,26 @@ class SARepository(Generic[T]):
45
56
  return entity
46
57
 
47
58
  def remove(self, entity: T) -> None:
59
+ insp = inspect(entity, raiseerr=False)
60
+ if not (insp and (insp.persistent or insp.pending)):
61
+ pk = getattr(entity, "id", None)
62
+ if pk is None:
63
+ raise ValueError("remove() needs a persistent entity or an entity with a primary key set")
64
+ entity = self.session.get(self.model, pk)
65
+ if entity is None:
66
+ return
48
67
  if hasattr(entity, "is_deleted"):
49
68
  setattr(entity, "is_deleted", True)
50
69
  else:
51
70
  self.session.delete(entity)
52
71
 
72
+ def delete_by_id(self, id_: Any) -> bool:
73
+ obj = self.session.get(self.model, id_)
74
+ if not obj:
75
+ return False
76
+ self.remove(obj)
77
+ return True
78
+
53
79
  def page(self, page: PageRequest, spec: Optional[Spec] = None, order_by=None) -> Page[T]:
54
80
  stmt = self._select()
55
81
  if spec:
@@ -63,13 +89,139 @@ class SARepository(Generic[T]):
63
89
  stmt.offset(page.page * page.size).limit(page.size)
64
90
  ).scalars().all()
65
91
  return Page(items, total, page.page, page.size)
92
+
93
+ def get_all_by_column(
94
+ self,
95
+ column_name: str,
96
+ value: Any,
97
+ *,
98
+ limit: Optional[int] = None,
99
+ order_by=None,
100
+ include_deleted: bool = False,
101
+ **extra_filters
102
+ ) -> list[T]:
103
+ col = self._resolve_column(column_name)
104
+ stmt = select(self.model).where(col == value)
105
+ if not include_deleted and hasattr(self.model, "is_deleted"):
106
+ stmt = stmt.where(self.model.is_deleted == False) # noqa: E712
107
+ stmt = self._apply_filters(stmt, **extra_filters)
108
+ if order_by is not None:
109
+ stmt = stmt.order_by(order_by)
110
+ if limit is not None:
111
+ stmt = stmt.limit(limit)
112
+ res = self.session.execute(stmt)
113
+ return res.scalars().all()
114
+
115
+ # Alias
116
+ def find_all_by_column(self, *args, **kwargs):
117
+ return self.get_all_by_column(*args, **kwargs)
118
+
119
+ def get_or_create(
120
+ self,
121
+ defaults: Optional[dict] = None,
122
+ **unique_filters
123
+ ) -> tuple[T, bool]:
124
+ """
125
+ Возвращает (obj, created). unique_filters определяют уникальность.
126
+ defaults дополняют поля при создании.
127
+ """
128
+ stmt = select(self.model).filter_by(**unique_filters)
129
+ if hasattr(self.model, "is_deleted"):
130
+ stmt = stmt.where(self.model.is_deleted == False) # noqa: E712
131
+ obj = self.session.execute(stmt).scalar_one_or_none()
132
+ if obj:
133
+ return obj, False
134
+ payload = {**unique_filters, **(defaults or {})}
135
+ obj = self.model(**payload) # type: ignore[call-arg]
136
+ self.session.add(obj)
137
+ self.session.flush()
138
+ self.session.refresh(obj)
139
+ return obj, True
140
+
141
+ def raw_query(self, sql: str, params: Optional[dict] = None) -> list[dict]:
142
+ """
143
+ Безопасно выполняет сырой SQL (используй плейсхолдеры :name).
144
+ Возвращает список dict (строки).
145
+ """
146
+ res = self.session.execute(text(sql), params or {})
147
+ # mapping() -> RowMapping (dict-like)
148
+ return [dict(row) for row in res.mappings().all()]
149
+
150
+ def aggregate_avg(self, column_name: str, **filters) -> Optional[float]:
151
+ col = self._resolve_column(column_name)
152
+ stmt = select(func.avg(col))
153
+ if hasattr(self.model, "is_deleted"):
154
+ stmt = stmt.where(self.model.is_deleted == False) # noqa: E712
155
+ stmt = self._apply_filters(stmt, **filters)
156
+ return self.session.execute(stmt).scalar()
157
+
158
+ def aggregate_min(self, column_name: str, **filters):
159
+ col = self._resolve_column(column_name)
160
+ stmt = select(func.min(col))
161
+ if hasattr(self.model, "is_deleted"):
162
+ stmt = stmt.where(self.model.is_deleted == False)
163
+ stmt = self._apply_filters(stmt, **filters)
164
+ return self.session.execute(stmt).scalar()
165
+
166
+ def aggregate_max(self, column_name: str, **filters):
167
+ col = self._resolve_column(column_name)
168
+ stmt = select(func.max(col))
169
+ if hasattr(self.model, "is_deleted"):
170
+ stmt = stmt.where(self.model.is_deleted == False)
171
+ stmt = self._apply_filters(stmt, **filters)
172
+ return self.session.execute(stmt).scalar()
173
+
174
+ def aggregate_sum(self, column_name: str, **filters):
175
+ col = self._resolve_column(column_name)
176
+ stmt = select(func.sum(col))
177
+ if hasattr(self.model, "is_deleted"):
178
+ stmt = stmt.where(self.model.is_deleted == False)
179
+ stmt = self._apply_filters(stmt, **filters)
180
+ return self.session.execute(stmt).scalar()
181
+
182
+ def count(self, **filters) -> int:
183
+ stmt = select(func.count()).select_from(self.model)
184
+ if hasattr(self.model, "is_deleted") and not filters.pop("include_deleted", False):
185
+ stmt = stmt.where(self.model.is_deleted == False) # noqa: E712
186
+ if filters:
187
+ stmt = stmt.filter_by(**filters)
188
+ return int(self.session.execute(stmt).scalar_one())
189
+
190
+ def restore(self, id_: Any) -> bool:
191
+ """
192
+ Для soft-delete: is_deleted=False. Возвращает True, если восстановили.
193
+ """
194
+ if not hasattr(self.model, "is_deleted"):
195
+ raise RuntimeError(f"{self.model.__name__} has no 'is_deleted' field")
196
+ obj = self.session.get(self.model, id_)
197
+ if not obj:
198
+ return False
199
+ if getattr(obj, "is_deleted", False):
200
+ setattr(obj, "is_deleted", False)
201
+ self.session.flush()
202
+ return True
203
+ return False
66
204
 
67
205
  class SAAsyncRepository(Generic[T]):
68
206
  """Async repository implementation for SQLAlchemy 2.x."""
69
207
  def __init__(self, model: Type[T], session: AsyncSession):
70
208
  self.model = model
71
209
  self.session = session
72
-
210
+
211
+ def _resolve_column(self, column_name: str):
212
+ try:
213
+ return getattr(self.model, column_name)
214
+ except AttributeError as e:
215
+ raise ValueError(f"Model {self.model.__name__} has no column '{column_name}'") from e
216
+
217
+ def _apply_filters(self, stmt, **filters):
218
+ if filters:
219
+ stmt = stmt.filter_by(**filters)
220
+ return stmt
221
+
222
+ def _select(self):
223
+ return select(self.model)
224
+
73
225
  async def getAll(self, limit: Optional[int] = None) -> List[T]:
74
226
  stmt = select(self.model)
75
227
  if limit is not None:
@@ -77,9 +229,6 @@ class SAAsyncRepository(Generic[T]):
77
229
  result = await self.session.execute(stmt)
78
230
  return result.scalars().all()
79
231
 
80
- def _select(self):
81
- return select(self.model)
82
-
83
232
  async def get(self, id_: Any) -> T:
84
233
  obj = await self.session.get(self.model, id_)
85
234
  if not obj:
@@ -101,11 +250,26 @@ class SAAsyncRepository(Generic[T]):
101
250
  return entity
102
251
 
103
252
  async def remove(self, entity: T) -> None:
253
+ insp = inspect(entity, raiseerr=False)
254
+ if not (insp and (insp.persistent or insp.pending)):
255
+ pk = getattr(entity, "id", None)
256
+ if pk is None:
257
+ raise ValueError("remove() needs a persistent entity or an entity with a primary key set")
258
+ entity = await self.session.get(self.model, pk)
259
+ if entity is None:
260
+ return
104
261
  if hasattr(entity, "is_deleted"):
105
262
  setattr(entity, "is_deleted", True)
106
263
  else:
107
264
  await self.session.delete(entity)
108
265
 
266
+ async def delete_by_id(self, id_: Any) -> bool:
267
+ obj = await self.session.get(self.model, id_)
268
+ if not obj:
269
+ return False
270
+ await self.remove(obj)
271
+ return True
272
+
109
273
  async def page(self, page: PageRequest, spec: Optional[Spec] = None, order_by=None) -> Page[T]:
110
274
  stmt = self._select()
111
275
  if spec:
@@ -120,3 +284,104 @@ class SAAsyncRepository(Generic[T]):
120
284
  )
121
285
  items = res.scalars().all()
122
286
  return Page(items, total, page.page, page.size)
287
+
288
+ async def get_all_by_column(
289
+ self,
290
+ column_name: str,
291
+ value: Any,
292
+ *,
293
+ limit: Optional[int] = None,
294
+ order_by=None,
295
+ include_deleted: bool = False,
296
+ **extra_filters
297
+ ) -> list[T]:
298
+ col = self._resolve_column(column_name)
299
+ stmt = select(self.model).where(col == value)
300
+ if not include_deleted and hasattr(self.model, "is_deleted"):
301
+ stmt = stmt.where(self.model.is_deleted == False) # noqa: E712
302
+ stmt = self._apply_filters(stmt, **extra_filters)
303
+ if order_by is not None:
304
+ stmt = stmt.order_by(order_by)
305
+ if limit is not None:
306
+ stmt = stmt.limit(limit)
307
+ res = await self.session.execute(stmt)
308
+ return res.scalars().all()
309
+
310
+ # Alias
311
+ async def find_all_by_column(self, *args, **kwargs):
312
+ return await self.get_all_by_column(*args, **kwargs)
313
+
314
+ async def get_or_create(
315
+ self,
316
+ defaults: Optional[dict] = None,
317
+ **unique_filters
318
+ ) -> tuple[T, bool]:
319
+ stmt = select(self.model).filter_by(**unique_filters)
320
+ if hasattr(self.model, "is_deleted"):
321
+ stmt = stmt.where(self.model.is_deleted == False) # noqa: E712
322
+ obj = (await self.session.execute(stmt)).scalar_one_or_none()
323
+ if obj:
324
+ return obj, False
325
+ payload = {**unique_filters, **(defaults or {})}
326
+ obj = self.model(**payload) # type: ignore[call-arg]
327
+ self.session.add(obj)
328
+ await self.session.flush()
329
+ await self.session.refresh(obj)
330
+ return obj, True
331
+
332
+ async def raw_query(self, sql: str, params: Optional[dict] = None) -> list[dict]:
333
+ res = await self.session.execute(text(sql), params or {})
334
+ return [dict(row) for row in res.mappings().all()]
335
+
336
+ async def aggregate_avg(self, column_name: str, **filters) -> Optional[float]:
337
+ col = self._resolve_column(column_name)
338
+ stmt = select(func.avg(col))
339
+ if hasattr(self.model, "is_deleted"):
340
+ stmt = stmt.where(self.model.is_deleted == False) # noqa: E712
341
+ stmt = self._apply_filters(stmt, **filters)
342
+ return (await self.session.execute(stmt)).scalar()
343
+
344
+ async def aggregate_min(self, column_name: str, **filters):
345
+ col = self._resolve_column(column_name)
346
+ stmt = select(func.min(col))
347
+ if hasattr(self.model, "is_deleted"):
348
+ stmt = stmt.where(self.model.is_deleted == False)
349
+ stmt = self._apply_filters(stmt, **filters)
350
+ return (await self.session.execute(stmt)).scalar()
351
+
352
+ async def aggregate_max(self, column_name: str, **filters):
353
+ col = self._resolve_column(column_name)
354
+ stmt = select(func.max(col))
355
+ if hasattr(self.model, "is_deleted"):
356
+ stmt = stmt.where(self.model.is_deleted == False)
357
+ stmt = self._apply_filters(stmt, **filters)
358
+ return (await self.session.execute(stmt)).scalar()
359
+
360
+ async def aggregate_sum(self, column_name: str, **filters):
361
+ col = self._resolve_column(column_name)
362
+ stmt = select(func.sum(col))
363
+ if hasattr(self.model, "is_deleted"):
364
+ stmt = stmt.where(self.model.is_deleted == False)
365
+ stmt = self._apply_filters(stmt, **filters)
366
+ return (await self.session.execute(stmt)).scalar()
367
+
368
+ async def count(self, **filters) -> int:
369
+ include_deleted = bool(filters.pop("include_deleted", False))
370
+ stmt = select(func.count()).select_from(self.model)
371
+ if hasattr(self.model, "is_deleted") and not include_deleted:
372
+ stmt = stmt.where(self.model.is_deleted == False) # noqa: E712
373
+ if filters:
374
+ stmt = stmt.filter_by(**filters)
375
+ return int((await self.session.execute(stmt)).scalar_one())
376
+
377
+ async def restore(self, id_: Any) -> bool:
378
+ if not hasattr(self.model, "is_deleted"):
379
+ raise RuntimeError(f"{self.model.__name__} has no 'is_deleted' field")
380
+ obj = await self.session.get(self.model, id_)
381
+ if not obj:
382
+ return False
383
+ if getattr(obj, "is_deleted", False):
384
+ setattr(obj, "is_deleted", False)
385
+ await self.session.flush()
386
+ return True
387
+ return False
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: SARepo
3
- Version: 0.1.2
3
+ Version: 0.1.4
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=WogRS4Ku2DppkLASJdPgK-POBUrUmao9E3NBEiSX2-4,1655
5
+ SARepo/sa_repo.py,sha256=NSv1UyI6zZ5O_wn0QSq__T5e-lnDGhm5HxtkyDynEe0,14926
6
+ SARepo/specs.py,sha256=e-X1cCkva3e71M37hcfbjNDMezZAaOkkRaPToUzhEeU,687
7
+ SARepo/uow.py,sha256=yPlvi7PoH9x2pLNt6hEin9zT5qG9axUKn_TkZcIuz1s,859
8
+ sarepo-0.1.4.dist-info/licenses/LICENSE,sha256=px90NOQCrnZ77qoFT8IvAiNS1CXQ2OU27uVBKbki9DM,131
9
+ sarepo-0.1.4.dist-info/METADATA,sha256=D6lgXeMHrohYrdko9BZPOF9W2e3F7u4-hFYrP6qnXFU,2691
10
+ sarepo-0.1.4.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
11
+ sarepo-0.1.4.dist-info/top_level.txt,sha256=0k952UYVZLIIv3kZzFlxI0yzBMusZo3-XCQtxms_kS0,7
12
+ sarepo-0.1.4.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=QJ_8VHxnGyHIyfjEMyw8-70k9Qhv4VUjQwSN9LnJCC8,547
5
- SARepo/sa_repo.py,sha256=P3V1fa8C3beERdGvcBAtDd1psDwpoxxx9r-om2ZwhMk,4139
6
- SARepo/specs.py,sha256=e-X1cCkva3e71M37hcfbjNDMezZAaOkkRaPToUzhEeU,687
7
- SARepo/uow.py,sha256=yPlvi7PoH9x2pLNt6hEin9zT5qG9axUKn_TkZcIuz1s,859
8
- sarepo-0.1.2.dist-info/licenses/LICENSE,sha256=px90NOQCrnZ77qoFT8IvAiNS1CXQ2OU27uVBKbki9DM,131
9
- sarepo-0.1.2.dist-info/METADATA,sha256=Ff0HK8YEwHHvZN1Lpg-VwOTvFX0H-jWJk-vpZk0TYIo,2691
10
- sarepo-0.1.2.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
11
- sarepo-0.1.2.dist-info/top_level.txt,sha256=0k952UYVZLIIv3kZzFlxI0yzBMusZo3-XCQtxms_kS0,7
12
- sarepo-0.1.2.dist-info/RECORD,,
File without changes