yaddd-sqlalchemy 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.
- yaddd_sqlalchemy-0.1.0/.gitignore +165 -0
- yaddd_sqlalchemy-0.1.0/LICENSE +0 -0
- yaddd_sqlalchemy-0.1.0/PKG-INFO +31 -0
- yaddd_sqlalchemy-0.1.0/README.md +9 -0
- yaddd_sqlalchemy-0.1.0/pyproject.toml +39 -0
- yaddd_sqlalchemy-0.1.0/src/yaddd_sqlalchemy/__init__.py +8 -0
- yaddd_sqlalchemy-0.1.0/src/yaddd_sqlalchemy/py.typed +0 -0
- yaddd_sqlalchemy-0.1.0/src/yaddd_sqlalchemy/repositories.py +87 -0
- yaddd_sqlalchemy-0.1.0/src/yaddd_sqlalchemy/uow.py +47 -0
- yaddd_sqlalchemy-0.1.0/src/yaddd_sqlalchemy/value_objects.py +33 -0
- yaddd_sqlalchemy-0.1.0/tests/conftest.py +54 -0
- yaddd_sqlalchemy-0.1.0/tests/test_repository.py +33 -0
- yaddd_sqlalchemy-0.1.0/tests/test_uow.py +65 -0
- yaddd_sqlalchemy-0.1.0/tests/test_value_objects.py +61 -0
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
# Byte-compiled / optimized / DLL files
|
|
2
|
+
__pycache__/
|
|
3
|
+
*.py[cod]
|
|
4
|
+
*$py.class
|
|
5
|
+
|
|
6
|
+
# C extensions
|
|
7
|
+
*.so
|
|
8
|
+
|
|
9
|
+
# Distribution / packaging
|
|
10
|
+
.Python
|
|
11
|
+
build/
|
|
12
|
+
develop-eggs/
|
|
13
|
+
dist/
|
|
14
|
+
downloads/
|
|
15
|
+
eggs/
|
|
16
|
+
.eggs/
|
|
17
|
+
lib/
|
|
18
|
+
lib64/
|
|
19
|
+
parts/
|
|
20
|
+
sdist/
|
|
21
|
+
var/
|
|
22
|
+
wheels/
|
|
23
|
+
share/python-wheels/
|
|
24
|
+
*.egg-info/
|
|
25
|
+
.installed.cfg
|
|
26
|
+
*.egg
|
|
27
|
+
MANIFEST
|
|
28
|
+
|
|
29
|
+
# PyInstaller
|
|
30
|
+
# Usually these files are written by a python script from a template
|
|
31
|
+
# before PyInstaller builds the exe, so as to inject date/other infos into it.
|
|
32
|
+
*.manifest
|
|
33
|
+
*.spec
|
|
34
|
+
|
|
35
|
+
# Installer logs
|
|
36
|
+
pip-log.txt
|
|
37
|
+
pip-delete-this-directory.txt
|
|
38
|
+
|
|
39
|
+
# Unit test / coverage reports
|
|
40
|
+
htmlcov/
|
|
41
|
+
.tox/
|
|
42
|
+
.nox/
|
|
43
|
+
.coverage
|
|
44
|
+
.coverage.*
|
|
45
|
+
.cache
|
|
46
|
+
nosetests.xml
|
|
47
|
+
coverage.xml
|
|
48
|
+
*.cover
|
|
49
|
+
*.py,cover
|
|
50
|
+
.hypothesis/
|
|
51
|
+
.pytest_cache/
|
|
52
|
+
cover/
|
|
53
|
+
|
|
54
|
+
# Translations
|
|
55
|
+
*.mo
|
|
56
|
+
*.pot
|
|
57
|
+
|
|
58
|
+
# Django stuff:
|
|
59
|
+
*.log
|
|
60
|
+
local_settings.py
|
|
61
|
+
db.sqlite3
|
|
62
|
+
db.sqlite3-journal
|
|
63
|
+
|
|
64
|
+
# Flask stuff:
|
|
65
|
+
instance/
|
|
66
|
+
.webassets-cache
|
|
67
|
+
|
|
68
|
+
# Scrapy stuff:
|
|
69
|
+
.scrapy
|
|
70
|
+
|
|
71
|
+
# Sphinx documentation
|
|
72
|
+
docs/_build/
|
|
73
|
+
|
|
74
|
+
# PyBuilder
|
|
75
|
+
.pybuilder/
|
|
76
|
+
target/
|
|
77
|
+
|
|
78
|
+
# Jupyter Notebook
|
|
79
|
+
.ipynb_checkpoints
|
|
80
|
+
|
|
81
|
+
# IPython
|
|
82
|
+
profile_default/
|
|
83
|
+
ipython_config.py
|
|
84
|
+
|
|
85
|
+
# pyenv
|
|
86
|
+
# For a library or package, you might want to ignore these files since the code is
|
|
87
|
+
# intended to run in multiple environments; otherwise, check them in:
|
|
88
|
+
# .python-version
|
|
89
|
+
|
|
90
|
+
# pipenv
|
|
91
|
+
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
|
|
92
|
+
# However, in case of collaboration, if having platform-specific dependencies or dependencies
|
|
93
|
+
# having no cross-platform support, pipenv may install dependencies that don't work, or not
|
|
94
|
+
# install all needed dependencies.
|
|
95
|
+
#Pipfile.lock
|
|
96
|
+
|
|
97
|
+
# poetry
|
|
98
|
+
# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
|
|
99
|
+
# This is especially recommended for binary packages to ensure reproducibility, and is more
|
|
100
|
+
# commonly ignored for libraries.
|
|
101
|
+
# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
|
|
102
|
+
#poetry.lock
|
|
103
|
+
|
|
104
|
+
# pdm
|
|
105
|
+
# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
|
|
106
|
+
#pdm.lock
|
|
107
|
+
# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it
|
|
108
|
+
# in version control.
|
|
109
|
+
# https://pdm.fming.dev/latest/usage/project/#working-with-version-control
|
|
110
|
+
.pdm.toml
|
|
111
|
+
.pdm-python
|
|
112
|
+
.pdm-build/
|
|
113
|
+
|
|
114
|
+
# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
|
|
115
|
+
__pypackages__/
|
|
116
|
+
|
|
117
|
+
# Celery stuff
|
|
118
|
+
celerybeat-schedule
|
|
119
|
+
celerybeat.pid
|
|
120
|
+
|
|
121
|
+
# SageMath parsed files
|
|
122
|
+
*.sage.py
|
|
123
|
+
|
|
124
|
+
# Environments
|
|
125
|
+
.env
|
|
126
|
+
.venv
|
|
127
|
+
env/
|
|
128
|
+
venv/
|
|
129
|
+
ENV/
|
|
130
|
+
env.bak/
|
|
131
|
+
venv.bak/
|
|
132
|
+
|
|
133
|
+
# Spyder project settings
|
|
134
|
+
.spyderproject
|
|
135
|
+
.spyproject
|
|
136
|
+
|
|
137
|
+
# Rope project settings
|
|
138
|
+
.ropeproject
|
|
139
|
+
|
|
140
|
+
# mkdocs documentation
|
|
141
|
+
/site
|
|
142
|
+
|
|
143
|
+
# mypy
|
|
144
|
+
.mypy_cache/
|
|
145
|
+
.dmypy.json
|
|
146
|
+
dmypy.json
|
|
147
|
+
|
|
148
|
+
# Pyre type checker
|
|
149
|
+
.pyre/
|
|
150
|
+
|
|
151
|
+
# pytype static type analyzer
|
|
152
|
+
.pytype/
|
|
153
|
+
|
|
154
|
+
# Cython debug symbols
|
|
155
|
+
cython_debug/
|
|
156
|
+
|
|
157
|
+
# IDE
|
|
158
|
+
.idea
|
|
159
|
+
.vscode/!launch.json
|
|
160
|
+
.vscode/!tasks.json
|
|
161
|
+
|
|
162
|
+
# OS
|
|
163
|
+
.DS_Store
|
|
164
|
+
|
|
165
|
+
*_old/
|
|
File without changes
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: yaddd-sqlalchemy
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: SQLAlchemy integration for yaddd: SqlCrudRepository, SqlUnitOfWork, VOTypeDecorator
|
|
5
|
+
Project-URL: Homepage, https://github.com/netstuff/yaddd
|
|
6
|
+
Author-email: Yuri Andreev <andreev.jr@gmail.com>
|
|
7
|
+
License-Expression: MIT
|
|
8
|
+
License-File: LICENSE
|
|
9
|
+
Classifier: Development Status :: 4 - Beta
|
|
10
|
+
Classifier: Intended Audience :: Developers
|
|
11
|
+
Classifier: Operating System :: OS Independent
|
|
12
|
+
Classifier: Programming Language :: Python
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
16
|
+
Classifier: Topic :: Internet
|
|
17
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
18
|
+
Requires-Python: >=3.12
|
|
19
|
+
Requires-Dist: sqlalchemy[asyncio]>=2.0
|
|
20
|
+
Requires-Dist: yaddd>=0.1
|
|
21
|
+
Description-Content-Type: text/markdown
|
|
22
|
+
|
|
23
|
+
# yaddd-sqlalchemy
|
|
24
|
+
SQLAlchemy integration for [yaddd](https://github.com/pyDDD/yaddd).
|
|
25
|
+
|
|
26
|
+
- `SqlCrudRepository` — SQLAlchemy Core-based implementation of the `CrudRepository` port;
|
|
27
|
+
- `SqlUnitOfWork` — `UnitOfWork` over `AsyncSession`;
|
|
28
|
+
- `VOTypeDecorator` — `TypeDecorator` persisting `ValueObject` instances as raw values.
|
|
29
|
+
|
|
30
|
+
Wiring is explicit: instantiate in your composition root, no auto-discovery.
|
|
31
|
+
Port conformance is proven by the contract suites in `yaddd.testing`.
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
# yaddd-sqlalchemy
|
|
2
|
+
SQLAlchemy integration for [yaddd](https://github.com/pyDDD/yaddd).
|
|
3
|
+
|
|
4
|
+
- `SqlCrudRepository` — SQLAlchemy Core-based implementation of the `CrudRepository` port;
|
|
5
|
+
- `SqlUnitOfWork` — `UnitOfWork` over `AsyncSession`;
|
|
6
|
+
- `VOTypeDecorator` — `TypeDecorator` persisting `ValueObject` instances as raw values.
|
|
7
|
+
|
|
8
|
+
Wiring is explicit: instantiate in your composition root, no auto-discovery.
|
|
9
|
+
Port conformance is proven by the contract suites in `yaddd.testing`.
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "yaddd-sqlalchemy"
|
|
3
|
+
description = "SQLAlchemy integration for yaddd: SqlCrudRepository, SqlUnitOfWork, VOTypeDecorator"
|
|
4
|
+
authors = [
|
|
5
|
+
{name = "Yuri Andreev", email = "andreev.jr@gmail.com"},
|
|
6
|
+
]
|
|
7
|
+
license = 'MIT'
|
|
8
|
+
license-files = ['LICENSE']
|
|
9
|
+
classifiers = [
|
|
10
|
+
'Development Status :: 4 - Beta',
|
|
11
|
+
'Programming Language :: Python',
|
|
12
|
+
'Programming Language :: Python :: 3.12',
|
|
13
|
+
'Programming Language :: Python :: 3.13',
|
|
14
|
+
'Programming Language :: Python :: 3.14',
|
|
15
|
+
'Intended Audience :: Developers',
|
|
16
|
+
'Operating System :: OS Independent',
|
|
17
|
+
'Topic :: Software Development :: Libraries :: Python Modules',
|
|
18
|
+
'Topic :: Internet',
|
|
19
|
+
]
|
|
20
|
+
requires-python = '>=3.12'
|
|
21
|
+
dependencies = [
|
|
22
|
+
"yaddd>=0.1",
|
|
23
|
+
"sqlalchemy[asyncio]>=2.0",
|
|
24
|
+
]
|
|
25
|
+
version = "0.1.0"
|
|
26
|
+
readme = 'README.md'
|
|
27
|
+
|
|
28
|
+
[project.urls]
|
|
29
|
+
Homepage = 'https://github.com/netstuff/yaddd'
|
|
30
|
+
|
|
31
|
+
[tool.uv.sources]
|
|
32
|
+
yaddd = { workspace = true }
|
|
33
|
+
|
|
34
|
+
[build-system]
|
|
35
|
+
requires = ["hatchling"]
|
|
36
|
+
build-backend = "hatchling.build"
|
|
37
|
+
|
|
38
|
+
[tool.hatch.build.targets.wheel]
|
|
39
|
+
packages = ["src/yaddd_sqlalchemy"]
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
"""SQLAlchemy integration for yaddd."""
|
|
2
|
+
|
|
3
|
+
from yaddd_sqlalchemy.repositories import SqlCrudRepository
|
|
4
|
+
from yaddd_sqlalchemy.uow import SqlUnitOfWork
|
|
5
|
+
from yaddd_sqlalchemy.value_objects import VOTypeDecorator
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
__all__ = ["SqlCrudRepository", "SqlUnitOfWork", "VOTypeDecorator"]
|
|
File without changes
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
"""SQLAlchemy Core implementation of the ``CrudRepository`` port."""
|
|
2
|
+
|
|
3
|
+
from abc import ABC, abstractmethod
|
|
4
|
+
from typing import Any, ClassVar
|
|
5
|
+
|
|
6
|
+
from sqlalchemy import Column, Table, delete, insert, select, update
|
|
7
|
+
from sqlalchemy.engine import Row
|
|
8
|
+
from sqlalchemy.ext.asyncio import AsyncSession
|
|
9
|
+
from yaddd.domain.entities import AggregateRoot, PrimaryKey
|
|
10
|
+
from yaddd.domain.repositories import CrudRepository
|
|
11
|
+
from yaddd.exceptions import EntityNotFoundError
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
__all__ = ["SqlCrudRepository"]
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class SqlCrudRepository[T: AggregateRoot](CrudRepository[T], ABC):
|
|
18
|
+
"""SQLAlchemy Core-based repository for a single aggregate type.
|
|
19
|
+
|
|
20
|
+
No ORM declarative opinions: the subclass declares a Core ``table`` and
|
|
21
|
+
the mapping between rows and aggregates. The repository never commits —
|
|
22
|
+
transaction boundaries belong to ``SqlUnitOfWork``.
|
|
23
|
+
|
|
24
|
+
Primary keys must be single-column, and ``pk`` values must be natively
|
|
25
|
+
bindable by the primary-key column type (use a ``TypeDecorator`` column
|
|
26
|
+
or string/integer primary keys otherwise).
|
|
27
|
+
|
|
28
|
+
Example:
|
|
29
|
+
class OrderRepository(SqlCrudRepository[Order]):
|
|
30
|
+
table = orders_table
|
|
31
|
+
|
|
32
|
+
def to_domain(self, row: Row[Any]) -> Order:
|
|
33
|
+
return Order(id=row.id, status=row.status, total=row.total)
|
|
34
|
+
|
|
35
|
+
def to_row(self, instance: Order) -> dict[str, Any]:
|
|
36
|
+
return {"id": instance.pk, "status": instance.status, "total": instance.total}
|
|
37
|
+
"""
|
|
38
|
+
|
|
39
|
+
table: ClassVar[Table]
|
|
40
|
+
|
|
41
|
+
def __init__(self, session: AsyncSession) -> None:
|
|
42
|
+
self._session = session
|
|
43
|
+
|
|
44
|
+
@abstractmethod
|
|
45
|
+
def to_domain(self, row: Row[Any]) -> T:
|
|
46
|
+
"""Reconstitute the aggregate from a table row."""
|
|
47
|
+
|
|
48
|
+
@abstractmethod
|
|
49
|
+
def to_row(self, instance: T) -> dict[str, Any]:
|
|
50
|
+
"""Map the aggregate to a dict of column values."""
|
|
51
|
+
|
|
52
|
+
@property
|
|
53
|
+
def _pk_column(self) -> Column[Any]:
|
|
54
|
+
columns = list(self.table.primary_key.columns)
|
|
55
|
+
if len(columns) != 1:
|
|
56
|
+
raise NotImplementedError("SqlCrudRepository supports single-column primary keys only")
|
|
57
|
+
return columns[0]
|
|
58
|
+
|
|
59
|
+
async def get(self, pk: PrimaryKey) -> T | None:
|
|
60
|
+
result = await self._session.execute(select(self.table).where(self._pk_column == pk))
|
|
61
|
+
row = result.first()
|
|
62
|
+
return None if row is None else self.to_domain(row)
|
|
63
|
+
|
|
64
|
+
async def read(self, filter: dict[str, Any] | None = None, *, slice: tuple[int, int] = (0, 0)) -> list[T]:
|
|
65
|
+
stmt = select(self.table)
|
|
66
|
+
for key, value in (filter or {}).items():
|
|
67
|
+
stmt = stmt.where(self.table.c[key] == value)
|
|
68
|
+
offset, limit = slice
|
|
69
|
+
stmt = stmt.offset(offset)
|
|
70
|
+
if limit:
|
|
71
|
+
stmt = stmt.limit(limit)
|
|
72
|
+
result = await self._session.execute(stmt)
|
|
73
|
+
return [self.to_domain(row) for row in result.all()]
|
|
74
|
+
|
|
75
|
+
async def create(self, instance: T) -> T:
|
|
76
|
+
await self._session.execute(insert(self.table).values(**self.to_row(instance)))
|
|
77
|
+
return instance
|
|
78
|
+
|
|
79
|
+
async def update(self, instance: T) -> T:
|
|
80
|
+
if await self.get(instance.pk) is None:
|
|
81
|
+
raise EntityNotFoundError(f"{type(instance).__name__}({instance.pk})")
|
|
82
|
+
stmt = update(self.table).where(self._pk_column == instance.pk).values(**self.to_row(instance))
|
|
83
|
+
await self._session.execute(stmt)
|
|
84
|
+
return instance
|
|
85
|
+
|
|
86
|
+
async def delete(self, pk: PrimaryKey) -> None:
|
|
87
|
+
await self._session.execute(delete(self.table).where(self._pk_column == pk))
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
"""``UnitOfWork`` implementation over a SQLAlchemy ``AsyncSession``."""
|
|
2
|
+
|
|
3
|
+
from types import TracebackType
|
|
4
|
+
from typing import Self
|
|
5
|
+
|
|
6
|
+
from sqlalchemy.ext.asyncio import AsyncSession
|
|
7
|
+
from yaddd.application.uow import UnitOfWork
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
__all__ = ["SqlUnitOfWork"]
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class SqlUnitOfWork(UnitOfWork):
|
|
14
|
+
"""Transactional boundary over a single ``AsyncSession``.
|
|
15
|
+
|
|
16
|
+
``commit()`` flushes and commits the session. Leaving the context without
|
|
17
|
+
a commit — or with an exception — rolls back; the session is always
|
|
18
|
+
closed on exit.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
def __init__(self, session: AsyncSession) -> None:
|
|
22
|
+
self._session = session
|
|
23
|
+
self._committed = False
|
|
24
|
+
|
|
25
|
+
async def __aenter__(self) -> Self:
|
|
26
|
+
return self
|
|
27
|
+
|
|
28
|
+
async def __aexit__(
|
|
29
|
+
self,
|
|
30
|
+
exc_type: type[BaseException] | None,
|
|
31
|
+
exc_value: BaseException | None,
|
|
32
|
+
traceback: TracebackType | None,
|
|
33
|
+
) -> None:
|
|
34
|
+
try:
|
|
35
|
+
if exc_type is not None or not self._committed:
|
|
36
|
+
await self.rollback()
|
|
37
|
+
finally:
|
|
38
|
+
await self._session.close()
|
|
39
|
+
|
|
40
|
+
async def commit(self) -> None:
|
|
41
|
+
"""Persist all changes made within the unit of work."""
|
|
42
|
+
await self._session.commit()
|
|
43
|
+
self._committed = True
|
|
44
|
+
|
|
45
|
+
async def rollback(self) -> None:
|
|
46
|
+
"""Discard all changes made within the unit of work."""
|
|
47
|
+
await self._session.rollback()
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
"""SQLAlchemy type decorator persisting ``ValueObject`` instances."""
|
|
2
|
+
|
|
3
|
+
from typing import Any, ClassVar
|
|
4
|
+
|
|
5
|
+
from sqlalchemy.engine import Dialect
|
|
6
|
+
from sqlalchemy.types import TypeDecorator
|
|
7
|
+
from yaddd.domain.value_object import ValueObject
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
__all__ = ["VOTypeDecorator"]
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class VOTypeDecorator(TypeDecorator[ValueObject[Any]]):
|
|
14
|
+
"""Persist a ``ValueObject`` as its raw value and rebuild it on load.
|
|
15
|
+
|
|
16
|
+
Subclass, declare ``vo_class`` and pick the storage type via ``impl``::
|
|
17
|
+
|
|
18
|
+
class EmailType(VOTypeDecorator):
|
|
19
|
+
impl = String(255)
|
|
20
|
+
cache_ok = True
|
|
21
|
+
vo_class = Email
|
|
22
|
+
|
|
23
|
+
Binding unwraps ``value.value``; loading revalidates through
|
|
24
|
+
``vo_class(value)``. ``None`` passes through untouched in both directions.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
vo_class: ClassVar[type[ValueObject[Any]]]
|
|
28
|
+
|
|
29
|
+
def process_bind_param(self, value: ValueObject[Any] | None, dialect: Dialect) -> Any:
|
|
30
|
+
return None if value is None else value.value
|
|
31
|
+
|
|
32
|
+
def process_result_value(self, value: Any, dialect: Dialect) -> ValueObject[Any] | None:
|
|
33
|
+
return None if value is None else self.vo_class(value)
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
from collections.abc import AsyncIterator
|
|
2
|
+
from typing import Any
|
|
3
|
+
from uuid import uuid4
|
|
4
|
+
|
|
5
|
+
import pytest
|
|
6
|
+
from sqlalchemy import Column, Integer, MetaData, String, Table
|
|
7
|
+
from sqlalchemy.engine import Row
|
|
8
|
+
from sqlalchemy.ext.asyncio import AsyncEngine, AsyncSession, async_sessionmaker, create_async_engine
|
|
9
|
+
|
|
10
|
+
from yaddd.domain import AggregateRoot
|
|
11
|
+
from yaddd_sqlalchemy import SqlCrudRepository
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
metadata = MetaData()
|
|
15
|
+
|
|
16
|
+
orders_table = Table(
|
|
17
|
+
"orders",
|
|
18
|
+
metadata,
|
|
19
|
+
Column("id", String(32), primary_key=True),
|
|
20
|
+
Column("status", String(16), nullable=False),
|
|
21
|
+
Column("total", Integer, nullable=False),
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class Order(AggregateRoot):
|
|
26
|
+
id: str
|
|
27
|
+
status: str
|
|
28
|
+
total: int
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class OrderRepository(SqlCrudRepository[Order]):
|
|
32
|
+
table = orders_table
|
|
33
|
+
|
|
34
|
+
def to_domain(self, row: Row[Any]) -> Order:
|
|
35
|
+
return Order(id=str(row.id), status=str(row.status), total=int(row.total))
|
|
36
|
+
|
|
37
|
+
def to_row(self, instance: Order) -> dict[str, Any]:
|
|
38
|
+
return {"id": instance.pk, "status": instance.status, "total": instance.total}
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@pytest.fixture
|
|
42
|
+
async def engine() -> AsyncIterator[AsyncEngine]:
|
|
43
|
+
engine = create_async_engine("sqlite+aiosqlite://")
|
|
44
|
+
async with engine.begin() as connection:
|
|
45
|
+
await connection.run_sync(metadata.create_all)
|
|
46
|
+
yield engine
|
|
47
|
+
await engine.dispose()
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@pytest.fixture
|
|
51
|
+
async def session(engine: AsyncEngine) -> AsyncIterator[AsyncSession]:
|
|
52
|
+
session_factory = async_sessionmaker(engine, expire_on_commit=False)
|
|
53
|
+
async with session_factory() as session:
|
|
54
|
+
yield session
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
from collections.abc import Callable
|
|
2
|
+
from typing import Any
|
|
3
|
+
from uuid import uuid4
|
|
4
|
+
|
|
5
|
+
import pytest
|
|
6
|
+
from sqlalchemy.ext.asyncio import AsyncSession
|
|
7
|
+
|
|
8
|
+
from yaddd.domain import AggregateRoot, CrudRepository
|
|
9
|
+
from yaddd.testing import CrudRepositoryContract
|
|
10
|
+
|
|
11
|
+
from conftest import Order, OrderRepository
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class TestSqlCrudRepository(CrudRepositoryContract):
|
|
15
|
+
"""The contract suite proves port conformance against in-memory sqlite."""
|
|
16
|
+
|
|
17
|
+
@pytest.fixture
|
|
18
|
+
def repo(self, session: AsyncSession) -> CrudRepository[Order]:
|
|
19
|
+
return OrderRepository(session)
|
|
20
|
+
|
|
21
|
+
@pytest.fixture
|
|
22
|
+
def aggregate_factory(self) -> Callable[[], Order]:
|
|
23
|
+
def make() -> Order:
|
|
24
|
+
token = uuid4().hex
|
|
25
|
+
return Order(id=token, status=f"status-{token[:8]}", total=int(token[:6], 16) % 1000)
|
|
26
|
+
|
|
27
|
+
return make
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
async def test_get_missing_returns_none_for_unknown_pk(session: AsyncSession):
|
|
31
|
+
repo: CrudRepository[Any] = OrderRepository(session)
|
|
32
|
+
|
|
33
|
+
assert await repo.get("no-such-id") is None
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
from collections.abc import Awaitable, Callable
|
|
2
|
+
|
|
3
|
+
import pytest
|
|
4
|
+
from sqlalchemy import select
|
|
5
|
+
from sqlalchemy.ext.asyncio import AsyncEngine, AsyncSession
|
|
6
|
+
|
|
7
|
+
from yaddd.application.uow import UnitOfWork
|
|
8
|
+
from yaddd.testing import UnitOfWorkContract
|
|
9
|
+
from yaddd_sqlalchemy import SqlUnitOfWork
|
|
10
|
+
|
|
11
|
+
from conftest import Order, OrderRepository, orders_table
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class TestSqlUnitOfWork(UnitOfWorkContract):
|
|
15
|
+
@pytest.fixture
|
|
16
|
+
def uow(self, session: AsyncSession) -> UnitOfWork:
|
|
17
|
+
return SqlUnitOfWork(session)
|
|
18
|
+
|
|
19
|
+
@pytest.fixture
|
|
20
|
+
def assert_rolled_back(self, session: AsyncSession) -> Callable[[], Awaitable[None]]:
|
|
21
|
+
async def check() -> None:
|
|
22
|
+
assert not session.in_transaction()
|
|
23
|
+
assert not session.new and not session.dirty
|
|
24
|
+
|
|
25
|
+
return check
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
async def test_commit_persists_changes(engine: AsyncEngine, session: AsyncSession):
|
|
29
|
+
async with SqlUnitOfWork(session) as uow:
|
|
30
|
+
await OrderRepository(session).create(Order(id="order-1", status="new", total=10))
|
|
31
|
+
await uow.commit()
|
|
32
|
+
|
|
33
|
+
async with AsyncSession(engine) as verify:
|
|
34
|
+
result = await verify.execute(select(orders_table).where(orders_table.c.id == "order-1"))
|
|
35
|
+
assert result.first() is not None
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
async def test_exception_inside_context_rolls_back(engine: AsyncEngine, session: AsyncSession):
|
|
39
|
+
with pytest.raises(RuntimeError, match="boom"):
|
|
40
|
+
async with SqlUnitOfWork(session):
|
|
41
|
+
await OrderRepository(session).create(Order(id="order-2", status="new", total=10))
|
|
42
|
+
raise RuntimeError("boom")
|
|
43
|
+
|
|
44
|
+
async with AsyncSession(engine) as verify:
|
|
45
|
+
result = await verify.execute(select(orders_table).where(orders_table.c.id == "order-2"))
|
|
46
|
+
assert result.first() is None
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
async def test_exit_without_commit_rolls_back(engine: AsyncEngine, session: AsyncSession):
|
|
50
|
+
async with SqlUnitOfWork(session):
|
|
51
|
+
await OrderRepository(session).create(Order(id="order-3", status="new", total=10))
|
|
52
|
+
|
|
53
|
+
async with AsyncSession(engine) as verify:
|
|
54
|
+
result = await verify.execute(select(orders_table).where(orders_table.c.id == "order-3"))
|
|
55
|
+
assert result.first() is None
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
async def test_rollback_discards_pending_changes(engine: AsyncEngine, session: AsyncSession):
|
|
59
|
+
async with SqlUnitOfWork(session) as uow:
|
|
60
|
+
await OrderRepository(session).create(Order(id="order-4", status="new", total=10))
|
|
61
|
+
await uow.rollback()
|
|
62
|
+
|
|
63
|
+
async with AsyncSession(engine) as verify:
|
|
64
|
+
result = await verify.execute(select(orders_table).where(orders_table.c.id == "order-4"))
|
|
65
|
+
assert result.first() is None
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import pytest
|
|
2
|
+
from sqlalchemy import Column, MetaData, String, Table, insert, select
|
|
3
|
+
from sqlalchemy.ext.asyncio import AsyncEngine, AsyncSession, async_sessionmaker, create_async_engine
|
|
4
|
+
|
|
5
|
+
from yaddd.domain import ValueObject
|
|
6
|
+
from yaddd.exceptions import ValidationError
|
|
7
|
+
from yaddd_sqlalchemy import VOTypeDecorator
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class Email(ValueObject[str]):
|
|
11
|
+
@classmethod
|
|
12
|
+
def validate(cls, value: str) -> str:
|
|
13
|
+
if "@" not in value:
|
|
14
|
+
raise ValidationError(f"not an email: {value}")
|
|
15
|
+
return value.lower()
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class EmailType(VOTypeDecorator):
|
|
19
|
+
impl = String(255)
|
|
20
|
+
cache_ok = True
|
|
21
|
+
vo_class = Email
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
contacts_metadata = MetaData()
|
|
25
|
+
|
|
26
|
+
contacts_table = Table(
|
|
27
|
+
"contacts",
|
|
28
|
+
contacts_metadata,
|
|
29
|
+
Column("id", String(32), primary_key=True),
|
|
30
|
+
Column("email", EmailType, nullable=True),
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@pytest.fixture
|
|
35
|
+
async def session() -> AsyncSession:
|
|
36
|
+
engine = create_async_engine("sqlite+aiosqlite://")
|
|
37
|
+
async with engine.begin() as connection:
|
|
38
|
+
await connection.run_sync(contacts_metadata.create_all)
|
|
39
|
+
session_factory = async_sessionmaker(engine, expire_on_commit=False)
|
|
40
|
+
async with session_factory() as session:
|
|
41
|
+
yield session
|
|
42
|
+
await engine.dispose()
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
async def test_value_object_round_trip(session: AsyncSession):
|
|
46
|
+
await session.execute(insert(contacts_table).values(id="c-1", email=Email("User@Example.com")))
|
|
47
|
+
await session.commit()
|
|
48
|
+
|
|
49
|
+
result = await session.execute(select(contacts_table.c.email).where(contacts_table.c.id == "c-1"))
|
|
50
|
+
loaded = result.scalar_one()
|
|
51
|
+
|
|
52
|
+
assert isinstance(loaded, Email)
|
|
53
|
+
assert loaded == Email("user@example.com")
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
async def test_none_round_trip(session: AsyncSession):
|
|
57
|
+
await session.execute(insert(contacts_table).values(id="c-2", email=None))
|
|
58
|
+
await session.commit()
|
|
59
|
+
|
|
60
|
+
result = await session.execute(select(contacts_table.c.email).where(contacts_table.c.id == "c-2"))
|
|
61
|
+
assert result.scalar_one() is None
|