docs: add MkDocs documentation with Material theme and GitHub Pages deploy
Some checks failed
docs / deploy (push) Has been cancelled
Some checks failed
docs / deploy (push) Has been cancelled
This commit is contained in:
48
docs/api.md
Normal file
48
docs/api.md
Normal 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
35
docs/examples.md
Normal 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
53
docs/guide/container.md
Normal 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
55
docs/guide/filters.md
Normal 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"),
|
||||
)
|
||||
]
|
||||
```
|
||||
79
docs/guide/getting-started.md
Normal file
79
docs/guide/getting-started.md
Normal 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.
|
||||
60
docs/guide/relationships.md
Normal file
60
docs/guide/relationships.md
Normal 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.
|
||||
52
docs/guide/repositories.md
Normal file
52
docs/guide/repositories.md
Normal 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
64
docs/guide/tables-dto.md
Normal 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`.
|
||||
41
docs/guide/transactions.md
Normal file
41
docs/guide/transactions.md
Normal 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
63
docs/index.md
Normal 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.
|
||||
Reference in New Issue
Block a user