docs: add MkDocs documentation with Material theme and GitHub Pages deploy
Some checks failed
docs / deploy (push) Has been cancelled

This commit is contained in:
2026-08-17 14:22:34 +03:00
parent 1608b00708
commit 8787342476
18 changed files with 1277 additions and 16 deletions

48
docs/api.md Normal file
View File

@@ -0,0 +1,48 @@
# API Reference
## BaseRepository
::: metaorm.repositories.BaseRepository
options:
show_source: true
members:
- __init_subclass__
- __init__
- get_items_count
- get_item
- get_items
- create_item
- update_items
- delete_items
- create_tables
- get_table_type
- get_filter_type
- get_dto_type
## BaseTable
::: metaorm.tables.BaseTable
## RepositoriesContainer
::: metaorm.container.RepositoriesContainer
options:
show_source: true
## RepositorySettings
::: metaorm.settings.RepositorySettings
## Exceptions
::: metaorm.exceptions.DatabaseException
::: metaorm.exceptions.NotFoundError
::: metaorm.exceptions.HaveNoSessionError
::: metaorm.exceptions.AlreadyExistsError
## Re-exports
The following symbols are re-exported from `metaorm` for convenience:
- `BaseFilter`, `BasePagination`, `BaseSort`, `OffsetPagination`, `PagePagination` — from `pydantic-filters`
- `Field`, `Relationship` — from `sqlmodel`

35
docs/examples.md Normal file
View File

