refactor: migrate to keyword-based repository config, add nested transactions and tests

This commit is contained in:
2026-08-14 19:58:32 +03:00
parent 3d85c2b5dd
commit 7abb513b30
22 changed files with 658 additions and 324 deletions

1
.gitignore vendored
View File

@@ -1,6 +1,7 @@
# Byte-compiled / optimized / DLL files
__pycache__/
.pytest_cache/
.ruff_cache/
*.py[cod]
*$py.class

View File

@@ -10,12 +10,13 @@ metaorm/
repositories.py # BaseRepository
tables.py # BaseTable
container.py # RepositoriesContainer (session/transaction manager)
settings.py # DatabaseSettings (Pydantic model)
settings.py # RepositorySettings (Pydantic model)
exceptions.py # Domain exceptions
examples/ # Usage examples
basic_usage.py # Simple CRUD with tables directly
dto_usage.py # DTO mapping via get_dto_type()
dto_usage.py # DTO mapping via dto= keyword
transactions.py # Explicit transaction management
nested_transactions.py # Savepoints and partial rollback
filter_usage.py # Query filters, pagination and sorting
relationships.py # Eager loading with joinedload/selectinload
container_usage.py # Multi-repository atomic transactions
@@ -49,20 +50,33 @@ If `ItemType` is not specified (e.g. `BaseTable` without generic arg), `from_ite
### BaseRepository
`BaseRepository` is **not** a Generic class. Type behavior is controlled by overriding methods:
`BaseRepository` uses `__init_subclass__` to enforce keyword arguments at class-definition time.
- `table` — required. The SQLModel table class. Must be specified on the first concrete subclass; intermediate bases that already specify it do not need to repeat it.
- `filter_` — required. A `BaseFilter` subclass.
- `dto` — optional. When provided, repository methods map table rows to that DTO type.
`table=` must be provided on the first subclass in the hierarchy.
The following introspection helpers are available as classmethods:
- `get_table_type()` — returns the `table` class specified at definition time.
- `get_filter_type()` — returns the `filter_` class.
- `get_dto_type()` — returns the `dto` class, or `None` if no DTO was set.
```python
class UserRepository(BaseRepository):
def get_db_table(self) -> type[UserTable]:
return UserTable
def get_dto_type(self) -> type[User] | None:
return User # Methods return User instances
class UserRepository(BaseRepository, table=UserTable, filter_=UserFilter, dto=User):
pass # Methods return User instances
```
If `get_dto_type()` returns `None` (default), repository methods return table instances directly (no DTO conversion). This is the simplest mode when you don't need a separate DTO layer.
If `dto` is omitted, repository methods return table instances directly:
If `get_filter_type()` returns a `BaseFilter` subclass, type hints on `filter_` parameters reflect that type. Note: `pydantic-filters` from GitHub is required for filter support (PyPI version is broken with pydantic v2).
```python
class ProductRepository(BaseRepository, table=ProductTable, filter_=ProductFilter):
pass # Methods return ProductTable instances
```
Note: `pydantic-filters` from GitHub is required for filter support (PyPI version is broken with pydantic v2). The project currently uses a fork with Python 3.14 lazy-annotations support: `git+https://github.com/OlegYurchik/pydantic-filters.git@fix/compare-to-pydantic-2.12`.
#### Eager loading (options)
@@ -83,7 +97,7 @@ books = [
```python
# Simple: create container internally
repo = UserRepository(settings=DatabaseSettings(dsn="sqlite+aiosqlite:///:memory:"))
repo = UserRepository(settings=RepositorySettings(dsn="sqlite+aiosqlite:///:memory:"))
# Advanced: reuse container for shared transactions
container = RepositoriesContainer(settings=settings)
@@ -110,7 +124,8 @@ async with container.transaction():
- Each 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.
- Accessing `repository.session` outside a transaction raises `HaveNoSessionError`.
- `repository.session` and `container.session` both return the current `AsyncSession` or `None` if no session is active. Both `repository.transaction()` and `container.transaction()` yield the `AsyncSession` and handle nested calls by reusing the same session.
- `repository.nested_transaction()` and `container.nested_transaction()` create a SQLAlchemy savepoint (`begin_nested()`). When no outer session exists they start a new session with a savepoint. On exception the savepoint is rolled back, leaving any outer transaction unaffected.
## Development

167
README.md
View File

@@ -1,6 +1,14 @@
# 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`.
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
@@ -14,11 +22,8 @@ Requires Python `>=3.12`.
## 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
from metaorm import BaseFilter, BaseRepository, BaseTable, RepositorySettings, Field
class UserTable(BaseTable, table=True):
@@ -28,129 +33,68 @@ class UserTable(BaseTable, table=True):
email: str = Field(unique=True)
class UserRepository(BaseRepository):
def get_db_table(self) -> type[UserTable]:
return UserTable
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=DatabaseSettings(dsn="sqlite+aiosqlite:///:memory:"),
settings=RepositorySettings(dsn="sqlite+aiosqlite:///:memory:"),
)
await repo.create_tables()
user = await repo.create_item(
UserTable(name="Alice", email="alice@example.com"),
)
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
## Repository API
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:
Subclass `BaseRepository` with keyword arguments `table`, `filter_`, and optionally `dto`:
```python
from pydantic import BaseModel
from sqlmodel import Field
from metaorm import BaseRepository, BaseTable, DatabaseSettings
class MyRepository(BaseRepository, table=MyTable, filter_=MyFilter):
pass # returns table instances directly
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())
class MyRepositoryWithDto(BaseRepository, table=MyTable, filter_=MyFilter, dto=MyDto):
pass # maps rows to MyDto
```
## Filters, pagination and sorting
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.
`pydantic-filters` provides `BaseFilter`, `BasePagination` and `BaseSort`. Pass them to `get_items`:
### Constructor
```python
from pydantic_filters import BaseFilter, BaseSort, OffsetPagination
# Simple — container is created internally
repo = MyRepository(settings=RepositorySettings(dsn="..."))
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"),
)
]
# Advanced — share a container for atomic multi-repo transactions
container = RepositoriesContainer(settings=settings)
repo = MyRepository(container=container)
```
## Explicit transactions
### Methods
Each repository method already runs inside a transaction automatically. If you need an explicit scope (e.g. to read `repository.session`), use `repository.transaction()`:
| 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. |
```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
### Multi-repository transactions
Use `RepositoriesContainer` when you need a single atomic transaction spanning multiple repositories:
@@ -166,22 +110,21 @@ async with container.transaction():
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.
`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.
## Eager loading
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.
`get_items()` and `update_items()` accept an optional `options` parameter for SQLAlchemy eager loading strategies:
## More examples
```python
from sqlalchemy.orm import joinedload
See [`examples/`](examples/) for detailed usage patterns:
books = [
item
async for item in book_repo.get_items(
options=[joinedload(BookTable.author)],
)
]
```
- [`basic_usage.py`](examples/basic_usage.py) — CRUD with tables directly
- [`dto_usage.py`](examples/dto_usage.py) — DTO mapping via `dto=` keyword
- [`transactions.py`](examples/transactions.py) — Explicit transaction management
- [`nested_transactions.py`](examples/nested_transactions.py) — Savepoints and partial rollback
- [`filter_usage.py`](examples/filter_usage.py) — Query filters, pagination and sorting
- [`relationships.py`](examples/relationships.py) — Eager loading with `joinedload`
- [`container_usage.py`](examples/container_usage.py) — Multi-repository atomic transactions
## Exceptions

View File

