200 lines
5.2 KiB
Markdown
200 lines
5.2 KiB
Markdown
# MetaORM
|
|
|
|
Async repository layer over [SQLModel](https://sqlmodel.tiangolo.com). Provides a minimal, explicit pattern for database access with optional DTO mapping, automatic transaction management via `contextvars`, and built-in filter / pagination / sort support via `pydantic-filters`.
|
|
|
|
## Install
|
|
|
|
```bash
|
|
pip install "git+https://github.com/OlegYurchik/metaorm.git"
|
|
```
|
|
|
|
Requires Python `>=3.12`.
|
|
|
|
> **Note:** The package is installed directly from GitHub because `metaorm` depends on a patched version of `pydantic-filters` (from `so-saf/pydantic-filters`) that is not yet available on PyPI.
|
|
|
|
## Quick start
|
|
|
|
The simplest mode works with SQLModel tables directly — no DTOs, no generics, no magic:
|
|
|
|
```python
|
|
from sqlmodel import Field
|
|
from metaorm import BaseRepository, BaseTable, DatabaseSettings
|
|
|
|
|
|
class UserTable(BaseTable, table=True):
|
|
__tablename__ = "users"
|
|
id: int | None = Field(default=None, primary_key=True)
|
|
name: str
|
|
email: str = Field(unique=True)
|
|
|
|
|
|
class UserRepository(BaseRepository):
|
|
def get_db_table(self) -> type[UserTable]:
|
|
return UserTable
|
|
|
|
|
|
async def main():
|
|
repo = UserRepository(
|
|
settings=DatabaseSettings(dsn="sqlite+aiosqlite:///:memory:"),
|
|
)
|
|
await repo.create_tables()
|
|
|
|
user = await repo.create_item(
|
|
UserTable(name="Alice", email="alice@example.com"),
|
|
)
|
|
print(user.id, user.name)
|
|
|
|
all_users = [u async for u in repo.get_items()]
|
|
print(len(all_users))
|
|
```
|
|
|
|
## DTO mapping
|
|
|
|
When you want repository methods to return separate Pydantic models instead of table instances, override `get_dto_type()` and implement `from_item` / `to_item` on the table:
|
|
|
|
```python
|
|
from pydantic import BaseModel
|
|
from sqlmodel import Field
|
|
from metaorm import BaseRepository, BaseTable, DatabaseSettings
|
|
|
|
|
|
class User(BaseModel):
|
|
id: int | None = None
|
|
name: str
|
|
|
|
|
|
class UserTable(BaseTable[User], table=True):
|
|
__tablename__ = "users"
|
|
id: int | None = Field(default=None, primary_key=True)
|
|
name: str
|
|
|
|
@classmethod
|
|
def from_item(cls, item: User) -> "UserTable":
|
|
return cls(id=item.id, name=item.name)
|
|
|
|
def to_item(self) -> User:
|
|
return User(id=self.id, name=self.name)
|
|
|
|
|
|
class UserRepository(BaseRepository):
|
|
def get_db_table(self) -> type[UserTable]:
|
|
return UserTable
|
|
|
|
def get_dto_type(self) -> type[User]:
|
|
return User
|
|
|
|
|
|
async def main():
|
|
repo = UserRepository(
|
|
settings=DatabaseSettings(dsn="sqlite+aiosqlite:///:memory:"),
|
|
)
|
|
await repo.create_tables()
|
|
|
|
user = await repo.create_item(User(name="Alice"))
|
|
# user is a User DTO, not UserTable
|
|
print(user.model_dump())
|
|
```
|
|
|
|
## Filters, pagination and sorting
|
|
|
|
`pydantic-filters` provides `BaseFilter`, `BasePagination` and `BaseSort`. Pass them to `get_items`:
|
|
|
|
```python
|
|
from pydantic_filters import BaseFilter, BaseSort, OffsetPagination
|
|
|
|
class BookFilter(BaseFilter):
|
|
title: str | None = None
|
|
year: int | None = None
|
|
|
|
|
|
class BookRepository(BaseRepository):
|
|
def get_db_table(self) -> type[BookTable]:
|
|
return BookTable
|
|
|
|
def get_filter_type(self) -> type[BookFilter]:
|
|
return BookFilter
|
|
|
|
|
|
# Exact match filter
|
|
filtered = [
|
|
item
|
|
async for item in repo.get_items(filter_=BookFilter(year=2025))
|
|
]
|
|
|
|
# Pagination
|
|
page = [
|
|
item
|
|
async for item in repo.get_items(
|
|
pagination=OffsetPagination(offset=10, limit=20),
|
|
)
|
|
]
|
|
|
|
# Sorting
|
|
sorted_items = [
|
|
item
|
|
async for item in repo.get_items(
|
|
sort=BaseSort(sort_by="year", sort_by_order="desc"),
|
|
)
|
|
]
|
|
```
|
|
|
|
## Explicit transactions
|
|
|
|
Each repository method already runs inside a transaction automatically. If you need an explicit scope (e.g. to read `repository.session`), use `repository.transaction()`:
|
|
|
|
```python
|
|
async with repo.transaction():
|
|
user = await repo.create_item(UserTable(name="Alice"))
|
|
# nested transaction reuses the same session
|
|
async with repo.transaction():
|
|
items = [item async for item in repo.get_items()]
|
|
```
|
|
|
|
## Atomic transactions across multiple repositories
|
|
|
|
Use `RepositoriesContainer` when you need a single atomic transaction spanning multiple repositories:
|
|
|
|
```python
|
|
from metaorm import RepositoriesContainer
|
|
|
|
container = RepositoriesContainer(settings=settings)
|
|
user_repo = container.get_repository(UserRepository)
|
|
order_repo = container.get_repository(OrderRepository)
|
|
|
|
async with container.transaction():
|
|
user = await user_repo.create_item(UserTable(name="Alice"))
|
|
await order_repo.create_item(OrderTable(user_id=user.id, total=100))
|
|
```
|
|
|
|
`container.transaction()` stores the session in a `contextvars.ContextVar`. All repository operations within the `async with` block automatically reuse that session. Nested `container.transaction()` calls yield the same session.
|
|
|
|
## Eager loading
|
|
|
|
`get_items()` and `update_items()` accept an optional `options` parameter for SQLAlchemy eager loading strategies:
|
|
|
|
```python
|
|
from sqlalchemy.orm import joinedload
|
|
|
|
books = [
|
|
item
|
|
async for item in book_repo.get_items(
|
|
options=[joinedload(BookTable.author)],
|
|
)
|
|
]
|
|
```
|
|
|
|
## Exceptions
|
|
|
|
```
|
|
DatabaseException
|
|
├── NotFoundError
|
|
├── HaveNoSessionError
|
|
└── AlreadyExistsError
|
|
```
|
|
|
|
All repository methods raise `DatabaseException` subclasses or SQLAlchemy errors.
|
|
|
|
## License
|
|
|
|
MIT
|