SARepo 0.1.0__tar.gz
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-0.1.0/LICENSE +7 -0
- sarepo-0.1.0/PKG-INFO +90 -0
- sarepo-0.1.0/README.md +78 -0
- sarepo-0.1.0/SARepo/__init__.py +5 -0
- sarepo-0.1.0/SARepo/base.py +31 -0
- sarepo-0.1.0/SARepo/models.py +14 -0
- sarepo-0.1.0/SARepo/repo.py +14 -0
- sarepo-0.1.0/SARepo/sa_repo.py +108 -0
- sarepo-0.1.0/SARepo/specs.py +27 -0
- sarepo-0.1.0/SARepo/uow.py +28 -0
- sarepo-0.1.0/SARepo.egg-info/PKG-INFO +90 -0
- sarepo-0.1.0/SARepo.egg-info/SOURCES.txt +16 -0
- sarepo-0.1.0/SARepo.egg-info/dependency_links.txt +1 -0
- sarepo-0.1.0/SARepo.egg-info/requires.txt +1 -0
- sarepo-0.1.0/SARepo.egg-info/top_level.txt +1 -0
- sarepo-0.1.0/pyproject.toml +20 -0
- sarepo-0.1.0/setup.cfg +4 -0
- sarepo-0.1.0/tests/test_sync_basic.py +30 -0
sarepo-0.1.0/LICENSE
ADDED
sarepo-0.1.0/PKG-INFO
ADDED
@@ -0,0 +1,90 @@
|
|
1
|
+
Metadata-Version: 2.4
|
2
|
+
Name: SARepo
|
3
|
+
Version: 0.1.0
|
4
|
+
Summary: Minimal, explicit Repository & Unit-of-Work layer over SQLAlchemy 2.x
|
5
|
+
Author: nurbergenovv
|
6
|
+
License: MIT
|
7
|
+
Requires-Python: >=3.11
|
8
|
+
Description-Content-Type: text/markdown
|
9
|
+
License-File: LICENSE
|
10
|
+
Requires-Dist: SQLAlchemy>=2.0
|
11
|
+
Dynamic: license-file
|
12
|
+
|
13
|
+
|
14
|
+
# SARepo
|
15
|
+
|
16
|
+
Minimal, explicit **Repository** + **Unit of Work** layer on top of **SQLAlchemy 2.x** (sync & async).
|
17
|
+
No magic method-names, just clean typed APIs, composable specs, pagination, and optional soft-delete.
|
18
|
+
|
19
|
+
## Install (editable)
|
20
|
+
|
21
|
+
```bash
|
22
|
+
pip install -e .
|
23
|
+
```
|
24
|
+
|
25
|
+
## Quick Start (sync)
|
26
|
+
|
27
|
+
```python
|
28
|
+
from sqlalchemy import create_engine
|
29
|
+
from sqlalchemy.orm import sessionmaker, Mapped, mapped_column
|
30
|
+
from sqlalchemy.orm import DeclarativeBase
|
31
|
+
from sqlalchemy import String
|
32
|
+
|
33
|
+
from SARepo.sa_repo import SARepository
|
34
|
+
from SARepo.base import PageRequest
|
35
|
+
from SARepo.specs import and_specs, ilike
|
36
|
+
|
37
|
+
class Base(DeclarativeBase): pass
|
38
|
+
|
39
|
+
class Request(Base):
|
40
|
+
__tablename__ = "requests"
|
41
|
+
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
|
42
|
+
title: Mapped[str] = mapped_column(String(255))
|
43
|
+
status: Mapped[str] = mapped_column(String(50))
|
44
|
+
|
45
|
+
engine = create_engine("sqlite+pysqlite:///:memory:", echo=False)
|
46
|
+
Base.metadata.create_all(engine)
|
47
|
+
SessionLocal = sessionmaker(bind=engine, expire_on_commit=False)
|
48
|
+
|
49
|
+
with SessionLocal() as session:
|
50
|
+
repo = SARepository(Request, session)
|
51
|
+
# create
|
52
|
+
r = repo.add(Request(title="Hello", status="NEW"))
|
53
|
+
session.commit()
|
54
|
+
|
55
|
+
# search + paginate
|
56
|
+
page = repo.page(PageRequest(0, 10), spec=ilike(Request.title, "%He%"))
|
57
|
+
print(page.total, [i.title for i in page.items])
|
58
|
+
```
|
59
|
+
|
60
|
+
## Async Quick Start
|
61
|
+
|
62
|
+
```python
|
63
|
+
import asyncio
|
64
|
+
from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker
|
65
|
+
from SARepo.sa_repo import SAAsyncRepository
|
66
|
+
from SARepo.base import PageRequest
|
67
|
+
|
68
|
+
async def main():
|
69
|
+
engine = create_async_engine("sqlite+aiosqlite:///:memory:", echo=False)
|
70
|
+
async with engine.begin() as conn:
|
71
|
+
await conn.run_sync(Base.metadata.create_all)
|
72
|
+
SessionLocal = async_sessionmaker(bind=engine, expire_on_commit=False)
|
73
|
+
async with SessionLocal() as session:
|
74
|
+
repo = SAAsyncRepository(Request, session)
|
75
|
+
await repo.add(Request(title="Hello", status="NEW"))
|
76
|
+
await session.commit()
|
77
|
+
page = await repo.page(PageRequest(0, 10))
|
78
|
+
print(page.total)
|
79
|
+
|
80
|
+
asyncio.run(main())
|
81
|
+
```
|
82
|
+
|
83
|
+
## Features
|
84
|
+
- `SARepository` and `SAAsyncRepository` (CRUD, `page()` with total count)
|
85
|
+
- Composable **specs** (`eq`, `ilike`, `and_specs`, `not_deleted`)
|
86
|
+
- Optional **soft-delete** if the entity has `is_deleted: bool`
|
87
|
+
- Minimal **Unit of Work** helpers (sync & async)
|
88
|
+
|
89
|
+
## License
|
90
|
+
MIT
|
sarepo-0.1.0/README.md
ADDED
@@ -0,0 +1,78 @@
|
|
1
|
+
|
2
|
+
# SARepo
|
3
|
+
|
4
|
+
Minimal, explicit **Repository** + **Unit of Work** layer on top of **SQLAlchemy 2.x** (sync & async).
|
5
|
+
No magic method-names, just clean typed APIs, composable specs, pagination, and optional soft-delete.
|
6
|
+
|
7
|
+
## Install (editable)
|
8
|
+
|
9
|
+
```bash
|
10
|
+
pip install -e .
|
11
|
+
```
|
12
|
+
|
13
|
+
## Quick Start (sync)
|
14
|
+
|
15
|
+
```python
|
16
|
+
from sqlalchemy import create_engine
|
17
|
+
from sqlalchemy.orm import sessionmaker, Mapped, mapped_column
|
18
|
+
from sqlalchemy.orm import DeclarativeBase
|
19
|
+
from sqlalchemy import String
|
20
|
+
|
21
|
+
from SARepo.sa_repo import SARepository
|
22
|
+
from SARepo.base import PageRequest
|
23
|
+
from SARepo.specs import and_specs, ilike
|
24
|
+
|
25
|
+
class Base(DeclarativeBase): pass
|
26
|
+
|
27
|
+
class Request(Base):
|
28
|
+
__tablename__ = "requests"
|
29
|
+
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
|
30
|
+
title: Mapped[str] = mapped_column(String(255))
|
31
|
+
status: Mapped[str] = mapped_column(String(50))
|
32
|
+
|
33
|
+
engine = create_engine("sqlite+pysqlite:///:memory:", echo=False)
|
34
|
+
Base.metadata.create_all(engine)
|
35
|
+
SessionLocal = sessionmaker(bind=engine, expire_on_commit=False)
|
36
|
+
|
37
|
+
with SessionLocal() as session:
|
38
|
+
repo = SARepository(Request, session)
|
39
|
+
# create
|
40
|
+
r = repo.add(Request(title="Hello", status="NEW"))
|
41
|
+
session.commit()
|
42
|
+
|
43
|
+
# search + paginate
|
44
|
+
page = repo.page(PageRequest(0, 10), spec=ilike(Request.title, "%He%"))
|
45
|
+
print(page.total, [i.title for i in page.items])
|
46
|
+
```
|
47
|
+
|
48
|
+
## Async Quick Start
|
49
|
+
|
50
|
+
```python
|
51
|
+
import asyncio
|
52
|
+
from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker
|
53
|
+
from SARepo.sa_repo import SAAsyncRepository
|
54
|
+
from SARepo.base import PageRequest
|
55
|
+
|
56
|
+
async def main():
|
57
|
+
engine = create_async_engine("sqlite+aiosqlite:///:memory:", echo=False)
|
58
|
+
async with engine.begin() as conn:
|
59
|
+
await conn.run_sync(Base.metadata.create_all)
|
60
|
+
SessionLocal = async_sessionmaker(bind=engine, expire_on_commit=False)
|
61
|
+
async with SessionLocal() as session:
|
62
|
+
repo = SAAsyncRepository(Request, session)
|
63
|
+
await repo.add(Request(title="Hello", status="NEW"))
|
64
|
+
await session.commit()
|
65
|
+
page = await repo.page(PageRequest(0, 10))
|
66
|
+
print(page.total)
|
67
|
+
|
68
|
+
asyncio.run(main())
|
69
|
+
```
|
70
|
+
|
71
|
+
## Features
|
72
|
+
- `SARepository` and `SAAsyncRepository` (CRUD, `page()` with total count)
|
73
|
+
- Composable **specs** (`eq`, `ilike`, `and_specs`, `not_deleted`)
|
74
|
+
- Optional **soft-delete** if the entity has `is_deleted: bool`
|
75
|
+
- Minimal **Unit of Work** helpers (sync & async)
|
76
|
+
|
77
|
+
## License
|
78
|
+
MIT
|
@@ -0,0 +1,31 @@
|
|
1
|
+
|
2
|
+
from typing import Generic, TypeVar, Sequence
|
3
|
+
T = TypeVar("T")
|
4
|
+
|
5
|
+
class NotFoundError(Exception):
|
6
|
+
"""Raised when entity not found."""
|
7
|
+
pass
|
8
|
+
|
9
|
+
class ConcurrencyError(Exception):
|
10
|
+
"""Raised when optimistic lock/version conflicts occur."""
|
11
|
+
pass
|
12
|
+
|
13
|
+
class PageRequest:
|
14
|
+
def __init__(self, page: int = 0, size: int = 10):
|
15
|
+
if page < 0:
|
16
|
+
raise ValueError("page must be >= 0")
|
17
|
+
if not (1 <= size <= 10000):
|
18
|
+
raise ValueError("size must be in [1, 10000]")
|
19
|
+
self.page = page
|
20
|
+
self.size = size
|
21
|
+
|
22
|
+
class Page(Generic[T]):
|
23
|
+
def __init__(self, items: Sequence[T], total: int, page: int, size: int):
|
24
|
+
self.items = list(items)
|
25
|
+
self.total = int(total)
|
26
|
+
self.page = int(page)
|
27
|
+
self.size = int(size)
|
28
|
+
|
29
|
+
@property
|
30
|
+
def pages(self) -> int:
|
31
|
+
return (self.total + self.size - 1) // self.size
|
@@ -0,0 +1,14 @@
|
|
1
|
+
|
2
|
+
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
|
3
|
+
from sqlalchemy import func, DateTime, Boolean
|
4
|
+
|
5
|
+
class Base(DeclarativeBase):
|
6
|
+
"""Base for user models if you don't want to declare your own."""
|
7
|
+
pass
|
8
|
+
|
9
|
+
class TimeStamped:
|
10
|
+
created_at: Mapped["datetime"] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
11
|
+
updated_at: Mapped["datetime"] = mapped_column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
|
12
|
+
|
13
|
+
class SoftDelete:
|
14
|
+
is_deleted: Mapped[bool] = mapped_column(Boolean, default=False, index=True)
|
@@ -0,0 +1,14 @@
|
|
1
|
+
|
2
|
+
from typing import Generic, TypeVar, Type, Optional, Any, Protocol
|
3
|
+
from .base import Page, PageRequest
|
4
|
+
|
5
|
+
T = TypeVar("T")
|
6
|
+
|
7
|
+
class CrudRepository(Protocol, Generic[T]):
|
8
|
+
model: Type[T]
|
9
|
+
def get(self, id_: Any) -> T: ...
|
10
|
+
def try_get(self, id_: Any) -> Optional[T]: ...
|
11
|
+
def add(self, entity: T) -> T: ...
|
12
|
+
def update(self, entity: T) -> T: ...
|
13
|
+
def remove(self, entity: T) -> None: ...
|
14
|
+
def page(self, page: PageRequest, spec=None, order_by=None) -> Page[T]: ...
|
@@ -0,0 +1,108 @@
|
|
1
|
+
|
2
|
+
from typing import Type, Generic, TypeVar, Optional, Sequence, Any, Callable
|
3
|
+
from sqlalchemy.orm import Session
|
4
|
+
from sqlalchemy.ext.asyncio import AsyncSession
|
5
|
+
from sqlalchemy import select, func
|
6
|
+
from .base import PageRequest, Page, NotFoundError
|
7
|
+
|
8
|
+
T = TypeVar("T")
|
9
|
+
Spec = Callable # aliased to match specs.Spec
|
10
|
+
|
11
|
+
class SARepository(Generic[T]):
|
12
|
+
"""Synchronous repository implementation for SQLAlchemy 2.x."""
|
13
|
+
def __init__(self, model: Type[T], session: Session):
|
14
|
+
self.model = model
|
15
|
+
self.session = session
|
16
|
+
|
17
|
+
def _select(self):
|
18
|
+
return select(self.model)
|
19
|
+
|
20
|
+
def get(self, id_: Any) -> T:
|
21
|
+
obj = self.session.get(self.model, id_)
|
22
|
+
if not obj:
|
23
|
+
raise NotFoundError(f"{self.model.__name__}({id_}) not found")
|
24
|
+
return obj
|
25
|
+
|
26
|
+
def try_get(self, id_: Any) -> Optional[T]:
|
27
|
+
return self.session.get(self.model, id_)
|
28
|
+
|
29
|
+
def add(self, entity: T) -> T:
|
30
|
+
self.session.add(entity)
|
31
|
+
self.session.flush()
|
32
|
+
self.session.refresh(entity)
|
33
|
+
return entity
|
34
|
+
|
35
|
+
def update(self, entity: T) -> T:
|
36
|
+
self.session.flush()
|
37
|
+
self.session.refresh(entity)
|
38
|
+
return entity
|
39
|
+
|
40
|
+
def remove(self, entity: T) -> None:
|
41
|
+
if hasattr(entity, "is_deleted"):
|
42
|
+
setattr(entity, "is_deleted", True)
|
43
|
+
else:
|
44
|
+
self.session.delete(entity)
|
45
|
+
|
46
|
+
def page(self, page: PageRequest, spec: Optional[Spec] = None, order_by=None) -> Page[T]:
|
47
|
+
stmt = self._select()
|
48
|
+
if spec:
|
49
|
+
stmt = spec(stmt)
|
50
|
+
if order_by is not None:
|
51
|
+
stmt = stmt.order_by(order_by)
|
52
|
+
total = self.session.execute(
|
53
|
+
select(func.count()).select_from(stmt.subquery())
|
54
|
+
).scalar_one()
|
55
|
+
items = self.session.execute(
|
56
|
+
stmt.offset(page.page * page.size).limit(page.size)
|
57
|
+
).scalars().all()
|
58
|
+
return Page(items, total, page.page, page.size)
|
59
|
+
|
60
|
+
class SAAsyncRepository(Generic[T]):
|
61
|
+
"""Async repository implementation for SQLAlchemy 2.x."""
|
62
|
+
def __init__(self, model: Type[T], session: AsyncSession):
|
63
|
+
self.model = model
|
64
|
+
self.session = session
|
65
|
+
|
66
|
+
def _select(self):
|
67
|
+
return select(self.model)
|
68
|
+
|
69
|
+
async def get(self, id_: Any) -> T:
|
70
|
+
obj = await self.session.get(self.model, id_)
|
71
|
+
if not obj:
|
72
|
+
raise NotFoundError(f"{self.model.__name__}({id_}) not found")
|
73
|
+
return obj
|
74
|
+
|
75
|
+
async def try_get(self, id_: Any) -> Optional[T]:
|
76
|
+
return await self.session.get(self.model, id_)
|
77
|
+
|
78
|
+
async def add(self, entity: T) -> T:
|
79
|
+
self.session.add(entity)
|
80
|
+
await self.session.flush()
|
81
|
+
await self.session.refresh(entity)
|
82
|
+
return entity
|
83
|
+
|
84
|
+
async def update(self, entity: T) -> T:
|
85
|
+
await self.session.flush()
|
86
|
+
await self.session.refresh(entity)
|
87
|
+
return entity
|
88
|
+
|
89
|
+
async def remove(self, entity: T) -> None:
|
90
|
+
if hasattr(entity, "is_deleted"):
|
91
|
+
setattr(entity, "is_deleted", True)
|
92
|
+
else:
|
93
|
+
await self.session.delete(entity)
|
94
|
+
|
95
|
+
async def page(self, page: PageRequest, spec: Optional[Spec] = None, order_by=None) -> Page[T]:
|
96
|
+
stmt = self._select()
|
97
|
+
if spec:
|
98
|
+
stmt = spec(stmt)
|
99
|
+
if order_by is not None:
|
100
|
+
stmt = stmt.order_by(order_by)
|
101
|
+
total = (await self.session.execute(
|
102
|
+
select(func.count()).select_from(stmt.subquery())
|
103
|
+
)).scalar_one()
|
104
|
+
res = await self.session.execute(
|
105
|
+
stmt.offset(page.page * page.size).limit(page.size)
|
106
|
+
)
|
107
|
+
items = res.scalars().all()
|
108
|
+
return Page(items, total, page.page, page.size)
|
@@ -0,0 +1,27 @@
|
|
1
|
+
|
2
|
+
from typing import Callable
|
3
|
+
from sqlalchemy.sql import Select
|
4
|
+
|
5
|
+
Spec = Callable[[Select], Select]
|
6
|
+
|
7
|
+
def and_specs(*specs: Spec) -> Spec:
|
8
|
+
def _apply(q: Select) -> Select:
|
9
|
+
for s in specs:
|
10
|
+
q = s(q)
|
11
|
+
return q
|
12
|
+
return _apply
|
13
|
+
|
14
|
+
def eq(model_attr, value) -> Spec:
|
15
|
+
def _s(q: Select) -> Select:
|
16
|
+
return q.where(model_attr == value)
|
17
|
+
return _s
|
18
|
+
|
19
|
+
def ilike(model_attr, pattern: str) -> Spec:
|
20
|
+
def _s(q: Select) -> Select:
|
21
|
+
return q.where(model_attr.ilike(pattern))
|
22
|
+
return _s
|
23
|
+
|
24
|
+
def not_deleted(model_cls) -> Spec:
|
25
|
+
def _s(q: Select) -> Select:
|
26
|
+
return q.where(getattr(model_cls, "is_deleted") == False) # noqa: E712
|
27
|
+
return _s
|
@@ -0,0 +1,28 @@
|
|
1
|
+
|
2
|
+
from contextlib import AbstractContextManager
|
3
|
+
from sqlalchemy.orm import Session
|
4
|
+
from sqlalchemy.ext.asyncio import AsyncSession
|
5
|
+
|
6
|
+
class UoW(AbstractContextManager):
|
7
|
+
"""Minimal Unit of Work for sync SQLAlchemy sessions."""
|
8
|
+
def __init__(self, session: Session):
|
9
|
+
self.session = session
|
10
|
+
def __enter__(self):
|
11
|
+
return self
|
12
|
+
def __exit__(self, exc_type, *_):
|
13
|
+
if exc_type:
|
14
|
+
self.session.rollback()
|
15
|
+
else:
|
16
|
+
self.session.commit()
|
17
|
+
|
18
|
+
class AsyncUoW:
|
19
|
+
"""Minimal Unit of Work for async SQLAlchemy sessions."""
|
20
|
+
def __init__(self, session: AsyncSession):
|
21
|
+
self.session = session
|
22
|
+
async def __aenter__(self):
|
23
|
+
return self
|
24
|
+
async def __aexit__(self, exc_type, *_):
|
25
|
+
if exc_type:
|
26
|
+
await self.session.rollback()
|
27
|
+
else:
|
28
|
+
await self.session.commit()
|
@@ -0,0 +1,90 @@
|
|
1
|
+
Metadata-Version: 2.4
|
2
|
+
Name: SARepo
|
3
|
+
Version: 0.1.0
|
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
|
@@ -0,0 +1,16 @@
|
|
1
|
+
LICENSE
|
2
|
+
README.md
|
3
|
+
pyproject.toml
|
4
|
+
SARepo/__init__.py
|
5
|
+
SARepo/base.py
|
6
|
+
SARepo/models.py
|
7
|
+
SARepo/repo.py
|
8
|
+
SARepo/sa_repo.py
|
9
|
+
SARepo/specs.py
|
10
|
+
SARepo/uow.py
|
11
|
+
SARepo.egg-info/PKG-INFO
|
12
|
+
SARepo.egg-info/SOURCES.txt
|
13
|
+
SARepo.egg-info/dependency_links.txt
|
14
|
+
SARepo.egg-info/requires.txt
|
15
|
+
SARepo.egg-info/top_level.txt
|
16
|
+
tests/test_sync_basic.py
|
@@ -0,0 +1 @@
|
|
1
|
+
|
@@ -0,0 +1 @@
|
|
1
|
+
SQLAlchemy>=2.0
|
@@ -0,0 +1 @@
|
|
1
|
+
SARepo
|
@@ -0,0 +1,20 @@
|
|
1
|
+
|
2
|
+
[project]
|
3
|
+
name = "SARepo"
|
4
|
+
version = "0.1.0"
|
5
|
+
description = "Minimal, explicit Repository & Unit-of-Work layer over SQLAlchemy 2.x"
|
6
|
+
readme = "README.md"
|
7
|
+
requires-python = ">=3.11"
|
8
|
+
authors = [{name = "nurbergenovv"}]
|
9
|
+
license = {text = "MIT"}
|
10
|
+
dependencies = [
|
11
|
+
"SQLAlchemy>=2.0"
|
12
|
+
]
|
13
|
+
|
14
|
+
[build-system]
|
15
|
+
requires = ["setuptools>=68", "wheel"]
|
16
|
+
build-backend = "setuptools.build_meta"
|
17
|
+
|
18
|
+
[tool.setuptools]
|
19
|
+
packages = ["SARepo"]
|
20
|
+
include-package-data = true
|
sarepo-0.1.0/setup.cfg
ADDED
@@ -0,0 +1,30 @@
|
|
1
|
+
|
2
|
+
import pytest
|
3
|
+
from sqlalchemy import create_engine, String
|
4
|
+
from sqlalchemy.orm import sessionmaker, DeclarativeBase, Mapped, mapped_column
|
5
|
+
from SARepoo.sa_repo import SARepository
|
6
|
+
from SARepoo.base import PageRequest
|
7
|
+
|
8
|
+
class Base(DeclarativeBase): pass
|
9
|
+
|
10
|
+
class Item(Base):
|
11
|
+
__tablename__ = "items"
|
12
|
+
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
|
13
|
+
title: Mapped[str] = mapped_column(String(100))
|
14
|
+
|
15
|
+
def test_crud_and_pagination():
|
16
|
+
engine = create_engine("sqlite+pysqlite:///:memory:", echo=False)
|
17
|
+
Base.metadata.create_all(engine)
|
18
|
+
SessionLocal = sessionmaker(bind=engine, expire_on_commit=False)
|
19
|
+
|
20
|
+
with SessionLocal() as session:
|
21
|
+
repo = SARepository(Item, session)
|
22
|
+
for i in range(25):
|
23
|
+
repo.add(Item(title=f"t{i}"))
|
24
|
+
session.commit()
|
25
|
+
|
26
|
+
page = repo.page(PageRequest(1, 10))
|
27
|
+
assert page.total == 25
|
28
|
+
assert len(page.items) == 10
|
29
|
+
assert page.page == 1
|
30
|
+
assert page.pages == 3
|