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 # Byte-compiled / optimized / DLL files
__pycache__/ __pycache__/
.pytest_cache/ .pytest_cache/
.ruff_cache/
*.py[cod] *.py[cod]
*$py.class *$py.class

View File

@@ -10,12 +10,13 @@ metaorm/
repositories.py # BaseRepository repositories.py # BaseRepository
tables.py # BaseTable tables.py # BaseTable
container.py # RepositoriesContainer (session/transaction manager) container.py # RepositoriesContainer (session/transaction manager)
settings.py # DatabaseSettings (Pydantic model) settings.py # RepositorySettings (Pydantic model)
exceptions.py # Domain exceptions exceptions.py # Domain exceptions
examples/ # Usage examples examples/ # Usage examples
basic_usage.py # Simple CRUD with tables directly 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 transactions.py # Explicit transaction management
nested_transactions.py # Savepoints and partial rollback
filter_usage.py # Query filters, pagination and sorting filter_usage.py # Query filters, pagination and sorting
relationships.py # Eager loading with joinedload/selectinload relationships.py # Eager loading with joinedload/selectinload
container_usage.py # Multi-repository atomic transactions 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
`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 ```python
class UserRepository(BaseRepository): class UserRepository(BaseRepository, table=UserTable, filter_=UserFilter, dto=User):
def get_db_table(self) -> type[UserTable]: pass # Methods return User instances
return UserTable
def get_dto_type(self) -> type[User] | None:
return User # 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) #### Eager loading (options)
@@ -83,7 +97,7 @@ books = [
```python ```python
# Simple: create container internally # 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 # Advanced: reuse container for shared transactions
container = RepositoriesContainer(settings=settings) 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()`. - 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. - `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 ## Development

167
README.md
View File

@@ -1,6 +1,14 @@
# MetaORM # 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 ## Install
@@ -14,11 +22,8 @@ Requires Python `>=3.12`.
## Quick start ## Quick start
The simplest mode works with SQLModel tables directly — no DTOs, no generics, no magic:
```python ```python
from sqlmodel import Field from metaorm import BaseFilter, BaseRepository, BaseTable, RepositorySettings, Field
from metaorm import BaseRepository, BaseTable, DatabaseSettings
class UserTable(BaseTable, table=True): class UserTable(BaseTable, table=True):
@@ -28,129 +33,68 @@ class UserTable(BaseTable, table=True):
email: str = Field(unique=True) email: str = Field(unique=True)
class UserRepository(BaseRepository): class UserFilter(BaseFilter):
def get_db_table(self) -> type[UserTable]: name: str | None = None
return UserTable email: str | None = None
class UserRepository(BaseRepository, table=UserTable, filter_=UserFilter):
pass
async def main(): async def main():
repo = UserRepository( repo = UserRepository(
settings=DatabaseSettings(dsn="sqlite+aiosqlite:///:memory:"), settings=RepositorySettings(dsn="sqlite+aiosqlite:///:memory:"),
) )
await repo.create_tables() await repo.create_tables()
user = await repo.create_item( user = await repo.create_item(UserTable(name="Alice", email="alice@example.com"))
UserTable(name="Alice", email="alice@example.com"),
)
print(user.id, user.name) print(user.id, user.name)
all_users = [u async for u in repo.get_items()] all_users = [u async for u in repo.get_items()]
print(len(all_users)) 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 ```python
from pydantic import BaseModel class MyRepository(BaseRepository, table=MyTable, filter_=MyFilter):
from sqlmodel import Field pass # returns table instances directly
from metaorm import BaseRepository, BaseTable, DatabaseSettings
class User(BaseModel): class MyRepositoryWithDto(BaseRepository, table=MyTable, filter_=MyFilter, dto=MyDto):
id: int | None = None pass # maps rows to MyDto
name: str
class UserTable(BaseTable[User], table=True):
__tablename__ = "users"
id: int | None = Field(default=None, primary_key=True)
name: str
@classmethod
def from_item(cls, item: User) -> "UserTable":
return cls(id=item.id, name=item.name)
def to_item(self) -> User:
return User(id=self.id, name=self.name)
class UserRepository(BaseRepository):
def get_db_table(self) -> type[UserTable]:
return UserTable
def get_dto_type(self) -> type[User]:
return User
async def main():
repo = UserRepository(
settings=DatabaseSettings(dsn="sqlite+aiosqlite:///:memory:"),
)
await repo.create_tables()
user = await repo.create_item(User(name="Alice"))
# user is a User DTO, not UserTable
print(user.model_dump())
``` ```
## Filters, pagination and sorting 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 ```python
from pydantic_filters import BaseFilter, BaseSort, OffsetPagination # Simple — container is created internally
repo = MyRepository(settings=RepositorySettings(dsn="..."))
class BookFilter(BaseFilter): # Advanced — share a container for atomic multi-repo transactions
title: str | None = None container = RepositoriesContainer(settings=settings)
year: int | None = None repo = MyRepository(container=container)
class BookRepository(BaseRepository):
def get_db_table(self) -> type[BookTable]:
return BookTable
def get_filter_type(self) -> type[BookFilter]:
return BookFilter
# Exact match filter
filtered = [
item
async for item in repo.get_items(filter_=BookFilter(year=2025))
]
# Pagination
page = [
item
async for item in repo.get_items(
pagination=OffsetPagination(offset=10, limit=20),
)
]
# Sorting
sorted_items = [
item
async for item in repo.get_items(
sort=BaseSort(sort_by="year", sort_by_order="desc"),
)
]
``` ```
## Explicit transactions ### 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 ### Multi-repository transactions
async with repo.transaction():
user = await repo.create_item(UserTable(name="Alice"))
# nested transaction reuses the same session
async with repo.transaction():
items = [item async for item in repo.get_items()]
```
## Atomic transactions across multiple repositories
Use `RepositoriesContainer` when you need a single atomic transaction spanning multiple repositories: 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)) 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 See [`examples/`](examples/) for detailed usage patterns:
from sqlalchemy.orm import joinedload
books = [ - [`basic_usage.py`](examples/basic_usage.py) — CRUD with tables directly
item - [`dto_usage.py`](examples/dto_usage.py) — DTO mapping via `dto=` keyword
async for item in book_repo.get_items( - [`transactions.py`](examples/transactions.py) — Explicit transaction management
options=[joinedload(BookTable.author)], - [`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 ## Exceptions

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,8 +1,6 @@
import asyncio import asyncio
from sqlmodel import Field from metaorm import BaseFilter, BaseRepository, BaseTable, Field, RepositorySettings
from metaorm import BaseRepository, BaseTable, DatabaseSettings
class ProductTable(BaseTable, table=True): class ProductTable(BaseTable, table=True):
@@ -13,13 +11,17 @@ class ProductTable(BaseTable, table=True):
price: float price: float
class ProductRepository(BaseRepository): class ProductFilter(BaseFilter):
def get_db_table(self) -> type[ProductTable]: name: str | None = None
return ProductTable price: int | None = None
class ProductRepository(BaseRepository, table=ProductTable, filter_=ProductFilter):
pass
async def main() -> None: async def main() -> None:
settings = DatabaseSettings(dsn="sqlite+aiosqlite:///:memory:") settings = RepositorySettings(dsn="sqlite+aiosqlite:///:memory:")
repository = ProductRepository(settings=settings) repository = ProductRepository(settings=settings)
await repository.create_tables() await repository.create_tables()
@@ -34,10 +36,23 @@ async def main() -> None:
) )
print(f"Created in transaction: {product1.name}, {product2.name}") 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(): async with repository.transaction(), repository.transaction():
items = [item async for item in repository.get_items()] 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__": 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 .container import RepositoriesContainer
from .exceptions import ( from .exceptions import (
AlreadyExistsError, AlreadyExistsError,
@@ -6,10 +15,19 @@ from .exceptions import (
NotFoundError, NotFoundError,
) )
from .repositories import BaseRepository from .repositories import BaseRepository
from .settings import DatabaseSettings from .settings import RepositorySettings
from .tables import BaseTable from .tables import BaseTable
__all__ = ( __all__ = (
# pydantic-filters
"BaseFilter",
"BasePagination",
"BaseSort",
"OffsetPagination",
"PagePagination",
# sqlmodel
"Field",
"Relationship",
# container # container
"RepositoriesContainer", "RepositoriesContainer",
# exceptions # exceptions
@@ -20,7 +38,7 @@ __all__ = (
# repositories # repositories
"BaseRepository", "BaseRepository",
# settings # settings
"DatabaseSettings", "RepositorySettings",
# tables # tables
"BaseTable", "BaseTable",
) )

View File

@@ -6,13 +6,13 @@ from typing import TypeVar
from sqlalchemy.ext.asyncio import AsyncEngine, create_async_engine from sqlalchemy.ext.asyncio import AsyncEngine, create_async_engine
from sqlmodel.ext.asyncio.session import AsyncSession from sqlmodel.ext.asyncio.session import AsyncSession
from .settings import DatabaseSettings from .settings import RepositorySettings
RepositoryType = TypeVar("RepositoryType", bound="BaseRepository") # noqa: F821 RepositoryType = TypeVar("RepositoryType", bound="BaseRepository") # noqa: F821
class RepositoriesContainer: class RepositoriesContainer:
def __init__(self, settings: DatabaseSettings): def __init__(self, settings: RepositorySettings):
engine_parameters = { engine_parameters = {
"url": settings.dsn, "url": settings.dsn,
"pool_recycle": settings.pool_recycle, "pool_recycle": settings.pool_recycle,
@@ -37,7 +37,7 @@ class RepositoriesContainer:
@asynccontextmanager @asynccontextmanager
async def transaction(self) -> AsyncGenerator[AsyncSession, None]: 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: if existing_session is not None:
yield existing_session yield existing_session
return return
@@ -54,5 +54,25 @@ class RepositoriesContainer:
finally: finally:
self._session_context.reset(token) 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: def get_repository(self, repository_class: type[RepositoryType]) -> RepositoryType:
return repository_class(container=self) return repository_class(container=self)

View File

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

View File

@@ -1,7 +1,7 @@
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
class DatabaseSettings(BaseModel): class RepositorySettings(BaseModel):
dsn: str = Field(default="sqlite+aiosqlite:///db.sqlite3", pattern=r"^.+://") dsn: str = Field(default="sqlite+aiosqlite:///db.sqlite3", pattern=r"^.+://")
pool_size: int = Field(default=5, ge=1) pool_size: int = Field(default=5, ge=1)
pool_recycle: int = Field(default=60, ge=1) # in seconds: 1 minute 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] [project]
name = "metaorm" name = "metaorm"
version = "0.2.0" version = "0.3.0"
description = "Async repository layer over SQLModel" description = "Async repository layer over SQLModel"
readme = "README.md" readme = "README.md"
license = {text = "MIT"} license = {text = "MIT"}
@@ -32,7 +28,7 @@ classifiers = [
] ]
dependencies = [ dependencies = [
"pydantic>=2.0", "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", "sqlalchemy>=2.0",
"sqlmodel>=0.0.22", "sqlmodel>=0.0.22",
] ]
@@ -42,6 +38,10 @@ Homepage = "https://github.com/OlegYurchik/metaorm"
Repository = "https://github.com/OlegYurchik/metaorm" Repository = "https://github.com/OlegYurchik/metaorm"
Issues = "https://github.com/OlegYurchik/metaorm/issues" Issues = "https://github.com/OlegYurchik/metaorm/issues"
[build-system]
requires = ["setuptools>=61.0", "wheel"]
build-backend = "setuptools.build_meta"
[dependency-groups] [dependency-groups]
dev = [ dev = [
"aiosqlite>=0.22.0", "aiosqlite>=0.22.0",
@@ -67,4 +67,4 @@ fail_under = 90
show_missing = true show_missing = true
[tool.uv.sources] [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
import pytest_asyncio import pytest_asyncio
from metaorm import DatabaseSettings, RepositoriesContainer from metaorm import RepositoriesContainer, RepositorySettings
from .models import ( from .models import (
AuthorRepository, AuthorRepository,
@@ -14,8 +14,8 @@ from .models import (
@pytest.fixture @pytest.fixture
def database_settings() -> DatabaseSettings: def database_settings() -> RepositorySettings:
return DatabaseSettings( return RepositorySettings(
dsn="sqlite+aiosqlite:///:memory:", dsn="sqlite+aiosqlite:///:memory:",
pool_size=1, pool_size=1,
pool_recycle=60, pool_recycle=60,
@@ -25,7 +25,7 @@ def database_settings() -> DatabaseSettings:
@pytest_asyncio.fixture @pytest_asyncio.fixture
async def repositories_container( async def repositories_container(
database_settings: DatabaseSettings, database_settings: RepositorySettings,
) -> AsyncGenerator[RepositoriesContainer, None]: ) -> AsyncGenerator[RepositoriesContainer, None]:
container = RepositoriesContainer(settings=database_settings) container = RepositoriesContainer(settings=database_settings)
yield container yield container
@@ -43,7 +43,7 @@ async def user_repository(
@pytest_asyncio.fixture @pytest_asyncio.fixture
async def product_repository_settings( async def product_repository_settings(
database_settings: DatabaseSettings, database_settings: RepositorySettings,
) -> AsyncGenerator[ProductRepository, None]: ) -> AsyncGenerator[ProductRepository, None]:
repository = ProductRepository(settings=database_settings) repository = ProductRepository(settings=database_settings)
await repository.create_tables() await repository.create_tables()

View File

@@ -1,8 +1,6 @@
from pydantic import BaseModel 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): class User(BaseModel):
@@ -25,12 +23,13 @@ class UserTable(BaseTable[User], table=True):
return User(id=self.id, name=self.name, email=self.email) return User(id=self.id, name=self.name, email=self.email)
class UserRepository(BaseRepository): class UserFilter(BaseFilter):
def get_db_table(self) -> type[UserTable]: name: str | None = None
return UserTable 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): class ProductTable(BaseTable, table=True):
@@ -45,12 +44,8 @@ class ProductFilter(BaseFilter):
price: int | None = None price: int | None = None
class ProductRepository(BaseRepository): class ProductRepository(BaseRepository, table=ProductTable, filter_=ProductFilter):
def get_db_table(self) -> type[ProductTable]: pass
return ProductTable
def get_filter_type(self) -> type[ProductFilter]:
return ProductFilter
class AuthorTable(BaseTable, table=True): class AuthorTable(BaseTable, table=True):
@@ -68,11 +63,18 @@ class BookTable(BaseTable, table=True):
author: AuthorTable = Relationship(back_populates="books") author: AuthorTable = Relationship(back_populates="books")
class AuthorRepository(BaseRepository): class AuthorFilter(BaseFilter):
def get_db_table(self) -> type[AuthorTable]: name: str | None = None
return AuthorTable
class BookRepository(BaseRepository): class BookFilter(BaseFilter):
def get_db_table(self) -> type[BookTable]: title: str | None = None
return BookTable 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 sqlalchemy.ext.asyncio import AsyncEngine
from metaorm import RepositoriesContainer from metaorm import RepositoriesContainer
from tests.models import UserRepository from tests.models import User, UserRepository
class TestRepositoriesContainer: class TestRepositoriesContainer:
@@ -39,6 +40,69 @@ class TestRepositoriesContainer:
): ):
assert inner_session is outer_session 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( async def test_get_repository_returns_repository_instance(
self, self,
repositories_container: RepositoriesContainer, repositories_container: RepositoriesContainer,

View File

@@ -1,8 +1,13 @@
import pytest import pytest
from pydantic_filters import BaseSort, OffsetPagination
from sqlalchemy.orm import joinedload from sqlalchemy.orm import joinedload
from metaorm import AlreadyExistsError, DatabaseSettings, HaveNoSessionError from metaorm import (
AlreadyExistsError,
BaseRepository,
BaseSort,
OffsetPagination,
RepositorySettings,
)
from tests.models import ( from tests.models import (
AuthorRepository, AuthorRepository,
AuthorTable, AuthorTable,
@@ -12,18 +17,13 @@ from tests.models import (
ProductRepository, ProductRepository,
ProductTable, ProductTable,
User, User,
UserFilter,
UserRepository, UserRepository,
UserTable,
) )
class TestBaseRepository: 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: async def test_create_item(self, user_repository: UserRepository) -> None:
user = User(name="Alice", email="alice@example.com") user = User(name="Alice", email="alice@example.com")
@@ -93,12 +93,29 @@ class TestBaseRepository:
with pytest.raises(AlreadyExistsError): with pytest.raises(AlreadyExistsError):
await user_repository.create_item(user) await user_repository.create_item(user)
async def test_transaction_reuses_existing_session( async def test_transaction_scope_allows_crud(
self, self,
user_repository: UserRepository, user_repository: UserRepository,
) -> None: ) -> None:
async with user_repository.transaction(): 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( async def test_get_items_with_pagination(
self, self,
@@ -147,11 +164,83 @@ class TestBaseRepository:
assert created.name == "Widget" assert created.name == "Widget"
async def test_get_filter_type(self) -> None: 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( async def test_get_items_with_filter(
self, self,
@@ -217,3 +306,72 @@ class TestBaseRepository:
assert len(updated) == 1 assert len(updated) == 1
assert updated[0].title == "Updated" 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 import pytest
from pydantic import ValidationError from pydantic import ValidationError
from metaorm import DatabaseSettings from metaorm import RepositorySettings
class TestDatabaseSettings: class TestRepositorySettings:
def test_default_values(self) -> None: def test_default_values(self) -> None:
settings = DatabaseSettings() settings = RepositorySettings()
assert settings.dsn == "sqlite+aiosqlite:///db.sqlite3" assert settings.dsn == "sqlite+aiosqlite:///db.sqlite3"
assert settings.pool_size == 5 assert settings.pool_size == 5
@@ -14,7 +14,7 @@ class TestDatabaseSettings:
assert settings.pool_timeout == 60 assert settings.pool_timeout == 60
def test_custom_values(self) -> None: def test_custom_values(self) -> None:
settings = DatabaseSettings( settings = RepositorySettings(
dsn="postgresql+asyncpg://user:pass@localhost/db", dsn="postgresql+asyncpg://user:pass@localhost/db",
pool_size=10, pool_size=10,
pool_recycle=120, pool_recycle=120,
@@ -28,7 +28,7 @@ class TestDatabaseSettings:
def test_dsn_must_match_pattern(self) -> None: def test_dsn_must_match_pattern(self) -> None:
with pytest.raises(ValidationError): with pytest.raises(ValidationError):
DatabaseSettings(dsn="invalid_dsn") RepositorySettings(dsn="invalid_dsn")
@pytest.mark.parametrize( @pytest.mark.parametrize(
"field_name,invalid_value", "field_name,invalid_value",
@@ -44,4 +44,4 @@ class TestDatabaseSettings:
invalid_value: int, invalid_value: int,
) -> None: ) -> None:
with pytest.raises(ValidationError): with pytest.raises(ValidationError):
DatabaseSettings(**{field_name: invalid_value}) RepositorySettings(**{field_name: invalid_value})

View File

@@ -1,7 +1,6 @@
import pytest import pytest
from sqlmodel import Field
from metaorm import BaseTable from metaorm import BaseTable, Field
from tests.models import User 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/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/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/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/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/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/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" }, { 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/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/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/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/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/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/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" }, { 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/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/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/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/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/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/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" }, { 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/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/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/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/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/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/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/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/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/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/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/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/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/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" }, { 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/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/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/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/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/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/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" }, { 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] [package.metadata]
requires-dist = [ requires-dist = [
{ name = "pydantic", specifier = ">=2.0" }, { 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 = "sqlalchemy", specifier = ">=2.0" },
{ name = "sqlmodel", specifier = ">=0.0.22" }, { name = "sqlmodel", specifier = ">=0.0.22" },
] ]
@@ -340,7 +352,7 @@ wheels = [
[[package]] [[package]]
name = "pydantic-filters" name = "pydantic-filters"
version = "0.0.0" 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 = [ dependencies = [
{ name = "pydantic" }, { name = "pydantic" },
] ]