@@ -1,8 +1,6 @@
import asyncio
from sqlmodel import Field
from metaorm import BaseRepository, BaseTable, DatabaseSettings
from metaorm import BaseFilter, BaseRepository, BaseTable, Field, RepositorySettings
class UserTable(BaseTable, table=True):
@@ -13,13 +11,17 @@ class UserTable(BaseTable, table=True):
email: str = Field(unique=True)
class UserRepository(BaseRepository):
def get_db_table(self) -> type[UserTable]:
return UserTable
class UserFilter(BaseFilter):
name: str | None = None
email: str | None = None
class UserRepository(BaseRepository, table=UserTable, filter_=UserFilter):
pass
async def main() -> None:
settings = DatabaseSettings(dsn="sqlite+aiosqlite:///:memory:")
settings = RepositorySettings(dsn="sqlite+aiosqlite:///:memory:")
repository = UserRepository(settings=settings)
await repository.create_tables()

View File

@@ -1,8 +1,13 @@
import asyncio
from sqlmodel import Field
from metaorm import BaseRepository, BaseTable, DatabaseSettings, RepositoriesContainer
from metaorm import (
BaseFilter,
BaseRepository,
BaseTable,
Field,
RepositoriesContainer,
RepositorySettings,
)
class UserTable(BaseTable, table=True):
@@ -20,18 +25,24 @@ class OrderTable(BaseTable, table=True):
total: float
class UserRepository(BaseRepository):
def get_db_table(self) -> type[UserTable]:
return UserTable
class UserFilter(BaseFilter):
name: str | None = None
class OrderRepository(BaseRepository):
def get_db_table(self) -> type[OrderTable]:
return OrderTable
class OrderFilter(BaseFilter):
user_id: int | None = None
class UserRepository(BaseRepository, table=UserTable, filter_=UserFilter):
pass
class OrderRepository(BaseRepository, table=OrderTable, filter_=OrderFilter):
pass
async def main() -> None:
settings = DatabaseSettings(dsn="sqlite+aiosqlite:///:memory:")
settings = RepositorySettings(dsn="sqlite+aiosqlite:///:memory:")
container = RepositoriesContainer(settings=settings)
user_repo = container.get_repository(UserRepository)
@@ -46,6 +57,19 @@ async def main() -> None:
await order_repo.create_item(OrderTable(user_id=user.id, total=100.00))
await order_repo.create_item(OrderTable(user_id=user.id, total=250.50))
# Nested transaction inside outer transaction (savepoint)
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
# Verify results
users = [item async for item in user_repo.get_items()]
orders = [item async for item in order_repo.get_items()]

View File

@@ -1,9 +1,8 @@
import asyncio
from pydantic import BaseModel
from sqlmodel import Field
from metaorm import BaseRepository, BaseTable, DatabaseSettings
from metaorm import BaseFilter, BaseRepository, BaseTable, Field, RepositorySettings
class User(BaseModel):
@@ -27,16 +26,17 @@ class UserTable(BaseTable[User], table=True):
return User(id=self.id, name=self.name, email=self.email)
class UserRepository(BaseRepository):
def get_db_table(self) -> type[UserTable]:
return UserTable
class UserFilter(BaseFilter):
name: str | None = None
email: str | None = None
def get_dto_type(self) -> type[User]:
return User
class UserRepository(BaseRepository, table=UserTable, filter_=UserFilter, dto=User):
pass
async def main() -> None:
settings = DatabaseSettings(dsn="sqlite+aiosqlite:///:memory:")
settings = RepositorySettings(dsn="sqlite+aiosqlite:///:memory:")
repository = UserRepository(settings=settings)
await repository.create_tables()

View File

@@ -1,9 +1,14 @@
import asyncio
from pydantic_filters import BaseFilter, BaseSort, OffsetPagination
from sqlmodel import Field
from metaorm import BaseRepository, BaseTable, DatabaseSettings
from metaorm import (
BaseFilter,
BaseRepository,
BaseSort,
BaseTable,
Field,
OffsetPagination,
RepositorySettings,
)
class BookTable(BaseTable, table=True):
@@ -19,16 +24,12 @@ class BookFilter(BaseFilter):
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
class BookRepository(BaseRepository, table=BookTable, filter_=BookFilter):
pass
async def main() -> None:
settings = DatabaseSettings(dsn="sqlite+aiosqlite:///:memory:")
settings = RepositorySettings(dsn="sqlite+aiosqlite:///:memory:")
repository = BookRepository(settings=settings)
await repository.create_tables()

View File

@@ -0,0 +1,64 @@
import asyncio
from metaorm import BaseFilter, BaseRepository, BaseTable, Field, RepositorySettings
class ProductTable(BaseTable, table=True):
__tablename__ = "products"
id: int | None = Field(default=None, primary_key=True)
name: str
price: float
class ProductFilter(BaseFilter):
name: str | None = None
class ProductRepository(BaseRepository, table=ProductTable, filter_=ProductFilter):
pass
async def main() -> None:
settings = RepositorySettings(dsn="sqlite+aiosqlite:///:memory:")
repository = ProductRepository(settings=settings)
await repository.create_tables()
# Standalone nested transaction: rollback only the inner scope
try:
async with repository.nested_transaction():
await repository.create_item(ProductTable(name="Laptop", price=999.99))
await repository.create_item(ProductTable(name="Mouse", price=29.99))
raise ValueError("Simulated error inside nested transaction")
except ValueError:
pass
count = await repository.get_items_count()
print(f"Items after standalone nested rollback: {count}") # 0
# Nested transaction inside an outer transaction
async with repository.transaction():
await repository.create_item(ProductTable(name="Keyboard", price=79.99))
try:
async with repository.nested_transaction():
await repository.create_item(
ProductTable(name="Monitor", price=299.99),
)
raise ValueError("Nested rollback")
except ValueError:
pass
# Monitor is rolled back, Keyboard stays in the outer transaction
items = [item async for item in repository.get_items()]
print(f"Items after partial rollback: {len(items)}") # 1
print(items[0].name) # Keyboard
# Verify committed results
all_items = [item async for item in repository.get_items()]
print(f"Final items: {[item.name for item in all_items]}") # ["Keyboard"]
if __name__ == "__main__":
asyncio.run(main())

View File

@@ -1,9 +1,16 @@
import asyncio
from sqlalchemy.orm import joinedload
from sqlmodel import Field, Relationship
from metaorm import BaseRepository, BaseTable, DatabaseSettings, RepositoriesContainer
from metaorm import (
BaseFilter,
BaseRepository,
BaseTable,
Field,
Relationship,
RepositoriesContainer,
RepositorySettings,
)
class AuthorTable(BaseTable, table=True):
@@ -23,18 +30,25 @@ class BookTable(BaseTable, table=True):
author: AuthorTable = Relationship(back_populates="books")
class BookRepository(BaseRepository):
def get_db_table(self) -> type[BookTable]:
return BookTable
class BookFilter(BaseFilter):
title: str | None = None
author_id: int | None = None
class AuthorRepository(BaseRepository):
def get_db_table(self) -> type[AuthorTable]:
return AuthorTable
class AuthorFilter(BaseFilter):
name: str | None = None
class BookRepository(BaseRepository, table=BookTable, filter_=BookFilter):
pass
class AuthorRepository(BaseRepository, table=AuthorTable, filter_=AuthorFilter):
pass
async def main() -> None:
settings = DatabaseSettings(dsn="sqlite+aiosqlite:///:memory:")
settings = RepositorySettings(dsn="sqlite+aiosqlite:///:memory:")
container = RepositoriesContainer(settings=settings)
author_repo = AuthorRepository(container=container)

View File