@@ -0,0 +1,35 @@
# Examples
All examples are located in the [`examples/`](https://github.com/OlegYurchik/metaorm/tree/main/examples) directory and can be run directly:
```bash
PYTHONPATH=. .venv/bin/python examples/basic_usage.py
```
## Basic Usage
--8<-- "examples/basic_usage.py"
## DTO Usage
--8<-- "examples/dto_usage.py"
## Transactions
--8<-- "examples/transactions.py"
## Nested Transactions
--8<-- "examples/nested_transactions.py"
## Filters, Pagination & Sorting
--8<-- "examples/filter_usage.py"
## Eager Loading
--8<-- "examples/relationships.py"
## Multi-Repo Transactions
--8<-- "examples/container_usage.py"

53
docs/guide/container.md Normal file
View File

@@ -0,0 +1,53 @@
# Multi-Repo Transactions
Use `RepositoriesContainer` when you need a single atomic transaction spanning multiple repositories.
## Creating a container
```python
from metaorm import RepositoriesContainer, RepositorySettings
settings = RepositorySettings(dsn="sqlite+aiosqlite:///:memory:")
container = RepositoriesContainer(settings=settings)
```
## Getting repositories
```python
user_repo = container.get_repository(UserRepository)
order_repo = container.get_repository(OrderRepository)
```
## Atomic transaction across repositories
```python
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 `transaction()` calls yield the same session.
## Creating tables via container
You can also create tables for multiple repositories at once:
```python
await container.create_tables(UserRepository, OrderRepository)
```
## Nested transactions (savepoints)
For partial rollback inside a shared transaction use `container.nested_transaction()` (or `repository.nested_transaction()`). It creates a SQLAlchemy savepoint: an error inside the block rolls back only the savepoint, leaving the outer transaction open for further operations or commit.
```python
async with container.transaction():
user = await user_repo.create_item(UserTable(name="Bob"))
try:
async with container.nested_transaction():
await order_repo.create_item(OrderTable(user_id=user.id, total=999.99))
raise ValueError("Rollback nested order")
except ValueError:
pass
# Bob stays, the order is rolled back
```

55
docs/guide/filters.md Normal file
View File

@@ -0,0 +1,55 @@
# Filters, Pagination & Sorting
MetaORM delegates filtering, pagination and sorting to `pydantic-filters`. All you need is a `BaseFilter` subclass.
## Filters
```python
from metaorm import BaseFilter
class BookFilter(BaseFilter):
title: str | None = None
year: int | None = None
```
Use the filter when querying:
```python
# Exact match
items = [item async for item in repo.get_items(filter_=BookFilter(year=2025))]
# Single item
single = await repo.get_item(filter_=BookFilter(title="Book 5"))
```
## Pagination
```python
from metaorm import OffsetPagination
pagination = OffsetPagination(offset=2, limit=3)
page = [item async for item in repo.get_items(pagination=pagination)]
```
## Sorting
```python
from metaorm import BaseSort
sort = BaseSort(sort_by="year", sort_by_order="desc")
sorted_items = [item async for item in repo.get_items(sort=sort)]
```
## Combining all three
```python
items = [
item
async for item in repo.get_items(
filter_=BookFilter(year=2025),
pagination=OffsetPagination(offset=0, limit=10),
sort=BaseSort(sort_by="title", sort_by_order="asc"),
)
]
```

View File

@@ -0,0 +1,79 @@
# Getting Started
## Installation
MetaORM requires Python **3.12 or higher**.
```bash
pip install "git+https://github.com/OlegYurchik/metaorm.git"
```
## Core concepts
MetaORM is built on three pillars:
1. **BaseTable** — a `SQLModel` subclass that defines your database schema and optional DTO mapping.
2. **BaseRepository** — provides CRUD methods for a specific table.
3. **RepositoriesContainer** — manages the async engine and sessions, enabling multi-repository transactions.
## Minimal example
```python
import asyncio
from metaorm import BaseFilter, BaseRepository, BaseTable, Field, RepositorySettings
class UserTable(BaseTable, table=True):
__tablename__ = "users"
id: int | None = Field(default=None, primary_key=True)
name: str
email: str = Field(unique=True)
class UserFilter(BaseFilter):
name: str | None = None
email: str | None = None
class UserRepository(BaseRepository, table=UserTable, filter_=UserFilter):
pass
async def main():
settings = RepositorySettings(dsn="sqlite+aiosqlite:///:memory:")
repo = UserRepository(settings=settings)
await repo.create_tables()
user = await repo.create_item(UserTable(name="Alice", email="alice@example.com"))
print(f"Created user {user.id}")
users = [u async for u in repo.get_items()]
print(f"Total users: {len(users)}")
if __name__ == "__main__":
asyncio.run(main())
```
## Constructor modes
### Simple mode
Create a repository directly with `settings`. An internal container is created automatically:
```python
repo = UserRepository(settings=RepositorySettings(dsn="sqlite+aiosqlite:///:memory:"))
```
### Advanced mode
Reuse a `RepositoriesContainer` when you need atomic transactions across multiple repositories:
```python
container = RepositoriesContainer(settings=settings)
user_repo = UserRepository(container=container)
order_repo = OrderRepository(container=container)
```
See [Multi-Repo Transactions](container.md) for details.

View File

@@ -0,0 +1,60 @@
# Eager Loading
MetaORM supports SQLAlchemy eager loading strategies via the `options` parameter in `get_items()` and `update_items()`.
## joinedload
Load related objects in the same query using a SQL JOIN:
```python
from sqlalchemy.orm import joinedload
books = [
item
async for item in book_repo.get_items(
options=[joinedload(BookTable.author)],
)
]
for book in books:
print(f"Book: {book.title}, Author: {book.author.name}")
```
## selectinload
For collections (one-to-many), `selectinload` is often more efficient:
```python
from sqlalchemy.orm import selectinload
authors = [
item
async for item in author_repo.get_items(
options=[selectinload(AuthorTable.books)],
)
]
```
## Defining relationships
Relationships are defined with SQLModel's `Relationship`:
```python
from metaorm import BaseTable, Field, Relationship
class AuthorTable(BaseTable, table=True):
__tablename__ = "authors"
id: int | None = Field(default=None, primary_key=True)
name: str
books: list["BookTable"] = Relationship(back_populates="author")
class BookTable(BaseTable, table=True):
__tablename__ = "books"
id: int | None = Field(default=None, primary_key=True)
title: str
author_id: int = Field(foreign_key="authors.id")
author: AuthorTable = Relationship(back_populates="books")
```
Both `Field` and `Relationship` are re-exported from `metaorm` for convenience.

View File

@@ -0,0 +1,52 @@
# Repositories
Subclass `BaseRepository` with keyword arguments `table`, `filter_`, and optionally `dto`:
```python
class MyRepository(BaseRepository, table=MyTable, filter_=MyFilter):
pass # returns table instances directly
class MyRepositoryWithDto(BaseRepository, table=MyTable, filter_=MyFilter, dto=MyDto):
pass # maps rows to MyDto
```
Keyword arguments are checked at class-definition time. If you forget `table` or `filter_`, Python raises `TypeError` immediately. `table=` must still be provided on the first subclass in the hierarchy.
## Methods
| Method | Signature | Description |
|--------|-----------|-------------|
| `create_tables` | `async () -> None` | Creates the table in the database. |
| `create_item` | `async (item) -> Any` | Inserts one row. Returns the table instance or DTO when `dto=` is set. |
| `get_items` | `async (filter_=None, pagination=None, sort=None, options=None) -> AsyncGenerator[Any]` | Streams matching rows. `options` accepts SQLAlchemy eager-loading strategies such as `joinedload`. |
| `get_items_count` | `async (filter_=None) -> int` | Returns the number of matching rows. |
| `update_items` | `async (filter_=None, options=None, **values) -> AsyncGenerator[Any]` | Updates matching rows and yields the updated instances. |
| `delete_items` | `async (filter_=None) -> None` | Deletes matching rows. |
| `transaction` | `async contextmanager () -> AsyncSession` | Explicit transaction scope. Automatically used by all CRUD methods. Reuses an existing session when nested. |
| `nested_transaction` | `async contextmanager () -> AsyncSession` | Creates a savepoint (nested transaction). Rolls back only the inner scope on error while leaving the outer transaction intact. |
## Single item retrieval
`get_item(filter_=..., sort=...)` returns the first matching record (or `None` if no records match). It delegates to `get_items` under the hood:
```python
user = await user_repository.get_item(filter_=UserFilter(email="alice@example.com"))
if user is not None:
print(user.name)
```
## Eager loading (options)
`get_items()` and `update_items()` accept an optional `options` parameter for SQLAlchemy eager loading strategies such as `joinedload` or `selectinload`:
```python
from sqlalchemy.orm import joinedload
books = [
item
async for item in book_repository.get_items(
options=[joinedload(BookTable.author)],
)
]
```

64
docs/guide/tables-dto.md Normal file
View File

@@ -0,0 +1,64 @@
# Tables & DTOs
## BaseTable
`BaseTable[ItemType]` is a generic `SQLModel` subclass that acts as the database table. It is the bridge between your database and your application code.
### Without DTO mapping
If you don't need a separate data-transfer object, use `BaseTable` directly:
```python
class UserTable(BaseTable, table=True):
__tablename__ = "users"
id: int | None = Field(default=None, primary_key=True)
name: str
email: str = Field(unique=True)
```
Repository methods will return `UserTable` instances directly.
### With DTO mapping
When you want repository methods to return a separate Pydantic model instead of the raw table, specify a generic argument and implement `from_item` / `to_item`:
```python
from pydantic import BaseModel
from metaorm import BaseTable, Field
class User(BaseModel):
id: int | None = None
name: str
email: str
class UserTable(BaseTable[User], table=True):
__tablename__ = "users"
id: int | None = Field(default=None, primary_key=True)
name: str
email: str = Field(unique=True)
@classmethod
def from_item(cls, item: User) -> "UserTable":
return cls(id=item.id, name=item.name, email=item.email)
def to_item(self) -> User:
return User(id=self.id, name=self.name, email=self.email)
```
Then pass `dto=User` to the repository:
```python
class UserRepository(BaseRepository, table=UserTable, filter_=UserFilter, dto=User):
pass
```
`BaseTable` also provides `to_values()` for insert / update operations.
## Introspection helpers
- `get_table_type()` — returns the SQLModel table class.
- `get_filter_type()` — returns the filter class.
- `get_dto_type()` — returns the DTO class or `None`.

View File

@@ -0,0 +1,41 @@
# Transactions
Every repository method (`get_items`, `create_item`, etc.) wraps its operation in a transaction via `self.transaction()`. `self.transaction()` reuses an existing session from the context if one exists, otherwise creates a new one.
## Explicit transaction
You can open an explicit transaction when you need to group several operations:
```python
async with repository.transaction():
product1 = await repository.create_item(ProductTable(name="Laptop", price=999.99))
product2 = await repository.create_item(ProductTable(name="Mouse", price=29.99))
```
If any operation raises an exception, the entire transaction is rolled back.
## Reusing an existing session
Nested `transaction()` calls yield the same session — no new savepoint is created:
```python
async with repository.transaction(), repository.transaction():
items = [item async for item in repository.get_items()]
```
## Nested transactions (savepoints)
`nested_transaction()` creates a SQLAlchemy savepoint. When no outer session exists it starts a new session with a savepoint. On exception the savepoint is rolled back, leaving any outer transaction unaffected:
```python
try:
async with repository.nested_transaction():
await repository.create_item(ProductTable(name="Keyboard", price=79.99))
raise ValueError("Rollback nested")
except ValueError:
pass
# Keyboard was rolled back; previous items remain
```
This is useful for partial rollback inside a larger transaction. See [Multi-Repo Transactions](container.md) for container-level savepoints.

63
docs/index.md Normal file
View File

@@ -0,0 +1,63 @@
# MetaORM
Async repository layer over [SQLModel](https://sqlmodel.tiangolo.com). Define a table, a repository with keyword arguments, and you have a complete async CRUD layer.
- **Minimal API** — `create_item`, `get_items`, `update_items`, `delete_items`. That's it.
- **Built-in DTO mapping** — return table instances directly or map to separate Pydantic models.
- **Intuitive transactions** — every CRUD call runs in a transaction; explicit `transaction()` context manager for custom scopes.
- **Nested transactions (savepoints)** — `nested_transaction()` allows partial rollback inside a shared transaction.
- **Multi-repo atomic transactions** — `RepositoriesContainer` lets several repositories share one atomic transaction.
- **Filters, pagination, sorting** — powered by `pydantic-filters`.
- **Eager loading** — pass SQLAlchemy `joinedload` / `selectinload` via `options`.
## 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` that is not yet available on PyPI.
## Quick start
```python
from metaorm import BaseFilter, BaseRepository, BaseTable, RepositorySettings, Field
class UserTable(BaseTable, table=True):
__tablename__ = "users"
id: int | None = Field(default=None, primary_key=True)
name: str
email: str = Field(unique=True)
class UserFilter(BaseFilter):
name: str | None = None
email: str | None = None
class UserRepository(BaseRepository, table=UserTable, filter_=UserFilter):
pass
async def main():
repo = UserRepository(
settings=RepositorySettings(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))
```
## Next steps
- Read the [User Guide](guide/getting-started.md) for detailed explanations.
- Browse the [API Reference](api.md) for auto-generated docs.
- Explore [Examples](examples.md) for common patterns.