metaorm 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- metaorm-0.1.0/PKG-INFO +25 -0
- metaorm-0.1.0/README.md +0 -0
- metaorm-0.1.0/metaorm/__init__.py +26 -0
- metaorm-0.1.0/metaorm/container.py +60 -0
- metaorm-0.1.0/metaorm/exceptions.py +17 -0
- metaorm-0.1.0/metaorm/repositories.py +150 -0
- metaorm-0.1.0/metaorm/settings.py +8 -0
- metaorm-0.1.0/metaorm/tables.py +21 -0
- metaorm-0.1.0/metaorm.egg-info/PKG-INFO +25 -0
- metaorm-0.1.0/metaorm.egg-info/SOURCES.txt +18 -0
- metaorm-0.1.0/metaorm.egg-info/dependency_links.txt +1 -0
- metaorm-0.1.0/metaorm.egg-info/requires.txt +4 -0
- metaorm-0.1.0/metaorm.egg-info/top_level.txt +1 -0
- metaorm-0.1.0/pyproject.toml +60 -0
- metaorm-0.1.0/setup.cfg +4 -0
- metaorm-0.1.0/tests/test_container.py +47 -0
- metaorm-0.1.0/tests/test_exceptions.py +56 -0
- metaorm-0.1.0/tests/test_repositories.py +129 -0
- metaorm-0.1.0/tests/test_settings.py +47 -0
- metaorm-0.1.0/tests/test_tables.py +42 -0
metaorm-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: metaorm
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Async repository layer over SQLModel
|
|
5
|
+
Author-email: Author Name <author@example.com>
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/username/metaorm
|
|
8
|
+
Project-URL: Repository, https://github.com/username/metaorm
|
|
9
|
+
Project-URL: Issues, https://github.com/username/metaorm/issues
|
|
10
|
+
Keywords: async,database,orm,repository,sqlmodel
|
|
11
|
+
Classifier: Development Status :: 3 - Alpha
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
14
|
+
Classifier: Operating System :: OS Independent
|
|
15
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
18
|
+
Classifier: Topic :: Database
|
|
19
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
20
|
+
Requires-Python: >=3.12
|
|
21
|
+
Description-Content-Type: text/markdown
|
|
22
|
+
Requires-Dist: pydantic>=2.0
|
|
23
|
+
Requires-Dist: pydantic-filters>=0.3.5
|
|
24
|
+
Requires-Dist: sqlalchemy>=2.0
|
|
25
|
+
Requires-Dist: sqlmodel>=0.0.22
|
metaorm-0.1.0/README.md
ADDED
|
File without changes
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
from .container import RepositoriesContainer
|
|
2
|
+
from .exceptions import (
|
|
3
|
+
AlreadyExistsError,
|
|
4
|
+
DatabaseException,
|
|
5
|
+
HaveNoSessionError,
|
|
6
|
+
NotFoundError,
|
|
7
|
+
)
|
|
8
|
+
from .repositories import BaseRepository
|
|
9
|
+
from .settings import DatabaseSettings
|
|
10
|
+
from .tables import BaseTable
|
|
11
|
+
|
|
12
|
+
__all__ = (
|
|
13
|
+
# container
|
|
14
|
+
"RepositoriesContainer",
|
|
15
|
+
# exceptions
|
|
16
|
+
"AlreadyExistsError",
|
|
17
|
+
"DatabaseException",
|
|
18
|
+
"HaveNoSessionError",
|
|
19
|
+
"NotFoundError",
|
|
20
|
+
# repositories
|
|
21
|
+
"BaseRepository",
|
|
22
|
+
# settings
|
|
23
|
+
"DatabaseSettings",
|
|
24
|
+
# tables
|
|
25
|
+
"BaseTable",
|
|
26
|
+
)
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
from collections.abc import AsyncGenerator
|
|
2
|
+
from contextlib import asynccontextmanager
|
|
3
|
+
from typing import TypeVar
|
|
4
|
+
|
|
5
|
+
import contextvars
|
|
6
|
+
|
|
7
|
+
from sqlalchemy.ext.asyncio import AsyncEngine, create_async_engine
|
|
8
|
+
from sqlmodel.ext.asyncio.session import AsyncSession
|
|
9
|
+
|
|
10
|
+
from .settings import DatabaseSettings
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
RepositoryType = TypeVar("RepositoryType", bound="BaseRepository")
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class RepositoriesContainer:
|
|
17
|
+
def __init__(self, settings: DatabaseSettings):
|
|
18
|
+
engine_parameters = {
|
|
19
|
+
"url": settings.dsn,
|
|
20
|
+
"pool_recycle": settings.pool_recycle,
|
|
21
|
+
}
|
|
22
|
+
if not settings.dsn.startswith("sqlite"):
|
|
23
|
+
engine_parameters["pool_timeout"] = settings.pool_timeout
|
|
24
|
+
engine_parameters["pool_size"] = settings.pool_size
|
|
25
|
+
|
|
26
|
+
self._engine = create_async_engine(**engine_parameters)
|
|
27
|
+
self._session_context = contextvars.ContextVar(
|
|
28
|
+
"session_context",
|
|
29
|
+
default=None,
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
@property
|
|
33
|
+
def engine(self) -> AsyncEngine:
|
|
34
|
+
return self._engine
|
|
35
|
+
|
|
36
|
+
@property
|
|
37
|
+
def session(self) -> AsyncSession | None:
|
|
38
|
+
return self._session_context.get()
|
|
39
|
+
|
|
40
|
+
@asynccontextmanager
|
|
41
|
+
async def transaction(self) -> AsyncGenerator[AsyncSession, None]:
|
|
42
|
+
existing_session = self._session_context.get()
|
|
43
|
+
if existing_session is not None:
|
|
44
|
+
yield existing_session
|
|
45
|
+
return
|
|
46
|
+
|
|
47
|
+
session_parameters = {
|
|
48
|
+
"bind": self._engine,
|
|
49
|
+
"expire_on_commit": False,
|
|
50
|
+
}
|
|
51
|
+
async with AsyncSession(**session_parameters) as session:
|
|
52
|
+
token = self._session_context.set(session)
|
|
53
|
+
try:
|
|
54
|
+
async with session.begin():
|
|
55
|
+
yield session
|
|
56
|
+
finally:
|
|
57
|
+
self._session_context.reset(token)
|
|
58
|
+
|
|
59
|
+
def get_repository(self, repository_class: type[RepositoryType]) -> RepositoryType:
|
|
60
|
+
return repository_class(container=self)
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
class DatabaseException(Exception):
|
|
2
|
+
pass
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class NotFoundError(DatabaseException):
|
|
6
|
+
def __init__(self, detail: str = "Not found"):
|
|
7
|
+
super().__init__(detail)
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class HaveNoSessionError(DatabaseException):
|
|
11
|
+
def __init__(self):
|
|
12
|
+
super().__init__("Have no actual session")
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class AlreadyExistsError(DatabaseException):
|
|
16
|
+
def __init__(self, detail: str = "Record already exists"):
|
|
17
|
+
super().__init__(detail)
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
from collections.abc import AsyncGenerator, Sequence
|
|
2
|
+
from contextlib import asynccontextmanager
|
|
3
|
+
from typing import Any, Generic, TypeVar
|
|
4
|
+
|
|
5
|
+
from pydantic_filters import BasePagination, BaseSort
|
|
6
|
+
from pydantic_filters.drivers.sqlalchemy import append_to_statement
|
|
7
|
+
from sqlalchemy import func
|
|
8
|
+
from sqlalchemy.exc import IntegrityError
|
|
9
|
+
from sqlmodel import delete, insert, select, update
|
|
10
|
+
from sqlmodel.ext.asyncio.session import AsyncSession
|
|
11
|
+
|
|
12
|
+
from .container import RepositoriesContainer
|
|
13
|
+
from .exceptions import AlreadyExistsError, DatabaseException, HaveNoSessionError
|
|
14
|
+
from .tables import BaseTable
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
DTOType = TypeVar("DTOType")
|
|
18
|
+
FilterType = TypeVar("FilterType")
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class BaseRepository(Generic[DTOType, FilterType]):
|
|
22
|
+
def __init__(self, container: RepositoriesContainer):
|
|
23
|
+
self._container = container
|
|
24
|
+
|
|
25
|
+
@property
|
|
26
|
+
def session(self) -> AsyncSession:
|
|
27
|
+
session = self._container.session
|
|
28
|
+
if session is None:
|
|
29
|
+
raise HaveNoSessionError()
|
|
30
|
+
return session
|
|
31
|
+
|
|
32
|
+
@asynccontextmanager
|
|
33
|
+
async def transaction(self) -> AsyncGenerator[None, None]:
|
|
34
|
+
existing_session = self._container.session
|
|
35
|
+
if existing_session is not None:
|
|
36
|
+
yield
|
|
37
|
+
return
|
|
38
|
+
|
|
39
|
+
async with self._container.transaction():
|
|
40
|
+
yield
|
|
41
|
+
|
|
42
|
+
def get_db_table(self) -> type[BaseTable]:
|
|
43
|
+
raise NotImplementedError
|
|
44
|
+
|
|
45
|
+
async def create_tables(self) -> None:
|
|
46
|
+
table = self.get_db_table()
|
|
47
|
+
async with self._container.engine.begin() as connection:
|
|
48
|
+
await connection.run_sync(
|
|
49
|
+
table.metadata.create_all,
|
|
50
|
+
tables=[table.__table__],
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
async def get_items_count(
|
|
54
|
+
self,
|
|
55
|
+
filter_: FilterType | None = None,
|
|
56
|
+
) -> int:
|
|
57
|
+
table = self.get_db_table()
|
|
58
|
+
statement = select(func.count()).select_from(table)
|
|
59
|
+
statement = append_to_statement(
|
|
60
|
+
statement=statement,
|
|
61
|
+
model=table,
|
|
62
|
+
filter_=filter_,
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
async with self.transaction():
|
|
66
|
+
result = await self.session.exec(statement)
|
|
67
|
+
items_count = result.first()
|
|
68
|
+
|
|
69
|
+
return items_count
|
|
70
|
+
|
|
71
|
+
async def get_items(
|
|
72
|
+
self,
|
|
73
|
+
filter_: FilterType | None = None,
|
|
74
|
+
pagination: BasePagination | None = None,
|
|
75
|
+
sort: BaseSort | None = None,
|
|
76
|
+
options: Sequence[Any] | None = None,
|
|
77
|
+
) -> AsyncGenerator[DTOType]:
|
|
78
|
+
table = self.get_db_table()
|
|
79
|
+
statement = select(table)
|
|
80
|
+
statement = append_to_statement(
|
|
81
|
+
statement=statement,
|
|
82
|
+
model=table,
|
|
83
|
+
filter_=filter_,
|
|
84
|
+
pagination=pagination,
|
|
85
|
+
sort=sort,
|
|
86
|
+
)
|
|
87
|
+
if options:
|
|
88
|
+
statement = statement.options(*options)
|
|
89
|
+
|
|
90
|
+
async with self.transaction():
|
|
91
|
+
result = await self.session.execute(statement)
|
|
92
|
+
result = result.yield_per(100)
|
|
93
|
+
for db_item in result.scalars():
|
|
94
|
+
yield db_item.to_item()
|
|
95
|
+
|
|
96
|
+
async def create_item(self, item: DTOType) -> DTOType:
|
|
97
|
+
table = self.get_db_table()
|
|
98
|
+
values = table.from_item(item=item).to_values()
|
|
99
|
+
statement = insert(table).values(values).returning(table)
|
|
100
|
+
|
|
101
|
+
async with self.transaction():
|
|
102
|
+
try:
|
|
103
|
+
result = await self.session.exec(statement)
|
|
104
|
+
except IntegrityError as error:
|
|
105
|
+
error_message = str(error.orig).lower()
|
|
106
|
+
if "unique" in error_message or "duplicate" in error_message:
|
|
107
|
+
raise AlreadyExistsError(
|
|
108
|
+
f"Record for model '{table.__name__}' already exists",
|
|
109
|
+
) from error
|
|
110
|
+
raise DatabaseException(
|
|
111
|
+
f"Integrity error for model '{table.__name__}'",
|
|
112
|
+
) from error
|
|
113
|
+
db_item = result.scalar_one()
|
|
114
|
+
|
|
115
|
+
return db_item.to_item()
|
|
116
|
+
|
|
117
|
+
async def update_items(
|
|
118
|
+
self,
|
|
119
|
+
filter_: FilterType | None = None,
|
|
120
|
+
**values,
|
|
121
|
+
) -> AsyncGenerator[DTOType]:
|
|
122
|
+
table = self.get_db_table()
|
|
123
|
+
statement = update(table)
|
|
124
|
+
statement = append_to_statement(
|
|
125
|
+
statement=statement,
|
|
126
|
+
model=table,
|
|
127
|
+
filter_=filter_,
|
|
128
|
+
)
|
|
129
|
+
statement = statement.values(**values).returning(table)
|
|
130
|
+
|
|
131
|
+
async with self.transaction():
|
|
132
|
+
result = await self.session.exec(statement)
|
|
133
|
+
result = result.yield_per(100)
|
|
134
|
+
for db_item in result.scalars():
|
|
135
|
+
yield db_item.to_item()
|
|
136
|
+
|
|
137
|
+
async def delete_items(
|
|
138
|
+
self,
|
|
139
|
+
filter_: FilterType | None = None,
|
|
140
|
+
) -> None:
|
|
141
|
+
table = self.get_db_table()
|
|
142
|
+
statement = delete(table)
|
|
143
|
+
statement = append_to_statement(
|
|
144
|
+
statement=statement,
|
|
145
|
+
model=table,
|
|
146
|
+
filter_=filter_,
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
async with self.transaction():
|
|
150
|
+
await self.session.exec(statement)
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
from pydantic import BaseModel, Field
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class DatabaseSettings(BaseModel):
|
|
5
|
+
dsn: str = Field(default="sqlite+aiosqlite:///db.sqlite3", pattern=r"^.+://")
|
|
6
|
+
pool_size: int = Field(default=5, ge=1)
|
|
7
|
+
pool_recycle: int = Field(default=60, ge=1) # in seconds: 1 minute
|
|
8
|
+
pool_timeout: int = Field(default=60, ge=1) # in seconds: 1 minute
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
from typing import Any, Generic, Self, TypeVar
|
|
2
|
+
|
|
3
|
+
from pydantic import BaseModel
|
|
4
|
+
from sqlmodel import SQLModel
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
ItemType = TypeVar("ItemType", bound=BaseModel)
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class BaseTable(SQLModel, Generic[ItemType]):
|
|
11
|
+
@classmethod
|
|
12
|
+
def from_item(cls, item: ItemType) -> Self:
|
|
13
|
+
raise NotImplementedError
|
|
14
|
+
|
|
15
|
+
def to_item(self) -> ItemType:
|
|
16
|
+
raise NotImplementedError
|
|
17
|
+
|
|
18
|
+
def to_values(self) -> dict[str, Any]:
|
|
19
|
+
return {
|
|
20
|
+
column.name: getattr(self, column.name) for column in self.__table__.columns
|
|
21
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: metaorm
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Async repository layer over SQLModel
|
|
5
|
+
Author-email: Author Name <author@example.com>
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/username/metaorm
|
|
8
|
+
Project-URL: Repository, https://github.com/username/metaorm
|
|
9
|
+
Project-URL: Issues, https://github.com/username/metaorm/issues
|
|
10
|
+
Keywords: async,database,orm,repository,sqlmodel
|
|
11
|
+
Classifier: Development Status :: 3 - Alpha
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
14
|
+
Classifier: Operating System :: OS Independent
|
|
15
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
18
|
+
Classifier: Topic :: Database
|
|
19
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
20
|
+
Requires-Python: >=3.12
|
|
21
|
+
Description-Content-Type: text/markdown
|
|
22
|
+
Requires-Dist: pydantic>=2.0
|
|
23
|
+
Requires-Dist: pydantic-filters>=0.3.5
|
|
24
|
+
Requires-Dist: sqlalchemy>=2.0
|
|
25
|
+
Requires-Dist: sqlmodel>=0.0.22
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
README.md
|
|
2
|
+
pyproject.toml
|
|
3
|
+
metaorm/__init__.py
|
|
4
|
+
metaorm/container.py
|
|
5
|
+
metaorm/exceptions.py
|
|
6
|
+
metaorm/repositories.py
|
|
7
|
+
metaorm/settings.py
|
|
8
|
+
metaorm/tables.py
|
|
9
|
+
metaorm.egg-info/PKG-INFO
|
|
10
|
+
metaorm.egg-info/SOURCES.txt
|
|
11
|
+
metaorm.egg-info/dependency_links.txt
|
|
12
|
+
metaorm.egg-info/requires.txt
|
|
13
|
+
metaorm.egg-info/top_level.txt
|
|
14
|
+
tests/test_container.py
|
|
15
|
+
tests/test_exceptions.py
|
|
16
|
+
tests/test_repositories.py
|
|
17
|
+
tests/test_settings.py
|
|
18
|
+
tests/test_tables.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
metaorm
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=61.0", "wheel"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "metaorm"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Async repository layer over SQLModel"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = {text = "MIT"}
|
|
11
|
+
requires-python = ">=3.12"
|
|
12
|
+
authors = [
|
|
13
|
+
{name = "Author Name", email = "author@example.com"},
|
|
14
|
+
]
|
|
15
|
+
keywords = [
|
|
16
|
+
"async",
|
|
17
|
+
"database",
|
|
18
|
+
"orm",
|
|
19
|
+
"repository",
|
|
20
|
+
"sqlmodel",
|
|
21
|
+
]
|
|
22
|
+
classifiers = [
|
|
23
|
+
"Development Status :: 3 - Alpha",
|
|
24
|
+
"Intended Audience :: Developers",
|
|
25
|
+
"License :: OSI Approved :: MIT License",
|
|
26
|
+
"Operating System :: OS Independent",
|
|
27
|
+
"Programming Language :: Python :: 3 :: Only",
|
|
28
|
+
"Programming Language :: Python :: 3.12",
|
|
29
|
+
"Programming Language :: Python :: 3.13",
|
|
30
|
+
"Topic :: Database",
|
|
31
|
+
"Topic :: Software Development :: Libraries :: Python Modules",
|
|
32
|
+
]
|
|
33
|
+
dependencies = [
|
|
34
|
+
"pydantic>=2.0",
|
|
35
|
+
"pydantic-filters>=0.3.5",
|
|
36
|
+
"sqlalchemy>=2.0",
|
|
37
|
+
"sqlmodel>=0.0.22",
|
|
38
|
+
]
|
|
39
|
+
|
|
40
|
+
[project.urls]
|
|
41
|
+
Homepage = "https://github.com/username/metaorm"
|
|
42
|
+
Repository = "https://github.com/username/metaorm"
|
|
43
|
+
Issues = "https://github.com/username/metaorm/issues"
|
|
44
|
+
|
|
45
|
+
[dependency-groups]
|
|
46
|
+
dev = [
|
|
47
|
+
"aiosqlite>=0.22.0",
|
|
48
|
+
"pytest>=9.1.1",
|
|
49
|
+
"pytest-asyncio>=1.4.0",
|
|
50
|
+
"pytest-cov>=7.1.0",
|
|
51
|
+
"ruff>=0.16.2",
|
|
52
|
+
]
|
|
53
|
+
|
|
54
|
+
[tool.setuptools.packages.find]
|
|
55
|
+
where = ["."]
|
|
56
|
+
include = ["metaorm*"]
|
|
57
|
+
|
|
58
|
+
[tool.pytest.ini_options]
|
|
59
|
+
asyncio_mode = "auto"
|
|
60
|
+
testpaths = ["tests"]
|
metaorm-0.1.0/setup.cfg
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import pytest
|
|
2
|
+
from sqlalchemy.ext.asyncio import AsyncEngine
|
|
3
|
+
|
|
4
|
+
from metaorm import HaveNoSessionError, RepositoriesContainer
|
|
5
|
+
from tests.models import UserRepository
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class TestRepositoriesContainer:
|
|
9
|
+
async def test_engine_property_returns_async_engine(
|
|
10
|
+
self,
|
|
11
|
+
repositories_container: RepositoriesContainer,
|
|
12
|
+
) -> None:
|
|
13
|
+
assert isinstance(repositories_container.engine, AsyncEngine)
|
|
14
|
+
|
|
15
|
+
async def test_session_is_none_without_transaction(
|
|
16
|
+
self,
|
|
17
|
+
repositories_container: RepositoriesContainer,
|
|
18
|
+
) -> None:
|
|
19
|
+
assert repositories_container.session is None
|
|
20
|
+
|
|
21
|
+
async def test_transaction_creates_session(
|
|
22
|
+
self,
|
|
23
|
+
repositories_container: RepositoriesContainer,
|
|
24
|
+
) -> None:
|
|
25
|
+
assert repositories_container.session is None
|
|
26
|
+
|
|
27
|
+
async with repositories_container.transaction() as session:
|
|
28
|
+
assert session is not None
|
|
29
|
+
assert repositories_container.session is session
|
|
30
|
+
|
|
31
|
+
assert repositories_container.session is None
|
|
32
|
+
|
|
33
|
+
async def test_nested_transaction_yields_same_session(
|
|
34
|
+
self,
|
|
35
|
+
repositories_container: RepositoriesContainer,
|
|
36
|
+
) -> None:
|
|
37
|
+
async with repositories_container.transaction() as outer_session:
|
|
38
|
+
async with repositories_container.transaction() as inner_session:
|
|
39
|
+
assert inner_session is outer_session
|
|
40
|
+
|
|
41
|
+
async def test_get_repository_returns_repository_instance(
|
|
42
|
+
self,
|
|
43
|
+
repositories_container: RepositoriesContainer,
|
|
44
|
+
) -> None:
|
|
45
|
+
repository = repositories_container.get_repository(UserRepository)
|
|
46
|
+
|
|
47
|
+
assert isinstance(repository, UserRepository)
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import pytest
|
|
2
|
+
|
|
3
|
+
from metaorm import (
|
|
4
|
+
AlreadyExistsError,
|
|
5
|
+
DatabaseException,
|
|
6
|
+
HaveNoSessionError,
|
|
7
|
+
NotFoundError,
|
|
8
|
+
)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class TestExceptions:
|
|
12
|
+
def test_database_exception_is_base_class(self) -> None:
|
|
13
|
+
assert issubclass(NotFoundError, DatabaseException)
|
|
14
|
+
assert issubclass(HaveNoSessionError, DatabaseException)
|
|
15
|
+
assert issubclass(AlreadyExistsError, DatabaseException)
|
|
16
|
+
|
|
17
|
+
def test_not_found_error_default_message(self) -> None:
|
|
18
|
+
error = NotFoundError()
|
|
19
|
+
|
|
20
|
+
assert str(error) == "Not found"
|
|
21
|
+
|
|
22
|
+
def test_not_found_error_custom_message(self) -> None:
|
|
23
|
+
error = NotFoundError(detail="User not found")
|
|
24
|
+
|
|
25
|
+
assert str(error) == "User not found"
|
|
26
|
+
|
|
27
|
+
def test_have_no_session_error_message(self) -> None:
|
|
28
|
+
error = HaveNoSessionError()
|
|
29
|
+
|
|
30
|
+
assert str(error) == "Have no actual session"
|
|
31
|
+
|
|
32
|
+
def test_already_exists_error_default_message(self) -> None:
|
|
33
|
+
error = AlreadyExistsError()
|
|
34
|
+
|
|
35
|
+
assert str(error) == "Record already exists"
|
|
36
|
+
|
|
37
|
+
def test_already_exists_error_custom_message(self) -> None:
|
|
38
|
+
error = AlreadyExistsError(detail="User already exists")
|
|
39
|
+
|
|
40
|
+
assert str(error) == "User already exists"
|
|
41
|
+
|
|
42
|
+
@pytest.mark.parametrize(
|
|
43
|
+
"exception_class",
|
|
44
|
+
[
|
|
45
|
+
DatabaseException,
|
|
46
|
+
NotFoundError,
|
|
47
|
+
HaveNoSessionError,
|
|
48
|
+
AlreadyExistsError,
|
|
49
|
+
],
|
|
50
|
+
)
|
|
51
|
+
def test_all_exceptions_are_catchable_as_database_exception(
|
|
52
|
+
self,
|
|
53
|
+
exception_class: type[Exception],
|
|
54
|
+
) -> None:
|
|
55
|
+
with pytest.raises(DatabaseException):
|
|
56
|
+
raise exception_class()
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import pytest
|
|
2
|
+
from pydantic_filters import BaseSort, OffsetPagination
|
|
3
|
+
|
|
4
|
+
from metaorm import AlreadyExistsError, HaveNoSessionError
|
|
5
|
+
from tests.models import User, UserRepository
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class TestBaseRepository:
|
|
9
|
+
async def test_session_raises_error_without_transaction(
|
|
10
|
+
self,
|
|
11
|
+
user_repository: UserRepository,
|
|
12
|
+
) -> None:
|
|
13
|
+
with pytest.raises(HaveNoSessionError):
|
|
14
|
+
_ = user_repository.session
|
|
15
|
+
|
|
16
|
+
async def test_create_item(self, user_repository: UserRepository) -> None:
|
|
17
|
+
user = User(name="Alice", email="alice@example.com")
|
|
18
|
+
|
|
19
|
+
created = await user_repository.create_item(user)
|
|
20
|
+
|
|
21
|
+
assert created.id is not None
|
|
22
|
+
assert created.name == "Alice"
|
|
23
|
+
assert created.email == "alice@example.com"
|
|
24
|
+
|
|
25
|
+
async def test_get_items(self, user_repository: UserRepository) -> None:
|
|
26
|
+
user1 = User(name="Alice", email="alice@example.com")
|
|
27
|
+
user2 = User(name="Bob", email="bob@example.com")
|
|
28
|
+
await user_repository.create_item(user1)
|
|
29
|
+
await user_repository.create_item(user2)
|
|
30
|
+
|
|
31
|
+
items = [item async for item in user_repository.get_items()]
|
|
32
|
+
|
|
33
|
+
assert len(items) == 2
|
|
34
|
+
assert {item.name for item in items} == {"Alice", "Bob"}
|
|
35
|
+
|
|
36
|
+
async def test_get_items_count(self, user_repository: UserRepository) -> None:
|
|
37
|
+
await user_repository.create_item(
|
|
38
|
+
User(name="Alice", email="alice@example.com"),
|
|
39
|
+
)
|
|
40
|
+
await user_repository.create_item(
|
|
41
|
+
User(name="Bob", email="bob@example.com"),
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
count = await user_repository.get_items_count()
|
|
45
|
+
|
|
46
|
+
assert count == 2
|
|
47
|
+
|
|
48
|
+
async def test_update_items(self, user_repository: UserRepository) -> None:
|
|
49
|
+
await user_repository.create_item(
|
|
50
|
+
User(name="Alice", email="alice@example.com"),
|
|
51
|
+
)
|
|
52
|
+
await user_repository.create_item(
|
|
53
|
+
User(name="Bob", email="bob@example.com"),
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
updated = [
|
|
57
|
+
item
|
|
58
|
+
async for item in user_repository.update_items(name="Updated")
|
|
59
|
+
]
|
|
60
|
+
|
|
61
|
+
assert len(updated) == 2
|
|
62
|
+
assert all(item.name == "Updated" for item in updated)
|
|
63
|
+
|
|
64
|
+
async def test_delete_items(self, user_repository: UserRepository) -> None:
|
|
65
|
+
await user_repository.create_item(
|
|
66
|
+
User(name="Alice", email="alice@example.com"),
|
|
67
|
+
)
|
|
68
|
+
await user_repository.create_item(
|
|
69
|
+
User(name="Bob", email="bob@example.com"),
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
await user_repository.delete_items()
|
|
73
|
+
|
|
74
|
+
count = await user_repository.get_items_count()
|
|
75
|
+
|
|
76
|
+
assert count == 0
|
|
77
|
+
|
|
78
|
+
async def test_create_item_raises_already_exists_on_duplicate(
|
|
79
|
+
self,
|
|
80
|
+
user_repository: UserRepository,
|
|
81
|
+
) -> None:
|
|
82
|
+
user = User(name="Alice", email="alice@example.com")
|
|
83
|
+
await user_repository.create_item(user)
|
|
84
|
+
|
|
85
|
+
with pytest.raises(AlreadyExistsError):
|
|
86
|
+
await user_repository.create_item(user)
|
|
87
|
+
|
|
88
|
+
async def test_transaction_reuses_existing_session(
|
|
89
|
+
self,
|
|
90
|
+
user_repository: UserRepository,
|
|
91
|
+
) -> None:
|
|
92
|
+
async with user_repository.transaction():
|
|
93
|
+
_ = user_repository.session
|
|
94
|
+
|
|
95
|
+
async def test_get_items_with_pagination(
|
|
96
|
+
self,
|
|
97
|
+
user_repository: UserRepository,
|
|
98
|
+
) -> None:
|
|
99
|
+
for index in range(5):
|
|
100
|
+
await user_repository.create_item(
|
|
101
|
+
User(name=f"User{index}", email=f"user{index}@example.com"),
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
pagination = OffsetPagination(offset=1, limit=2)
|
|
105
|
+
items = [
|
|
106
|
+
item
|
|
107
|
+
async for item in user_repository.get_items(pagination=pagination)
|
|
108
|
+
]
|
|
109
|
+
|
|
110
|
+
assert len(items) == 2
|
|
111
|
+
|
|
112
|
+
async def test_get_items_with_sort(
|
|
113
|
+
self,
|
|
114
|
+
user_repository: UserRepository,
|
|
115
|
+
) -> None:
|
|
116
|
+
await user_repository.create_item(
|
|
117
|
+
User(name="Charlie", email="c@example.com"),
|
|
118
|
+
)
|
|
119
|
+
await user_repository.create_item(
|
|
120
|
+
User(name="Alice", email="a@example.com"),
|
|
121
|
+
)
|
|
122
|
+
await user_repository.create_item(
|
|
123
|
+
User(name="Bob", email="b@example.com"),
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
sort = BaseSort(sort_by="name", sort_by_order="asc")
|
|
127
|
+
items = [item async for item in user_repository.get_items(sort=sort)]
|
|
128
|
+
|
|
129
|
+
assert [item.name for item in items] == ["Alice", "Bob", "Charlie"]
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import pytest
|
|
2
|
+
from pydantic import ValidationError
|
|
3
|
+
|
|
4
|
+
from metaorm import DatabaseSettings
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class TestDatabaseSettings:
|
|
8
|
+
def test_default_values(self) -> None:
|
|
9
|
+
settings = DatabaseSettings()
|
|
10
|
+
|
|
11
|
+
assert settings.dsn == "sqlite+aiosqlite:///db.sqlite3"
|
|
12
|
+
assert settings.pool_size == 5
|
|
13
|
+
assert settings.pool_recycle == 60
|
|
14
|
+
assert settings.pool_timeout == 60
|
|
15
|
+
|
|
16
|
+
def test_custom_values(self) -> None:
|
|
17
|
+
settings = DatabaseSettings(
|
|
18
|
+
dsn="postgresql+asyncpg://user:pass@localhost/db",
|
|
19
|
+
pool_size=10,
|
|
20
|
+
pool_recycle=120,
|
|
21
|
+
pool_timeout=30,
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
assert settings.dsn == "postgresql+asyncpg://user:pass@localhost/db"
|
|
25
|
+
assert settings.pool_size == 10
|
|
26
|
+
assert settings.pool_recycle == 120
|
|
27
|
+
assert settings.pool_timeout == 30
|
|
28
|
+
|
|
29
|
+
def test_dsn_must_match_pattern(self) -> None:
|
|
30
|
+
with pytest.raises(ValidationError):
|
|
31
|
+
DatabaseSettings(dsn="invalid_dsn")
|
|
32
|
+
|
|
33
|
+
@pytest.mark.parametrize(
|
|
34
|
+
"field_name,invalid_value",
|
|
35
|
+
[
|
|
36
|
+
("pool_size", 0),
|
|
37
|
+
("pool_recycle", 0),
|
|
38
|
+
("pool_timeout", 0),
|
|
39
|
+
],
|
|
40
|
+
)
|
|
41
|
+
def test_integer_fields_must_be_greater_or_equal_one(
|
|
42
|
+
self,
|
|
43
|
+
field_name: str,
|
|
44
|
+
invalid_value: int,
|
|
45
|
+
) -> None:
|
|
46
|
+
with pytest.raises(ValidationError):
|
|
47
|
+
DatabaseSettings(**{field_name: invalid_value})
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
from typing import Optional
|
|
2
|
+
|
|
3
|
+
import pytest
|
|
4
|
+
from sqlmodel import Field, SQLModel
|
|
5
|
+
|
|
6
|
+
from metaorm import BaseTable
|
|
7
|
+
from tests.models import User
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class TestBaseTable:
|
|
11
|
+
def test_to_values_returns_column_data(self) -> None:
|
|
12
|
+
from tests.models import UserTable
|
|
13
|
+
|
|
14
|
+
user_table = UserTable(id=1, name="Alice", email="alice@example.com")
|
|
15
|
+
|
|
16
|
+
values = user_table.to_values()
|
|
17
|
+
|
|
18
|
+
assert values == {
|
|
19
|
+
"id": 1,
|
|
20
|
+
"name": "Alice",
|
|
21
|
+
"email": "alice@example.com",
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
def test_from_item_not_implemented_in_base_class(self) -> None:
|
|
25
|
+
class DummyFromItemTable(BaseTable[User], table=True):
|
|
26
|
+
__tablename__ = "dummy_from_item"
|
|
27
|
+
id: Optional[int] = Field(default=None, primary_key=True)
|
|
28
|
+
|
|
29
|
+
with pytest.raises(NotImplementedError):
|
|
30
|
+
DummyFromItemTable.from_item(
|
|
31
|
+
User(name="Alice", email="alice@example.com"),
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
def test_to_item_not_implemented_in_base_class(self) -> None:
|
|
35
|
+
class DummyToItemTable(BaseTable[User], table=True):
|
|
36
|
+
__tablename__ = "dummy_to_item"
|
|
37
|
+
id: Optional[int] = Field(default=None, primary_key=True)
|
|
38
|
+
|
|
39
|
+
dummy = DummyToItemTable(id=1, name="Alice", email="alice@example.com")
|
|
40
|
+
|
|
41
|
+
with pytest.raises(NotImplementedError):
|
|
42
|
+
dummy.to_item()
|