@@ -1,8 +1,6 @@
import asyncio
from sqlmodel import Field
from metaorm import BaseRepository, BaseTable, DatabaseSettings
from metaorm import BaseFilter, BaseRepository, BaseTable, Field, RepositorySettings
class ProductTable(BaseTable, table=True):
@@ -13,13 +11,17 @@ class ProductTable(BaseTable, table=True):
price: float
class ProductRepository(BaseRepository):
def get_db_table(self) -> type[ProductTable]:
return ProductTable
class ProductFilter(BaseFilter):
name: str | None = None
price: int | None = None
class ProductRepository(BaseRepository, table=ProductTable, filter_=ProductFilter):
pass
async def main() -> None:
settings = DatabaseSettings(dsn="sqlite+aiosqlite:///:memory:")
settings = RepositorySettings(dsn="sqlite+aiosqlite:///:memory:")
repository = ProductRepository(settings=settings)
await repository.create_tables()
@@ -34,10 +36,23 @@ async def main() -> None:
)
print(f"Created in transaction: {product1.name}, {product2.name}")
# Nested transaction reuses existing session
# Reusing an existing session (no new savepoint)
async with repository.transaction(), repository.transaction():
items = [item async for item in repository.get_items()]
print(f"Items in nested transaction: {len(items)}")
print(f"Items in reused session: {len(items)}")
# True nested transaction (savepoint) via repository
try:
async with repository.nested_transaction():
await repository.create_item(
ProductTable(name="Keyboard", price=79.99),
)
raise ValueError("Rollback nested")
except ValueError:
pass
count = await repository.get_items_count()
print(f"Items after nested rollback: {count}") # 2
if __name__ == "__main__":

View File

