Initial release: async repository layer over SQLModel
This commit is contained in:
0
tests/__init__.py
Normal file
0
tests/__init__.py
Normal file
68
tests/conftest.py
Normal file
68
tests/conftest.py
Normal file
@@ -0,0 +1,68 @@
|
||||
from collections.abc import AsyncGenerator
|
||||
|
||||
import pytest
|
||||
import pytest_asyncio
|
||||
|
||||
from metaorm import DatabaseSettings, RepositoriesContainer
|
||||
|
||||
from .models import (
|
||||
AuthorRepository,
|
||||
BookRepository,
|
||||
ProductRepository,
|
||||
UserRepository,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def database_settings() -> DatabaseSettings:
|
||||
return DatabaseSettings(
|
||||
dsn="sqlite+aiosqlite:///:memory:",
|
||||
pool_size=1,
|
||||
pool_recycle=60,
|
||||
pool_timeout=60,
|
||||
)
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def repositories_container(
|
||||
database_settings: DatabaseSettings,
|
||||
) -> AsyncGenerator[RepositoriesContainer, None]:
|
||||
container = RepositoriesContainer(settings=database_settings)
|
||||
yield container
|
||||
await container.engine.dispose()
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def user_repository(
|
||||
repositories_container: RepositoriesContainer,
|
||||
) -> AsyncGenerator[UserRepository, None]:
|
||||
repository = repositories_container.get_repository(UserRepository)
|
||||
await repository.create_tables()
|
||||
yield repository
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def product_repository_settings(
|
||||
database_settings: DatabaseSettings,
|
||||
) -> AsyncGenerator[ProductRepository, None]:
|
||||
repository = ProductRepository(settings=database_settings)
|
||||
await repository.create_tables()
|
||||
yield repository
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def book_repository(
|
||||
repositories_container: RepositoriesContainer,
|
||||
) -> AsyncGenerator[BookRepository, None]:
|
||||
repository = repositories_container.get_repository(BookRepository)
|
||||
await repository.create_tables()
|
||||
yield repository
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def author_repository(
|
||||
repositories_container: RepositoriesContainer,
|
||||
) -> AsyncGenerator[AuthorRepository, None]:
|
||||
repository = repositories_container.get_repository(AuthorRepository)
|
||||
await repository.create_tables()
|
||||
yield repository
|
||||
78
tests/models.py
Normal file
78
tests/models.py
Normal file
@@ -0,0 +1,78 @@
|
||||
from pydantic import BaseModel
|
||||
from pydantic_filters import BaseFilter
|
||||
from sqlmodel import Field, Relationship
|
||||
|
||||
from metaorm import BaseRepository, BaseTable
|
||||
|
||||
|
||||
class User(BaseModel):
|
||||
id: int | None = None
|
||||
name: str
|
||||
email: str
|
||||
|
||||
|
||||
class UserTable(BaseTable[User], table=True):
|
||||
__tablename__ = "users"
|
||||
id: int | None = Field(default=None, primary_key=True)
|
||||
name: str
|
||||
email: str = Field(unique=True)
|
||||
|
||||
@classmethod
|
||||
def from_item(cls, item: User) -> "UserTable":
|
||||
return cls(id=item.id, name=item.name, email=item.email)
|
||||
|
||||
def to_item(self) -> User:
|
||||
return User(id=self.id, name=self.name, email=self.email)
|
||||
|
||||
|
||||
class UserRepository(BaseRepository):
|
||||
def get_db_table(self) -> type[UserTable]:
|
||||
return UserTable
|
||||
|
||||
def get_dto_type(self) -> type[User]:
|
||||
return User
|
||||
|
||||
|
||||
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
|
||||
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 AuthorTable(BaseTable, table=True):
|
||||
__tablename__ = "authors"
|
||||
id: int | None = Field(default=None, primary_key=True)
|
||||
name: str
|
||||
books: list["BookTable"] = Relationship(back_populates="author")
|
||||
|
||||
|
||||
class BookTable(BaseTable, table=True):
|
||||
__tablename__ = "books"
|
||||
id: int | None = Field(default=None, primary_key=True)
|
||||
title: str
|
||||
author_id: int = Field(foreign_key="authors.id")
|
||||
author: AuthorTable = Relationship(back_populates="books")
|
||||
|
||||
|
||||
class AuthorRepository(BaseRepository):
|
||||
def get_db_table(self) -> type[AuthorTable]:
|
||||
return AuthorTable
|
||||
|
||||
|
||||
class BookRepository(BaseRepository):
|
||||
def get_db_table(self) -> type[BookTable]:
|
||||
return BookTable
|
||||
48
tests/test_container.py
Normal file
48
tests/test_container.py
Normal file
@@ -0,0 +1,48 @@
|
||||
from sqlalchemy.ext.asyncio import AsyncEngine
|
||||
|
||||
from metaorm import RepositoriesContainer
|
||||
from tests.models import UserRepository
|
||||
|
||||
|
||||
class TestRepositoriesContainer:
|
||||
async def test_engine_property_returns_async_engine(
|
||||
self,
|
||||
repositories_container: RepositoriesContainer,
|
||||
) -> None:
|
||||
assert isinstance(repositories_container.engine, AsyncEngine)
|
||||
|
||||
async def test_session_is_none_without_transaction(
|
||||
self,
|
||||
repositories_container: RepositoriesContainer,
|
||||
) -> None:
|
||||
assert repositories_container.session is None
|
||||
|
||||
async def test_transaction_creates_session(
|
||||
self,
|
||||
repositories_container: RepositoriesContainer,
|
||||
) -> None:
|
||||
assert repositories_container.session is None
|
||||
|
||||
async with repositories_container.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_yields_same_session(
|
||||
self,
|
||||
repositories_container: RepositoriesContainer,
|
||||
) -> None:
|
||||
async with (
|
||||
repositories_container.transaction() as outer_session,
|
||||
repositories_container.transaction() as inner_session,
|
||||
):
|
||||
assert inner_session is outer_session
|
||||
|
||||
async def test_get_repository_returns_repository_instance(
|
||||
self,
|
||||
repositories_container: RepositoriesContainer,
|
||||
) -> None:
|
||||
repository = repositories_container.get_repository(UserRepository)
|
||||
|
||||
assert isinstance(repository, UserRepository)
|
||||
56
tests/test_exceptions.py
Normal file
56
tests/test_exceptions.py
Normal file
@@ -0,0 +1,56 @@
|
||||
import pytest
|
||||
|
||||
from metaorm import (
|
||||
AlreadyExistsError,
|
||||
DatabaseException,
|
||||
HaveNoSessionError,
|
||||
NotFoundError,
|
||||
)
|
||||
|
||||
|
||||
class TestExceptions:
|
||||
def test_database_exception_is_base_class(self) -> None:
|
||||
assert issubclass(NotFoundError, DatabaseException)
|
||||
assert issubclass(HaveNoSessionError, DatabaseException)
|
||||
assert issubclass(AlreadyExistsError, DatabaseException)
|
||||
|
||||
def test_not_found_error_default_message(self) -> None:
|
||||
error = NotFoundError()
|
||||
|
||||
assert str(error) == "Not found"
|
||||
|
||||
def test_not_found_error_custom_message(self) -> None:
|
||||
error = NotFoundError(detail="User not found")
|
||||
|
||||
assert str(error) == "User not found"
|
||||
|
||||
def test_have_no_session_error_message(self) -> None:
|
||||
error = HaveNoSessionError()
|
||||
|
||||
assert str(error) == "Have no actual session"
|
||||
|
||||
def test_already_exists_error_default_message(self) -> None:
|
||||
error = AlreadyExistsError()
|
||||
|
||||
assert str(error) == "Record already exists"
|
||||
|
||||
def test_already_exists_error_custom_message(self) -> None:
|
||||
error = AlreadyExistsError(detail="User already exists")
|
||||
|
||||
assert str(error) == "User already exists"
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"exception_class",
|
||||
[
|
||||
DatabaseException,
|
||||
NotFoundError,
|
||||
HaveNoSessionError,
|
||||
AlreadyExistsError,
|
||||
],
|
||||
)
|
||||
def test_all_exceptions_are_catchable_as_database_exception(
|
||||
self,
|
||||
exception_class: type[Exception],
|
||||
) -> None:
|
||||
with pytest.raises(DatabaseException):
|
||||
raise exception_class()
|
||||
219
tests/test_repositories.py
Normal file
219
tests/test_repositories.py
Normal file
@@ -0,0 +1,219 @@
|
||||
import pytest
|
||||
from pydantic_filters import BaseSort, OffsetPagination
|
||||
from sqlalchemy.orm import joinedload
|
||||
|
||||
from metaorm import AlreadyExistsError, DatabaseSettings, HaveNoSessionError
|
||||
from tests.models import (
|
||||
AuthorRepository,
|
||||
AuthorTable,
|
||||
BookRepository,
|
||||
BookTable,
|
||||
ProductFilter,
|
||||
ProductRepository,
|
||||
ProductTable,
|
||||
User,
|
||||
UserRepository,
|
||||
)
|
||||
|
||||
|
||||
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")
|
||||
|
||||
created = await user_repository.create_item(user)
|
||||
|
||||
assert created.id is not None
|
||||
assert created.name == "Alice"
|
||||
assert created.email == "alice@example.com"
|
||||
|
||||
async def test_get_items(self, user_repository: UserRepository) -> None:
|
||||
user1 = User(name="Alice", email="alice@example.com")
|
||||
user2 = User(name="Bob", email="bob@example.com")
|
||||
await user_repository.create_item(user1)
|
||||
await user_repository.create_item(user2)
|
||||
|
||||
items = [item async for item in user_repository.get_items()]
|
||||
|
||||
assert len(items) == 2
|
||||
assert {item.name for item in items} == {"Alice", "Bob"}
|
||||
|
||||
async def test_get_items_count(self, user_repository: UserRepository) -> None:
|
||||
await user_repository.create_item(
|
||||
User(name="Alice", email="alice@example.com"),
|
||||
)
|
||||
await user_repository.create_item(
|
||||
User(name="Bob", email="bob@example.com"),
|
||||
)
|
||||
|
||||
count = await user_repository.get_items_count()
|
||||
|
||||
assert count == 2
|
||||
|
||||
async def test_update_items(self, user_repository: UserRepository) -> None:
|
||||
await user_repository.create_item(
|
||||
User(name="Alice", email="alice@example.com"),
|
||||
)
|
||||
await user_repository.create_item(
|
||||
User(name="Bob", email="bob@example.com"),
|
||||
)
|
||||
|
||||
updated = [item async for item in user_repository.update_items(name="Updated")]
|
||||
|
||||
assert len(updated) == 2
|
||||
assert all(item.name == "Updated" for item in updated)
|
||||
|
||||
async def test_delete_items(self, user_repository: UserRepository) -> None:
|
||||
await user_repository.create_item(
|
||||
User(name="Alice", email="alice@example.com"),
|
||||
)
|
||||
await user_repository.create_item(
|
||||
User(name="Bob", email="bob@example.com"),
|
||||
)
|
||||
|
||||
await user_repository.delete_items()
|
||||
|
||||
count = await user_repository.get_items_count()
|
||||
|
||||
assert count == 0
|
||||
|
||||
async def test_create_item_raises_already_exists_on_duplicate(
|
||||
self,
|
||||
user_repository: UserRepository,
|
||||
) -> None:
|
||||
user = User(name="Alice", email="alice@example.com")
|
||||
await user_repository.create_item(user)
|
||||
|
||||
with pytest.raises(AlreadyExistsError):
|
||||
await user_repository.create_item(user)
|
||||
|
||||
async def test_transaction_reuses_existing_session(
|
||||
self,
|
||||
user_repository: UserRepository,
|
||||
) -> None:
|
||||
async with user_repository.transaction():
|
||||
_ = user_repository.session
|
||||
|
||||
async def test_get_items_with_pagination(
|
||||
self,
|
||||
user_repository: UserRepository,
|
||||
) -> None:
|
||||
for index in range(5):
|
||||
await user_repository.create_item(
|
||||
User(name=f"User{index}", email=f"user{index}@example.com"),
|
||||
)
|
||||
|
||||
pagination = OffsetPagination(offset=1, limit=2)
|
||||
items = [
|
||||
item async for item in user_repository.get_items(pagination=pagination)
|
||||
]
|
||||
|
||||
assert len(items) == 2
|
||||
|
||||
async def test_get_items_with_sort(
|
||||
self,
|
||||
user_repository: UserRepository,
|
||||
) -> None:
|
||||
await user_repository.create_item(
|
||||
User(name="Charlie", email="c@example.com"),
|
||||
)
|
||||
await user_repository.create_item(
|
||||
User(name="Alice", email="a@example.com"),
|
||||
)
|
||||
await user_repository.create_item(
|
||||
User(name="Bob", email="b@example.com"),
|
||||
)
|
||||
|
||||
sort = BaseSort(sort_by="name", sort_by_order="asc")
|
||||
items = [item async for item in user_repository.get_items(sort=sort)]
|
||||
|
||||
assert [item.name for item in items] == ["Alice", "Bob", "Charlie"]
|
||||
|
||||
async def test_init_with_settings_creates_container(
|
||||
self,
|
||||
product_repository_settings: ProductRepository,
|
||||
) -> None:
|
||||
product = ProductTable(name="Widget", price=9.99)
|
||||
|
||||
created = await product_repository_settings.create_item(product)
|
||||
|
||||
assert created.id is not None
|
||||
assert created.name == "Widget"
|
||||
|
||||
async def test_get_filter_type(self) -> None:
|
||||
repository = ProductRepository(settings=DatabaseSettings())
|
||||
|
||||
filter_type = repository.get_filter_type()
|
||||
|
||||
assert filter_type is ProductFilter
|
||||
|
||||
async def test_get_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),
|
||||
)
|
||||
|
||||
items = [
|
||||
item
|
||||
async for item in product_repository_settings.get_items(
|
||||
filter_=ProductFilter(name="Alpha"),
|
||||
)
|
||||
]
|
||||
|
||||
assert len(items) == 1
|
||||
assert items[0].name == "Alpha"
|
||||
|
||||
async def test_get_items_with_options(
|
||||
self,
|
||||
author_repository: AuthorRepository,
|
||||
book_repository: BookRepository,
|
||||
) -> None:
|
||||
author = await author_repository.create_item(AuthorTable(name="Tolkien"))
|
||||
await book_repository.create_item(
|
||||
BookTable(title="The Hobbit", author_id=author.id),
|
||||
)
|
||||
|
||||
books = [
|
||||
item
|
||||
async for item in book_repository.get_items(
|
||||
options=[joinedload(BookTable.author)],
|
||||
)
|
||||
]
|
||||
|
||||
assert len(books) == 1
|
||||
assert books[0].title == "The Hobbit"
|
||||
assert books[0].author.name == "Tolkien"
|
||||
|
||||
async def test_update_items_with_options(
|
||||
self,
|
||||
author_repository: AuthorRepository,
|
||||
book_repository: BookRepository,
|
||||
) -> None:
|
||||
from sqlalchemy.orm import raiseload
|
||||
|
||||
author = await author_repository.create_item(AuthorTable(name="Tolkien"))
|
||||
await book_repository.create_item(
|
||||
BookTable(title="The Hobbit", author_id=author.id),
|
||||
)
|
||||
|
||||
updated = [
|
||||
item
|
||||
async for item in book_repository.update_items(
|
||||
title="Updated",
|
||||
options=[raiseload(BookTable.author)],
|
||||
)
|
||||
]
|
||||
|
||||
assert len(updated) == 1
|
||||
assert updated[0].title == "Updated"
|
||||
47
tests/test_settings.py
Normal file
47
tests/test_settings.py
Normal file
@@ -0,0 +1,47 @@
|
||||
import pytest
|
||||
from pydantic import ValidationError
|
||||
|
||||
from metaorm import DatabaseSettings
|
||||
|
||||
|
||||
class TestDatabaseSettings:
|
||||
def test_default_values(self) -> None:
|
||||
settings = DatabaseSettings()
|
||||
|
||||
assert settings.dsn == "sqlite+aiosqlite:///db.sqlite3"
|
||||
assert settings.pool_size == 5
|
||||
assert settings.pool_recycle == 60
|
||||
assert settings.pool_timeout == 60
|
||||
|
||||
def test_custom_values(self) -> None:
|
||||
settings = DatabaseSettings(
|
||||
dsn="postgresql+asyncpg://user:pass@localhost/db",
|
||||
pool_size=10,
|
||||
pool_recycle=120,
|
||||
pool_timeout=30,
|
||||
)
|
||||
|
||||
assert settings.dsn == "postgresql+asyncpg://user:pass@localhost/db"
|
||||
assert settings.pool_size == 10
|
||||
assert settings.pool_recycle == 120
|
||||
assert settings.pool_timeout == 30
|
||||
|
||||
def test_dsn_must_match_pattern(self) -> None:
|
||||
with pytest.raises(ValidationError):
|
||||
DatabaseSettings(dsn="invalid_dsn")
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"field_name,invalid_value",
|
||||
[
|
||||
("pool_size", 0),
|
||||
("pool_recycle", 0),
|
||||
("pool_timeout", 0),
|
||||
],
|
||||
)
|
||||
def test_integer_fields_must_be_greater_or_equal_one(
|
||||
self,
|
||||
field_name: str,
|
||||
invalid_value: int,
|
||||
) -> None:
|
||||
with pytest.raises(ValidationError):
|
||||
DatabaseSettings(**{field_name: invalid_value})
|
||||
40
tests/test_tables.py
Normal file
40
tests/test_tables.py
Normal file
@@ -0,0 +1,40 @@
|
||||
import pytest
|
||||
from sqlmodel import Field
|
||||
|
||||
from metaorm import BaseTable
|
||||
from tests.models import User
|
||||
|
||||
|
||||
class TestBaseTable:
|
||||
def test_to_values_returns_column_data(self) -> None:
|
||||
from tests.models import UserTable
|
||||
|
||||
user_table = UserTable(id=1, name="Alice", email="alice@example.com")
|
||||
|
||||
values = user_table.to_values()
|
||||
|
||||
assert values == {
|
||||
"id": 1,
|
||||
"name": "Alice",
|
||||
"email": "alice@example.com",
|
||||
}
|
||||
|
||||
def test_from_item_not_implemented_in_base_class(self) -> None:
|
||||
class DummyFromItemTable(BaseTable[User], table=True):
|
||||
__tablename__ = "dummy_from_item"
|
||||
id: int | None = Field(default=None, primary_key=True)
|
||||
|
||||
with pytest.raises(NotImplementedError):
|
||||
DummyFromItemTable.from_item(
|
||||
User(name="Alice", email="alice@example.com"),
|
||||
)
|
||||
|
||||
def test_to_item_not_implemented_in_base_class(self) -> None:
|
||||
class DummyToItemTable(BaseTable[User], table=True):
|
||||
__tablename__ = "dummy_to_item"
|
||||
id: int | None = Field(default=None, primary_key=True)
|
||||
|
||||
dummy = DummyToItemTable(id=1, name="Alice", email="alice@example.com")
|
||||
|
||||
with pytest.raises(NotImplementedError):
|
||||
dummy.to_item()
|
||||
Reference in New Issue
Block a user