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 CHANGED
@@ -1,4 +1,6 @@
1
- from typing import Generic, List, TypeVar, Type, Optional, Any, Protocol
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
- model: Type[T]
11
-
12
- def getAll(self,
13
- limit: Optional[int] = None,
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
- def try_get(self, id_: Any = None, *, include_deleted: bool = False, **filters) -> Optional[T]: ...
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 page(self, page: PageRequest, spec: Optional[Spec] = None, order_by=None, *, include_deleted: bool = False) -> \
32
- Page[T]: ...
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
- self,
36
- column_name: str,
37
- value: Any,
38
- *,
39
- limit: Optional[int] = None,
40
- order_by=None,
41
- include_deleted: bool = False,
42
- **extra_filters
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
- self,
47
- column_name: str,
48
- value: Any,
49
- *,
50
- limit: Optional[int] = None,
51
- order_by=None,
52
- include_deleted: bool = False,
53
- **extra_filters
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
- self,
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
- """Synchronous repository implementation for SQLAlchemy 2.x."""
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
- self,
42
- limit: Optional[int] = None,
43
- *,
44
- include_deleted: bool = False,
45
- order_by=None,
46
- **filters
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
- raise NotFoundError(f"{self.model.__name__}({id_}) not found")
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
- raise NotFoundError(f"{self.model.__name__} not found for {filters}")
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, entity: T) -> T:
82
- self.session.add(entity)
83
- self.session.flush()
84
- self.session.refresh(entity)
85
- 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').")
86
129
 
87
- def update(self, entity: T) -> T:
88
- self.session.flush()
89
- self.session.refresh(entity)
90
- return entity
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
- return True
120
-
121
- def page(self, page: PageRequest, spec: Optional[Spec] = None, order_by=None, *, include_deleted: bool = False) -> \
122
- Page[T]:
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
- items = self.session.execute(
215
+ res = self.session.execute(
135
216
  base.offset(page.page * page.size).limit(page.size)
136
- ).scalars().all()
137
- return Page(items, total, page.page, page.size)
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
- self,
141
- column_name: str,
142
- value: Any,
143
- *,
144
- limit: Optional[int] = None,
145
- order_by=None,
146
- include_deleted: bool = False,
147
- **extra_filters
148
- ) -> list[T]:
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
- self,
166
- defaults: Optional[dict] = None,
167
- **unique_filters
168
- ) -> tuple[T, bool]:
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
- # mapping() -> RowMapping (dict-like)
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 filters.pop("include_deleted", False):
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
- raise NotFoundError(f"{self.model.__name__}({id_}) not found")
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
- raise NotFoundError(f"{self.model.__name__} not found for {filters}")
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, entity: T) -> T:
335
- self.session.add(entity)
336
- await self.session.flush()
337
- await self.session.refresh(entity)
338
- 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
339
423
 
340
- async def update(self, entity: T) -> T:
341
- await self.session.flush()
342
- await self.session.refresh(entity)
343
- return entity
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,,
@@ -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
@@ -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