@@ -1,3 +1,12 @@
from pydantic_filters import (
BaseFilter,
BasePagination,
BaseSort,
OffsetPagination,
PagePagination,
)
from sqlmodel import Field, Relationship
from .container import RepositoriesContainer
from .exceptions import (
AlreadyExistsError,
@@ -6,10 +15,19 @@ from .exceptions import (
NotFoundError,
)
from .repositories import BaseRepository
from .settings import DatabaseSettings
from .settings import RepositorySettings
from .tables import BaseTable
__all__ = (
# pydantic-filters
"BaseFilter",
"BasePagination",
"BaseSort",
"OffsetPagination",
"PagePagination",
# sqlmodel
"Field",
"Relationship",
# container
"RepositoriesContainer",
# exceptions
@@ -20,7 +38,7 @@ __all__ = (
# repositories
"BaseRepository",
# settings
"DatabaseSettings",
"RepositorySettings",
# tables
"BaseTable",
)

View File

@@ -6,13 +6,13 @@ from typing import TypeVar
from sqlalchemy.ext.asyncio import AsyncEngine, create_async_engine
from sqlmodel.ext.asyncio.session import AsyncSession
from .settings import DatabaseSettings
from .settings import RepositorySettings
RepositoryType = TypeVar("RepositoryType", bound="BaseRepository") # noqa: F821
class RepositoriesContainer:
def __init__(self, settings: DatabaseSettings):
def __init__(self, settings: RepositorySettings):
engine_parameters = {
"url": settings.dsn,
"pool_recycle": settings.pool_recycle,
@@ -37,7 +37,7 @@ class RepositoriesContainer:
@asynccontextmanager
async def transaction(self) -> AsyncGenerator[AsyncSession, None]:
existing_session = self._session_context.get()
existing_session = self._session_context.get(None)
if existing_session is not None:
yield existing_session
return
@@ -54,5 +54,25 @@ class RepositoriesContainer:
finally:
self._session_context.reset(token)
@asynccontextmanager
async def nested_transaction(self) -> AsyncGenerator[AsyncSession, None]:
existing_session = self._session_context.get(None)
if existing_session is not None:
async with existing_session.begin_nested():
yield existing_session
return
session_parameters = {
"bind": self._engine,
"expire_on_commit": False,
}
async with AsyncSession(**session_parameters) as session:
token = self._session_context.set(session)
try:
async with session.begin_nested():
yield session
finally:
self._session_context.reset(token)
def get_repository(self, repository_class: type[RepositoryType]) -> RepositoryType:
return repository_class(container=self)

View File

@@ -1,26 +1,46 @@
from collections.abc import AsyncGenerator, Sequence
from contextlib import asynccontextmanager
from typing import Any
from pydantic import BaseModel
from pydantic_filters import BaseFilter, BasePagination, BaseSort
from pydantic_filters.drivers.sqlalchemy import append_to_statement
from pydantic_filters.filter._fields import FilterFieldInfo
from sqlalchemy import func
from sqlalchemy.exc import IntegrityError
from sqlmodel import delete, insert, select, update
from sqlmodel.ext.asyncio.session import AsyncSession
from .container import RepositoriesContainer
from .exceptions import AlreadyExistsError, DatabaseException, HaveNoSessionError
from .settings import DatabaseSettings
from .exceptions import AlreadyExistsError, DatabaseException
from .settings import RepositorySettings
from .tables import BaseTable
class BaseRepository:
def __init_subclass__(
cls,
table: type[BaseTable] | None = None,
filter_: type[BaseFilter] | None = None,
dto: type[BaseModel] | None = None,
**kwargs,
):
super().__init_subclass__(**kwargs)
if (table := table or getattr(cls, "_table_type", None)) is None:
raise TypeError(
f"{cls.__name__} must specify 'table' keyword argument",
)
if (filter_ := filter_ or getattr(cls, "_filter_type", None)) is None:
raise TypeError(
f"{cls.__name__} must specify 'filter_' keyword argument",
)
cls._table_type = table
cls._filter_type = filter_
cls._dto_type = dto or getattr(cls, "_dto_type", None)
def __init__(
self,
settings: DatabaseSettings | None = None,
settings: RepositorySettings | None = None,
container: RepositoriesContainer | None = None,
):
if container is not None:
@@ -30,47 +50,11 @@ class BaseRepository:
else:
raise TypeError("Either 'container' or 'settings' must be provided")
def get_db_table(self) -> type[BaseTable]:
raise NotImplementedError
def get_dto_type(self) -> type[BaseModel] | None:
return None
def get_filter_type(self) -> type[BaseFilter] | None:
return None
@property
def session(self) -> AsyncSession:
session = self._container.session
if session is None:
raise HaveNoSessionError()
return session
@asynccontextmanager
async def transaction(self) -> AsyncGenerator[None, None]:
existing_session = self._container.session
if existing_session is not None:
yield
return
async with self._container.transaction():
yield
async def create_tables(self) -> None:
table = self.get_db_table()
async with self._container.engine.begin() as connection:
await connection.run_sync(
table.metadata.create_all,
tables=[table.__table__],
)
async def get_items_count(
self,
filter_: BaseFilter | None = None,
) -> int:
if filter_ is not None:
self._ensure_filter_fields(type(filter_))
table = self.get_db_table()
table = self.get_table_type()
statement = select(func.count()).select_from(table)
statement = append_to_statement(
statement=statement,
@@ -90,10 +74,8 @@ class BaseRepository:
pagination: BasePagination | None = None,
sort: BaseSort | None = None,
options: Sequence[Any] | None = None,
) -> AsyncGenerator[BaseModel]:
if filter_ is not None:
self._ensure_filter_fields(type(filter_))
table = self.get_db_table()
) -> AsyncGenerator[Any]:
table = self.get_table_type()
statement = select(table)
statement = append_to_statement(
statement=statement,
@@ -106,13 +88,13 @@ class BaseRepository:
statement = statement.options(*options)
async with self.transaction():
result = await self.session.execute(statement)
result = await self.session.exec(statement)
result = result.yield_per(100)
for db_item in result.scalars():
for db_item in result:
yield self._convert_from_table(db_item)
async def create_item(self, item: BaseModel) -> BaseModel:
table = self.get_db_table()
table = self.get_table_type()
values = self._convert_to_table(item).to_values()
statement = insert(table).values(values).returning(table)
@@ -138,9 +120,7 @@ class BaseRepository:
options: Sequence[Any] | None = None,
**values,
) -> AsyncGenerator[BaseModel]:
if filter_ is not None:
self._ensure_filter_fields(type(filter_))
table = self.get_db_table()
table = self.get_table_type()
statement = update(table)
statement = append_to_statement(
statement=statement,
@@ -161,9 +141,7 @@ class BaseRepository:
self,
filter_: BaseFilter | None = None,
) -> None:
if filter_ is not None:
self._ensure_filter_fields(type(filter_))
table = self.get_db_table()
table = self.get_table_type()
statement = delete(table)
statement = append_to_statement(
statement=statement,
@@ -174,41 +152,45 @@ class BaseRepository:
async with self.transaction():
await self.session.exec(statement)
@property
def session(self) -> AsyncSession | None:
return self._container.session
@property
def transaction(self):
return self._container.transaction
@property
def nested_transaction(self):
return self._container.nested_transaction
async def create_tables(self) -> None:
table = self.get_table_type()
async with self._container.engine.begin() as connection:
await connection.run_sync(
table.metadata.create_all,
tables=[table.__table__],
)
def _convert_to_table(self, item: BaseModel) -> BaseModel:
table = self.get_db_table()
table = self.get_table_type()
if isinstance(item, table):
return item
return table.from_item(item=item)
def _convert_from_table(self, table: BaseTable) -> BaseModel:
dto_type = self.get_dto_type()
if dto_type is None:
if self.get_dto_type() is None:
return table
return table.to_item()
def _ensure_filter_fields(self, filter_class: type[BaseFilter]) -> None:
"""Workaround for pydantic-filters not registering filter_fields with pydantic v2."""
if getattr(filter_class, "filter_fields", None):
return
@classmethod
def get_table_type(cls) -> type[BaseTable] | None:
return cls._table_type
filter_fields: dict[str, FilterFieldInfo] = {}
for field_name, field_info in filter_class.model_fields.items():
if field_info.annotation is None:
continue
annotation = field_info.annotation
# unwrap Optional[X] -> X
origin = getattr(annotation, "__origin__", None)
if origin is type | None:
args = getattr(annotation, "__args__", ())
if args and args[0] is not type(None):
annotation = args[0]
@classmethod
def get_filter_type(cls) -> type[BaseFilter] | None:
return cls._filter_type
is_sequence = hasattr(
annotation, "__origin__"
) and annotation.__origin__ in (list, set)
filter_fields[field_name] = FilterFieldInfo(
target=field_name,
type_="eq",
is_sequence=is_sequence,
)
filter_class.filter_fields = filter_fields
@classmethod
def get_dto_type(cls) -> type[BaseModel] | None:
return cls._dto_type

View File

@@ -1,7 +1,7 @@
from pydantic import BaseModel, Field
class DatabaseSettings(BaseModel):
class RepositorySettings(BaseModel):
dsn: str = Field(default="sqlite+aiosqlite:///db.sqlite3", pattern=r"^.+://")
pool_size: int = Field(default=5, ge=1)
pool_recycle: int = Field(default=60, ge=1) # in seconds: 1 minute

View File

@@ -1,10 +1,6 @@
[build-system]
requires = ["setuptools>=61.0", "wheel"]
build-backend = "setuptools.build_meta"
[project]
name = "metaorm"
version = "0.2.0"
version = "0.3.0"
description = "Async repository layer over SQLModel"
readme = "README.md"
license = {text = "MIT"}
@@ -32,7 +28,7 @@ classifiers = [
]
dependencies = [
"pydantic>=2.0",
"pydantic-filters @ git+https://github.com/so-saf/pydantic-filters.git",
"pydantic-filters @ git+https://github.com/OlegYurchik/pydantic-filters.git@fix/compare-to-pydantic-2.12",
"sqlalchemy>=2.0",
"sqlmodel>=0.0.22",
]
@@ -42,6 +38,10 @@ Homepage = "https://github.com/OlegYurchik/metaorm"
Repository = "https://github.com/OlegYurchik/metaorm"
Issues = "https://github.com/OlegYurchik/metaorm/issues"
[build-system]
requires = ["setuptools>=61.0", "wheel"]
build-backend = "setuptools.build_meta"
[dependency-groups]
dev = [
"aiosqlite>=0.22.0",
@@ -67,4 +67,4 @@ fail_under = 90
show_missing = true
[tool.uv.sources]
pydantic-filters = { git = "https://github.com/so-saf/pydantic-filters" }
pydantic-filters = { git = "https://github.com/OlegYurchik/pydantic-filters", branch = "fix/compare-to-pydantic-2.12" }

View File

@@ -3,7 +3,7 @@ from collections.abc import AsyncGenerator
import pytest
import pytest_asyncio
from metaorm import DatabaseSettings, RepositoriesContainer
from metaorm import RepositoriesContainer, RepositorySettings
from .models import (
AuthorRepository,
@@ -14,8 +14,8 @@ from .models import (
@pytest.fixture
def database_settings() -> DatabaseSettings:
return DatabaseSettings(
def database_settings() -> RepositorySettings:
return RepositorySettings(
dsn="sqlite+aiosqlite:///:memory:",
pool_size=1,
pool_recycle=60,
@@ -25,7 +25,7 @@ def database_settings() -> DatabaseSettings:
@pytest_asyncio.fixture
async def repositories_container(
database_settings: DatabaseSettings,
database_settings: RepositorySettings,
) -> AsyncGenerator[RepositoriesContainer, None]:
container = RepositoriesContainer(settings=database_settings)
yield container
@@ -43,7 +43,7 @@ async def user_repository(
@pytest_asyncio.fixture
async def product_repository_settings(
database_settings: DatabaseSettings,
database_settings: RepositorySettings,
) -> AsyncGenerator[ProductRepository, None]:
repository = ProductRepository(settings=database_settings)
await repository.create_tables()

View File

@@ -1,8 +1,6 @@
from pydantic import BaseModel
from pydantic_filters import BaseFilter
from sqlmodel import Field, Relationship
from metaorm import BaseRepository, BaseTable
from metaorm import BaseFilter, BaseRepository, BaseTable, Field, Relationship
class User(BaseModel):
@@ -25,12 +23,13 @@ class UserTable(BaseTable[User], table=True):
return User(id=self.id, name=self.name, email=self.email)
class UserRepository(BaseRepository):
def get_db_table(self) -> type[UserTable]:
return UserTable
class UserFilter(BaseFilter):
name: str | None = None
email: str | None = None
def get_dto_type(self) -> type[User]:
return User
class UserRepository(BaseRepository, table=UserTable, filter_=UserFilter, dto=User):
pass
class ProductTable(BaseTable, table=True):
@@ -45,12 +44,8 @@ class ProductFilter(BaseFilter):
price: int | None = None
class ProductRepository(BaseRepository):
def get_db_table(self) -> type[ProductTable]:
return ProductTable
def get_filter_type(self) -> type[ProductFilter]:
return ProductFilter
class ProductRepository(BaseRepository, table=ProductTable, filter_=ProductFilter):
pass
class AuthorTable(BaseTable, table=True):
@@ -68,11 +63,18 @@ class BookTable(BaseTable, table=True):
author: AuthorTable = Relationship(back_populates="books")
class AuthorRepository(BaseRepository):
def get_db_table(self) -> type[AuthorTable]:
return AuthorTable
class AuthorFilter(BaseFilter):
name: str | None = None
class BookRepository(BaseRepository):
def get_db_table(self) -> type[BookTable]:
return BookTable
class BookFilter(BaseFilter):
title: str | None = None
author_id: int | None = None
class AuthorRepository(BaseRepository, table=AuthorTable, filter_=AuthorFilter):
pass
class BookRepository(BaseRepository, table=BookTable, filter_=BookFilter):
pass

View File

@@ -1,7 +1,8 @@
import pytest
from sqlalchemy.ext.asyncio import AsyncEngine
from metaorm import RepositoriesContainer
from tests.models import UserRepository
from tests.models import User, UserRepository
class TestRepositoriesContainer:
@@ -39,6 +40,69 @@ class TestRepositoriesContainer:
):
assert inner_session is outer_session
async def test_nested_transaction_creates_session(
self,
repositories_container: RepositoriesContainer,
) -> None:
assert repositories_container.session is None
async with repositories_container.nested_transaction() as session:
assert session is not None
assert repositories_container.session is session
assert repositories_container.session is None
async def test_nested_transaction_reuses_outer_session(
self,
repositories_container: RepositoriesContainer,
) -> None:
async with (
repositories_container.transaction() as outer_session,
repositories_container.nested_transaction() as inner_session,
):
assert inner_session is outer_session
async def test_nested_transaction_rollbacks_on_exception(
self,
repositories_container: RepositoriesContainer,
) -> None:
repository = repositories_container.get_repository(UserRepository)
await repository.create_tables()
with pytest.raises(ValueError):
async with repositories_container.nested_transaction():
await repository.create_item(
User(name="Alice", email="alice@example.com"),
)
raise ValueError("boom")
count = await repository.get_items_count()
assert count == 0
async def test_nested_transaction_in_outer_transaction_rollbacks_only_inner(
self,
repositories_container: RepositoriesContainer,
) -> None:
repository = repositories_container.get_repository(UserRepository)
await repository.create_tables()
async with repositories_container.transaction():
await repository.create_item(
User(name="Bob", email="bob@example.com"),
)
with pytest.raises(ValueError):
async with repositories_container.nested_transaction():
await repository.create_item(
User(name="Alice", email="alice@example.com"),
)
raise ValueError("boom")
count = await repository.get_items_count()
assert count == 1
items = [item async for item in repository.get_items()]
assert items[0].name == "Bob"
async def test_get_repository_returns_repository_instance(
self,
repositories_container: RepositoriesContainer,

View File

@@ -1,8 +1,13 @@
import pytest
from pydantic_filters import BaseSort, OffsetPagination
from sqlalchemy.orm import joinedload
from metaorm import AlreadyExistsError, DatabaseSettings, HaveNoSessionError
from metaorm import (
AlreadyExistsError,
BaseRepository,
BaseSort,
OffsetPagination,
RepositorySettings,
)
from tests.models import (
AuthorRepository,
AuthorTable,
@@ -12,18 +17,13 @@ from tests.models import (
ProductRepository,
ProductTable,
User,
UserFilter,
UserRepository,
UserTable,
)
class TestBaseRepository:
async def test_session_raises_error_without_transaction(
self,
user_repository: UserRepository,
) -> None:
with pytest.raises(HaveNoSessionError):
_ = user_repository.session
async def test_create_item(self, user_repository: UserRepository) -> None:
user = User(name="Alice", email="alice@example.com")
@@ -93,12 +93,29 @@ class TestBaseRepository:
with pytest.raises(AlreadyExistsError):
await user_repository.create_item(user)
async def test_transaction_reuses_existing_session(
async def test_transaction_scope_allows_crud(
self,
user_repository: UserRepository,
) -> None:
async with user_repository.transaction():
_ = user_repository.session
count = await user_repository.get_items_count()
assert count == 0
async def test_session_is_none_without_transaction(
self,
user_repository: UserRepository,
) -> None:
assert user_repository.session is None
async def test_session_returns_session_inside_transaction(
self,
user_repository: UserRepository,
) -> None:
from sqlmodel.ext.asyncio.session import AsyncSession
async with user_repository.transaction() as session:
assert isinstance(user_repository.session, AsyncSession)
assert user_repository.session is session
async def test_get_items_with_pagination(
self,
@@ -147,11 +164,83 @@ class TestBaseRepository:
assert created.name == "Widget"
async def test_get_filter_type(self) -> None:
repository = ProductRepository(settings=DatabaseSettings())
product_repository = ProductRepository(settings=RepositorySettings())
user_repository = UserRepository(settings=RepositorySettings())
filter_type = repository.get_filter_type()
assert product_repository.get_filter_type() is ProductFilter
assert user_repository.get_filter_type() is UserFilter
assert filter_type is ProductFilter
async def test_get_dto_type(self) -> None:
product_repository = ProductRepository(settings=RepositorySettings())
user_repository = UserRepository(settings=RepositorySettings())
assert product_repository.get_dto_type() is None
assert user_repository.get_dto_type() is User
async def test_get_items_count_with_filter(
self,
product_repository_settings: ProductRepository,
) -> None:
await product_repository_settings.create_item(
ProductTable(name="Alpha", price=10.0),
)
await product_repository_settings.create_item(
ProductTable(name="Beta", price=20.0),
)
count = await product_repository_settings.get_items_count(
filter_=ProductFilter(name="Alpha"),
)
assert count == 1
async def test_delete_items_with_filter(
self,
product_repository_settings: ProductRepository,
) -> None:
await product_repository_settings.create_item(
ProductTable(name="Alpha", price=10.0),
)
await product_repository_settings.create_item(
ProductTable(name="Beta", price=20.0),
)
await product_repository_settings.delete_items(
filter_=ProductFilter(name="Alpha"),
)
count = await product_repository_settings.get_items_count()
assert count == 1
remaining = [item async for item in product_repository_settings.get_items()]
assert remaining[0].name == "Beta"
async def test_update_items_with_filter(
self,
product_repository_settings: ProductRepository,
) -> None:
await product_repository_settings.create_item(
ProductTable(name="Alpha", price=10.0),
)
await product_repository_settings.create_item(
ProductTable(name="Beta", price=20.0),
)
updated = [
item
async for item in product_repository_settings.update_items(
filter_=ProductFilter(name="Alpha"),
name="Gamma",
)
]
assert len(updated) == 1
assert updated[0].name == "Gamma"
all_items = [item async for item in product_repository_settings.get_items()]
assert len(all_items) == 2
names = {item.name for item in all_items}
assert names == {"Gamma", "Beta"}
async def test_get_items_with_filter(
self,
@@ -217,3 +306,72 @@ class TestBaseRepository:
assert len(updated) == 1
assert updated[0].title == "Updated"
async def test_init_raises_without_container_or_settings(self) -> None:
with pytest.raises(TypeError):
BaseRepository()
async def test_repository_without_table_raises(self) -> None:
with pytest.raises(TypeError):
class BadRepository(BaseRepository, filter_=UserFilter):
pass
async def test_nested_transaction_property_allows_crud(
self,
user_repository: UserRepository,
) -> None:
async with user_repository.nested_transaction():
count = await user_repository.get_items_count()
assert count == 0
async def test_nested_transaction_rollbacks_inner_scope(
self,
user_repository: UserRepository,
) -> None:
with pytest.raises(ValueError):
async with user_repository.nested_transaction():
await user_repository.create_item(
User(name="Alice", email="alice@example.com"),
)
raise ValueError("boom")
count = await user_repository.get_items_count()
assert count == 0
async def test_nested_transaction_in_outer_transaction_rollbacks_only_inner(
self,
user_repository: UserRepository,
) -> None:
async with user_repository.transaction():
await user_repository.create_item(
User(name="Bob", email="bob@example.com"),
)
with pytest.raises(ValueError):
async with user_repository.nested_transaction():
await user_repository.create_item(
User(name="Alice", email="alice@example.com"),
)
raise ValueError("boom")
count = await user_repository.get_items_count()
assert count == 1
items = [item async for item in user_repository.get_items()]
assert items[0].name == "Bob"
async def test_params_via_intermediate_base_class(self) -> None:
class IntermediateRepository(
BaseRepository,
table=UserTable,
filter_=UserFilter,
dto=User,
):
pass
class ConcreteRepository(IntermediateRepository):
pass
repository = ConcreteRepository(settings=RepositorySettings())
assert repository.get_filter_type() is UserFilter
assert repository.get_dto_type() is User

View File

@@ -1,12 +1,12 @@
import pytest
from pydantic import ValidationError
from metaorm import DatabaseSettings
from metaorm import RepositorySettings
class TestDatabaseSettings:
class TestRepositorySettings:
def test_default_values(self) -> None:
settings = DatabaseSettings()
settings = RepositorySettings()
assert settings.dsn == "sqlite+aiosqlite:///db.sqlite3"
assert settings.pool_size == 5
@@ -14,7 +14,7 @@ class TestDatabaseSettings:
assert settings.pool_timeout == 60
def test_custom_values(self) -> None:
settings = DatabaseSettings(
settings = RepositorySettings(
dsn="postgresql+asyncpg://user:pass@localhost/db",
pool_size=10,
pool_recycle=120,
@@ -28,7 +28,7 @@ class TestDatabaseSettings:
def test_dsn_must_match_pattern(self) -> None:
with pytest.raises(ValidationError):
DatabaseSettings(dsn="invalid_dsn")
RepositorySettings(dsn="invalid_dsn")
@pytest.mark.parametrize(
"field_name,invalid_value",
@@ -44,4 +44,4 @@ class TestDatabaseSettings:
invalid_value: int,
) -> None:
with pytest.raises(ValidationError):
DatabaseSettings(**{field_name: invalid_value})
RepositorySettings(**{field_name: invalid_value})

View File

@@ -1,7 +1,6 @@
import pytest
from sqlmodel import Field
from metaorm import BaseTable
from metaorm import BaseTable, Field
from tests.models import User

16
uv.lock generated
View File

@@ -137,7 +137,9 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/2e/7e/9ecd0285e3153532ae07aeb88063c43c72b4221cf0d4d123b02f3682e3ff/greenlet-3.5.5-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:49520f0c95a48b42cf55414b8e8479beb274ea70431afc33e3f79903c71f4380", size = 295809, upload-time = "2026-08-10T13:25:34.023Z" },
{ url = "https://files.pythonhosted.org/packages/35/73/60e4bbcc89252037b18087f2ec16405d5b2d5be42dde191bbf3667e96102/greenlet-3.5.5-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:55272212cbc5f43d1d723725ab931f1939969b7e9523882ca58b55061769d053", size = 611910, upload-time = "2026-08-10T14:14:35.18Z" },
{ url = "https://files.pythonhosted.org/packages/a4/17/cd5134be659cd4a443e7a61ae670dabec165a814c51162916d637b6dd38e/greenlet-3.5.5-cp312-cp312-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:655bca754a2ef4efcb0eb48a94d3f4593536d0f3d48f8ed44343c01d16a92f95", size = 624198, upload-time = "2026-08-10T14:27:25.229Z" },
{ url = "https://files.pythonhosted.org/packages/9b/30/87c212b5c684d0e72974f1063b7a9687631e8985902c06e1016542c874e7/greenlet-3.5.5-cp312-cp312-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:6ca5d6ae0739e5764f2cfcfaa562ac5a990cbdaedca93251c5e3cf07c362371f", size = 629504, upload-time = "2026-08-10T14:30:07.967Z" },
{ url = "https://files.pythonhosted.org/packages/78/ac/5c5b959999b6f09c3026b5dfe171575bc3121c5236ce74f495096f25b203/greenlet-3.5.5-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:147b25a42e5ca5be3d42356e8f608b37af715a1c196e9bf9d1627f3341adfe1d", size = 621439, upload-time = "2026-08-10T13:40:49.391Z" },
{ url = "https://files.pythonhosted.org/packages/63/2c/eb487fafc9f50ffff2b1e0b697f70fb34bf150821c08ab225aacf5583a7e/greenlet-3.5.5-cp312-cp312-manylinux_2_39_riscv64.whl", hash = "sha256:1b5ed9162c0c098e0bbc2cf88a94f433c1b8926f831745252e099e5d83e17759", size = 432462, upload-time = "2026-08-10T14:30:02.309Z" },
{ url = "https://files.pythonhosted.org/packages/c8/8b/6acf112ed8aee499f25b4d6949820fb02ac950ff9c1f3d793bd5be0599f2/greenlet-3.5.5-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:27493374cff1d1b7919dc8126547f2aea582737e3046147b434b1e12de56389b", size = 1581342, upload-time = "2026-08-10T14:15:05.653Z" },
{ url = "https://files.pythonhosted.org/packages/b8/d7/734e5f198888876b42d7616ff6644c075baf6b8a2412deadd6b0e1b8b20c/greenlet-3.5.5-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:12e2ee66c2aba86133f10fd99d6a8856c6d351ffb7be0e4d52ef2cc5fbb705b2", size = 1645744, upload-time = "2026-08-10T13:40:30.353Z" },
{ url = "https://files.pythonhosted.org/packages/de/30/1f42b88dc587b5899ee50616ad56ee40cafaf225df4fb829f10183c62a5c/greenlet-3.5.5-cp312-cp312-win_amd64.whl", hash = "sha256:49ddacd36af37735fab103846f4ee4d18a492dde72730d1699c0c8ebe30d9f18", size = 324171, upload-time = "2026-08-10T13:28:44.472Z" },
@@ -145,7 +147,9 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/fb/3d/8cef5f724ec0d4add2af8961d504535ec60c3cca9e464f6d03bdba29d85b/greenlet-3.5.5-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:b79fd2a5bc099b5e744f34c4c9a58954a5f4cb7529fb4b6e8446057d61b6edaa", size = 294730, upload-time = "2026-08-10T13:27:51.206Z" },
{ url = "https://files.pythonhosted.org/packages/88/4b/8e7aa3f514273aecff30a16ab1bac09ff54cfc7e6860fdd8058c37ff2499/greenlet-3.5.5-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:634cf15a233a949136879dd388e25d3296e16f3f1e217d2456797b8579ebc6ed", size = 614536, upload-time = "2026-08-10T14:14:36.589Z" },
{ url = "https://files.pythonhosted.org/packages/85/48/4e95e9dd5a8a397dc6a6345dd7f1935113d0fca4f85e89d3976da9cd988d/greenlet-3.5.5-cp313-cp313-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:499adea519f748407fc6806d20eedabac2884fd73b9f38d81236e190ba20dfef", size = 626924, upload-time = "2026-08-10T14:27:27.048Z" },
{ url = "https://files.pythonhosted.org/packages/0e/84/eaa476d6bf3816828d0d70e80dcc36bf30a058233bd889e707e693f6e860/greenlet-3.5.5-cp313-cp313-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f7278591501941bb2456af102bb9cd59aab48c6cfd6e2dd68fa1290bb0c49a42", size = 632726, upload-time = "2026-08-10T14:30:09.874Z" },
{ url = "https://files.pythonhosted.org/packages/89/5d/398a1c71fa7a277deeb376c999979de6786f08fc2d5747a0b9d6e11738dd/greenlet-3.5.5-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2eabb980975cba5b93a95f6f69287d05fc05ac955bfd6a320a7c083eeb52c0b0", size = 623906, upload-time = "2026-08-10T13:40:50.501Z" },
{ url = "https://files.pythonhosted.org/packages/d0/f2/0cc2849ede68579291e9c59b3ab6ec1958f98681cca5b14d8fc75bf674a4/greenlet-3.5.5-cp313-cp313-manylinux_2_39_riscv64.whl", hash = "sha256:4dfc7c4470354e7b09184d1a3a985761053a2fd694ddb5b5c80242afc2c8c90b", size = 434966, upload-time = "2026-08-10T14:30:03.729Z" },
{ url = "https://files.pythonhosted.org/packages/04/1b/745450fc5ea9e0cb17d840d248f284db3363de736d362c7d2d883e3eadba/greenlet-3.5.5-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:03115c2e0a371999bf8ae616aa8d653f96641d4705c457aebaa187276e9f7537", size = 1581430, upload-time = "2026-08-10T14:15:06.853Z" },
{ url = "https://files.pythonhosted.org/packages/d4/29/d51b296e3191bb15d3d81ec375af1909e4466c0f395d744ed475801798a9/greenlet-3.5.5-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4441153ffba21b90d3ca89fe3d31f5c093ae6c0bf0cfdfc98f54cde22f95b62e", size = 1645684, upload-time = "2026-08-10T13:40:32.133Z" },
{ url = "https://files.pythonhosted.org/packages/12/63/369f1a1625e64e9e31df3963c6044056e3fdfa3fa3fdba3c54ffefa6e987/greenlet-3.5.5-cp313-cp313-win_amd64.whl", hash = "sha256:95c5b1f4b3a193f8a0c2de4bfdcb48d119f7f1063941f1de1f2168051b3e52dd", size = 324075, upload-time = "2026-08-10T13:26:58.974Z" },
@@ -153,7 +157,9 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/7f/8c/080e881fa2be95ff1ddbd6994b2bab3b1a78df3b3fcab39306011764fcc7/greenlet-3.5.5-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:d4a389a852e392a6366058651a20fa5ba40d979865aa81bea2ccbdc44805070d", size = 295309, upload-time = "2026-08-10T13:26:03.032Z" },
{ url = "https://files.pythonhosted.org/packages/25/cc/0ac614e6586c0e42d4cc281a5819150f4f43685744a4c5ff77139286409d/greenlet-3.5.5-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:70b157cd319873e8b544ddc2de158f55bbd0a9b0218c8ce9332039801518e328", size = 661185, upload-time = "2026-08-10T14:14:37.867Z" },
{ url = "https://files.pythonhosted.org/packages/5e/b9/6808725354be8ad305dfe5172377664fc9642d4fc043be246b3314cf4482/greenlet-3.5.5-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8bdfd1424abcf26832961e766570cae79efdb9599d709088c9cb6ef82b194926", size = 673419, upload-time = "2026-08-10T14:27:28.652Z" },
{ url = "https://files.pythonhosted.org/packages/eb/52/f005d579acde46c3d1cc3cab1c9f3d5708c8a3006a4120e8cf5da801afe9/greenlet-3.5.5-cp314-cp314-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d98ef6f92e67c6dbf299dbfd8facc1b0d2d9cedf91e325e73b3d0373fe4309d8", size = 677863, upload-time = "2026-08-10T14:30:11.663Z" },
{ url = "https://files.pythonhosted.org/packages/42/2e/40c509967da7f254680826a2fa0dd22138ec79946c70b97542d74cde8b43/greenlet-3.5.5-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:182de51c6b572a705f2fafaab2e783bcf7d2760940229dfe73086cbae037af3e", size = 670822, upload-time = "2026-08-10T13:40:51.833Z" },
{ url = "https://files.pythonhosted.org/packages/c4/8a/a75f8a2bdcef3c358a3147cdc9db3aa83755f0a038f766ab0bedb66f512c/greenlet-3.5.5-cp314-cp314-manylinux_2_39_riscv64.whl", hash = "sha256:159df1942d88e8f784cbb38d6f18bdb365cd11319cfbb3e89623de2b97892d53", size = 480554, upload-time = "2026-08-10T14:30:05.171Z" },
{ url = "https://files.pythonhosted.org/packages/2d/22/c3c2eee4a8fe191d6d1d183086c56133d646024e3d70bfd414829f64560b/greenlet-3.5.5-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:8fec3f165dfe332e490c3247c0f6c23b0bfc45f06496ad7f00ddb00e3d35e4dc", size = 1628469, upload-time = "2026-08-10T14:15:08.11Z" },
{ url = "https://files.pythonhosted.org/packages/f7/87/25babd09b94cb1f03e71db815fde463f0262e40cfbd953d58a8d77311351/greenlet-3.5.5-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:c6ce25fee6cabc8bf22cb8b52e642cbb821be5b9aec8094d07ff03378141b8e9", size = 1691952, upload-time = "2026-08-10T13:40:33.502Z" },
{ url = "https://files.pythonhosted.org/packages/2e/3d/5cc9701117ea4dc0eb7bf1f4f9b7888a6e2e5277ddfae095805ace50f2b6/greenlet-3.5.5-cp314-cp314-win_amd64.whl", hash = "sha256:7dffc5c859fe6059974df1e37d7923d654a83e2ae18fdd616994270e001115e1", size = 327458, upload-time = "2026-08-10T13:27:02.868Z" },
@@ -161,14 +167,18 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/24/e0/50cd600b469e5734c72709b6b1838b6bc63f307b573c772c3132d6ecfe92/greenlet-3.5.5-cp314-cp314t-macosx_11_0_universal2.whl", hash = "sha256:0e5a7de979d764aea1f5b6e95cf92b5b37741b9823702041f34b126e7f690277", size = 305471, upload-time = "2026-08-10T13:26:20.568Z" },
{ url = "https://files.pythonhosted.org/packages/75/a3/77acd66dfc6387b5219b2080806c0cabb73c10eb1bb44b413c40a62015ba/greenlet-3.5.5-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fef01bd457f11fc158b130ca0027a3c365693280e8e231b65bdaf57999f39f5b", size = 672470, upload-time = "2026-08-10T14:14:39.058Z" },
{ url = "https://files.pythonhosted.org/packages/b9/71/0d178142dca3ec19f46fb2212ae73d30ad53b9d548dc64804086033a7089/greenlet-3.5.5-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5173a72310725a74afc82c164f0e52cb8ad0de62f2bb623f24f6c0cc07d80272", size = 679973, upload-time = "2026-08-10T14:27:30.072Z" },
{ url = "https://files.pythonhosted.org/packages/aa/ac/0d7887aa4bbfc9eba075cc428244dfc96f623478454d5ec81180d0d6bd5a/greenlet-3.5.5-cp314-cp314t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5e9ec2e7c98e895fcea0c5cc57b2606cf86ece6d0a56578f3eb225e2af4f0387", size = 681587, upload-time = "2026-08-10T14:30:13.519Z" },
{ url = "https://files.pythonhosted.org/packages/6e/31/46eb8567302eaf787abf88d09df014e14ae3baf460af1b8b0efdbd3efcd5/greenlet-3.5.5-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:44f08341873200ba8a60a8bc14ace3d91f1754f7fa7bc66157714a8cd420a476", size = 676634, upload-time = "2026-08-10T13:40:53.004Z" },
{ url = "https://files.pythonhosted.org/packages/4f/18/8d58ba1c429b0383e3219a3d0e0bba241d0444d8ed05b73349953c7d7c7b/greenlet-3.5.5-cp314-cp314t-manylinux_2_39_riscv64.whl", hash = "sha256:102817506f6090b5176c746a82603341a549b40e5c3d5b72a4c672228a918c41", size = 510175, upload-time = "2026-08-10T14:30:07.047Z" },
{ url = "https://files.pythonhosted.org/packages/a3/e9/b88bbf5b29970cb84172dc2c32aa3e5e579ceb94c808e81c826454138850/greenlet-3.5.5-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:d246c0db9a2513cd45f019ba178ea4d4d4705bd210ee465e2c15d76a1ab13874", size = 1637320, upload-time = "2026-08-10T14:15:09.317Z" },
{ url = "https://files.pythonhosted.org/packages/6d/8c/7631ed29cc6f0392f11830076e172ce4885e70b0bc2c1bce1731176d4b4e/greenlet-3.5.5-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:72507285b5caa1d17904a3f7c322ca780823a54170a0e04ec3f37bcc60d4db71", size = 1697412, upload-time = "2026-08-10T13:40:34.924Z" },
{ url = "https://files.pythonhosted.org/packages/da/0f/f7dd935f9c4cb1be49098770587f54d8a78518e55c89bce86c4fb4109057/greenlet-3.5.5-cp314-cp314t-win_amd64.whl", hash = "sha256:7805655781fb8f28a55d05fe57ed61f5f10f1892fb587673e3bb5264f28041f0", size = 331514, upload-time = "2026-08-10T13:29:20.611Z" },
{ url = "https://files.pythonhosted.org/packages/b7/e5/681b01f8fbc1b55232822f99e8f8afeb78a55a7c76a7bf9dbdc7ccb03a6d/greenlet-3.5.5-cp315-cp315-macosx_11_0_universal2.whl", hash = "sha256:c0db80fcd5b8aece93f66c64f78a786bbb6b96c5fe63ef5a5a4581ecf8bab206", size = 295975, upload-time = "2026-08-10T13:28:45.985Z" },
{ url = "https://files.pythonhosted.org/packages/11/f2/69b488cd9e7267bf4b0fe8cdebf25d8d6df680d21bdf41150d23e23d6652/greenlet-3.5.5-cp315-cp315-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b241c32f912ada659808d68e308c568baf577eebf757d15471472de0c18cfad", size = 666823, upload-time = "2026-08-10T14:14:40.222Z" },
{ url = "https://files.pythonhosted.org/packages/84/d4/d5bc2fdebbdda0c94555925ba79948b8395d75a7f6a36cc85dce5bab9f11/greenlet-3.5.5-cp315-cp315-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ef6a08349401d8eaf3cb12688ac8557de95788556b8631ef17555a4a173022c0", size = 677613, upload-time = "2026-08-10T14:27:31.543Z" },
{ url = "https://files.pythonhosted.org/packages/65/53/4e13642efc4d7ad6554ecb2242a5be42666b2e1a067323e88dfc0124a04b/greenlet-3.5.5-cp315-cp315-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:37faa97daccb6d9f4c2141ce3118d023c3c5506864a7d8bdf726f665018c1f76", size = 681436, upload-time = "2026-08-10T14:30:14.839Z" },
{ url = "https://files.pythonhosted.org/packages/bd/93/542d8a3a90f3b35c6ad8bf7e56a03010287f2cafa289a5b7985b5207db39/greenlet-3.5.5-cp315-cp315-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f2e3d061b8e13aec2f0441689b3c71b244a20e5d274a52cb0f7e31bd1d139552", size = 675930, upload-time = "2026-08-10T13:40:54.205Z" },
{ url = "https://files.pythonhosted.org/packages/cd/32/188447c9a468d6977d2989397226b0c6b65ab6f4cf943f931643328512fc/greenlet-3.5.5-cp315-cp315-manylinux_2_39_riscv64.whl", hash = "sha256:b18007dc2473a7942fd157366b55f01da6fed7ce85318591005b419e0a439474", size = 487404, upload-time = "2026-08-10T14:30:08.903Z" },
{ url = "https://files.pythonhosted.org/packages/52/b5/89c9f2e8460d71101037d47a1feed11928615a5edd42370be290e0657eeb/greenlet-3.5.5-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:9ab5f5b93655e77fe0d6c2dfd22b5eac751bb1f876d8ec21761b7c1fb9266007", size = 1633878, upload-time = "2026-08-10T14:15:10.693Z" },
{ url = "https://files.pythonhosted.org/packages/b8/60/297de93f3b02ac78a5e04d32bb8bbe3080f4a73d8ed95016561463b70618/greenlet-3.5.5-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:f0e5a21bd4452a88cf032fc43c4a5b307ab1380eacb63b5988f9c0317885e773", size = 1696597, upload-time = "2026-08-10T13:40:36.252Z" },
{ url = "https://files.pythonhosted.org/packages/18/25/54c6eaff4f337fb670215e89eb2d00d9499487b658e709d4b477be4a342e/greenlet-3.5.5-cp315-cp315-win_amd64.whl", hash = "sha256:469dbb0a78625642f4a626cfd0c6e8bccc0385b5e49189b6308bbe849ec88a8e", size = 327700, upload-time = "2026-08-10T13:28:06.752Z" },
@@ -176,7 +186,9 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/10/e2/3144c0a116067ac1e30457b0139a94d60d1d36a86e015de68e9ac87cb3bc/greenlet-3.5.5-cp315-cp315t-macosx_11_0_universal2.whl", hash = "sha256:68184dfcf50ccaa8e864770fe0633a7e27250ea9329f8192ef47ee9ecfd78e1c", size = 306387, upload-time = "2026-08-10T13:27:00.897Z" },
{ url = "https://files.pythonhosted.org/packages/5c/a1/cb4223a7e9b9f43b8807e8eb212358bfe2dfaa174a9ea2889eb1714dcba2/greenlet-3.5.5-cp315-cp315t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9ec0dc0e59dc9c61af5c47348365ccbbd7addfafe0a93b00336ff3da2907bdc6", size = 676472, upload-time = "2026-08-10T14:14:41.417Z" },
{ url = "https://files.pythonhosted.org/packages/9e/cd/a154b4498e5d8f12ada291cfb3b8d596eadde2177f5bf09a9be699d2a446/greenlet-3.5.5-cp315-cp315t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e604f58e35833fc46ef20302bcb314dddbfd3fcf33a4f936216d51dd678d63ae", size = 684238, upload-time = "2026-08-10T14:27:32.946Z" },
{ url = "https://files.pythonhosted.org/packages/ce/f4/e450a68a152f819491d8c7df6a8254e761d87e6a78759268961f8c5bd4dd/greenlet-3.5.5-cp315-cp315t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2888a3a38bc5ee5bb6c438372197152e815837e4fab7ed7a1f86ef18ffd58ad1", size = 686022, upload-time = "2026-08-10T14:30:15.96Z" },
{ url = "https://files.pythonhosted.org/packages/bf/bb/b0031d260c2968a3c87deebc51d80c64e499377f993aafe06ee3b7488cc2/greenlet-3.5.5-cp315-cp315t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:40239b5384f96da3963585cc6d7eaa9b56f8ae67e8d92cc82dd9e202fc847de3", size = 681246, upload-time = "2026-08-10T13:40:55.402Z" },
{ url = "https://files.pythonhosted.org/packages/18/23/17e63d6bf3b9c9b9dbea981b7f643a71f79603bdfb4f1c3a9cf353e22aed/greenlet-3.5.5-cp315-cp315t-manylinux_2_39_riscv64.whl", hash = "sha256:1e8d9391fe77f15649589a907cef972dbbd6352ef7ff7dc0492f658c0c26495f", size = 516951, upload-time = "2026-08-10T14:30:10.907Z" },
{ url = "https://files.pythonhosted.org/packages/9a/07/da554b71ab88e649da146e1065d86a48a5c5d92e50ab74ef41b504aa7f56/greenlet-3.5.5-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:a1eaccf5c3a1d3e46dead602c72e6836731e8e245c9de6a27764567b6b62d4c0", size = 1642735, upload-time = "2026-08-10T14:15:11.92Z" },
{ url = "https://files.pythonhosted.org/packages/78/76/26a3782a051677668af9d92beaa47cd87ba9dd5072f762961144a03dd4c6/greenlet-3.5.5-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:19e4e026fe20691f333b8eb1a3bc9625eceba8c3f9d62ec5a6f8581afbc6b5a5", size = 1700925, upload-time = "2026-08-10T13:40:37.656Z" },
{ url = "https://files.pythonhosted.org/packages/28/d9/fe7baf4190c2ae71f267efb9de21b3172bb35bc0ed1ef53dd6027d658e33/greenlet-3.5.5-cp315-cp315t-win_amd64.whl", hash = "sha256:712aee154f648bde84634654bb38bb78c69ac640c37a45c9effed800735049d8", size = 331829, upload-time = "2026-08-10T13:26:48.851Z" },
@@ -215,7 +227,7 @@ dev = [
[package.metadata]
requires-dist = [
{ name = "pydantic", specifier = ">=2.0" },
{ name = "pydantic-filters", git = "https://github.com/so-saf/pydantic-filters" },
{ name = "pydantic-filters", git = "https://github.com/OlegYurchik/pydantic-filters?branch=fix%2Fcompare-to-pydantic-2.12" },
{ name = "sqlalchemy", specifier = ">=2.0" },
{ name = "sqlmodel", specifier = ">=0.0.22" },
]
@@ -340,7 +352,7 @@ wheels = [
[[package]]
name = "pydantic-filters"
version = "0.0.0"
source = { git = "https://github.com/so-saf/pydantic-filters#d385c382c1ab3adc264855bf99e4e378f297eb66" }
source = { git = "https://github.com/OlegYurchik/pydantic-filters?branch=fix%2Fcompare-to-pydantic-2.12#2ca8b822d59feaf5f19f36b570974d314ba5e330" }
dependencies = [
{ name = "pydantic" },
]