yaddd-sqlalchemy 0.1.0__tar.gz → 0.2.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 → yaddd_sqlalchemy-0.2.0}/.gitignore +6 -2
- {yaddd_sqlalchemy-0.1.0 → yaddd_sqlalchemy-0.2.0}/PKG-INFO +2 -2
- {yaddd_sqlalchemy-0.1.0 → yaddd_sqlalchemy-0.2.0}/pyproject.toml +2 -2
- yaddd_sqlalchemy-0.2.0/src/yaddd_sqlalchemy/__init__.py +16 -0
- yaddd_sqlalchemy-0.2.0/src/yaddd_sqlalchemy/agents/manifest.toml +20 -0
- yaddd_sqlalchemy-0.2.0/src/yaddd_sqlalchemy/agents/rules/infrastructure.md +12 -0
- {yaddd_sqlalchemy-0.1.0 → yaddd_sqlalchemy-0.2.0}/src/yaddd_sqlalchemy/repositories.py +49 -6
- yaddd_sqlalchemy-0.2.0/src/yaddd_sqlalchemy/session.py +54 -0
- yaddd_sqlalchemy-0.2.0/src/yaddd_sqlalchemy/uow.py +56 -0
- {yaddd_sqlalchemy-0.1.0 → yaddd_sqlalchemy-0.2.0}/src/yaddd_sqlalchemy/value_objects.py +1 -0
- yaddd_sqlalchemy-0.2.0/tests/test_session.py +32 -0
- yaddd_sqlalchemy-0.2.0/tests/test_transaction_bound_repository.py +84 -0
- yaddd_sqlalchemy-0.2.0/tests/test_uow.py +139 -0
- yaddd_sqlalchemy-0.1.0/src/yaddd_sqlalchemy/__init__.py +0 -8
- yaddd_sqlalchemy-0.1.0/src/yaddd_sqlalchemy/uow.py +0 -47
- yaddd_sqlalchemy-0.1.0/tests/test_uow.py +0 -65
- {yaddd_sqlalchemy-0.1.0 → yaddd_sqlalchemy-0.2.0}/LICENSE +0 -0
- {yaddd_sqlalchemy-0.1.0 → yaddd_sqlalchemy-0.2.0}/README.md +0 -0
- {yaddd_sqlalchemy-0.1.0 → yaddd_sqlalchemy-0.2.0}/src/yaddd_sqlalchemy/py.typed +0 -0
- {yaddd_sqlalchemy-0.1.0 → yaddd_sqlalchemy-0.2.0}/tests/conftest.py +0 -0
- {yaddd_sqlalchemy-0.1.0 → yaddd_sqlalchemy-0.2.0}/tests/test_repository.py +0 -0
- {yaddd_sqlalchemy-0.1.0 → yaddd_sqlalchemy-0.2.0}/tests/test_value_objects.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: yaddd-sqlalchemy
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.2.0
|
|
4
4
|
Summary: SQLAlchemy integration for yaddd: SqlCrudRepository, SqlUnitOfWork, VOTypeDecorator
|
|
5
5
|
Project-URL: Homepage, https://github.com/netstuff/yaddd
|
|
6
6
|
Author-email: Yuri Andreev <andreev.jr@gmail.com>
|
|
@@ -17,7 +17,7 @@ Classifier: Topic :: Internet
|
|
|
17
17
|
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
18
18
|
Requires-Python: >=3.12
|
|
19
19
|
Requires-Dist: sqlalchemy[asyncio]>=2.0
|
|
20
|
-
Requires-Dist: yaddd>=0.
|
|
20
|
+
Requires-Dist: yaddd>=0.5
|
|
21
21
|
Description-Content-Type: text/markdown
|
|
22
22
|
|
|
23
23
|
# yaddd-sqlalchemy
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
"""SQLAlchemy integration for yaddd."""
|
|
2
|
+
|
|
3
|
+
from yaddd_sqlalchemy.repositories import SqlCrudRepository, SqlTransactionBoundRepository
|
|
4
|
+
from yaddd_sqlalchemy.session import SqlSession, SqlTransaction
|
|
5
|
+
from yaddd_sqlalchemy.uow import SqlUnitOfWork
|
|
6
|
+
from yaddd_sqlalchemy.value_objects import VOTypeDecorator
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
__all__ = [
|
|
10
|
+
"SqlCrudRepository",
|
|
11
|
+
"SqlSession",
|
|
12
|
+
"SqlTransaction",
|
|
13
|
+
"SqlTransactionBoundRepository",
|
|
14
|
+
"SqlUnitOfWork",
|
|
15
|
+
"VOTypeDecorator",
|
|
16
|
+
]
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
schema = 1
|
|
2
|
+
name = "yaddd-sqlalchemy"
|
|
3
|
+
version = "0.2.0"
|
|
4
|
+
|
|
5
|
+
[[relaxes]]
|
|
6
|
+
rule = "IMPORTS_INFRASTRUCTURE"
|
|
7
|
+
allow_add = ["sqlalchemy", "yaddd_sqlalchemy"]
|
|
8
|
+
reason = "SqlCrudRepository, SqlUnitOfWork and VOTypeDecorator implement persistence ports."
|
|
9
|
+
when = { dependency = "yaddd-sqlalchemy", specifier = ">=0.1" }
|
|
10
|
+
|
|
11
|
+
[[restricts]]
|
|
12
|
+
rule = "IMPORTS_APPLICATION"
|
|
13
|
+
deny_add = ["sqlalchemy", "yaddd_sqlalchemy"]
|
|
14
|
+
reason = "Application stays persistence-agnostic; only infrastructure may know SQLAlchemy."
|
|
15
|
+
when = { dependency = "yaddd-sqlalchemy" }
|
|
16
|
+
|
|
17
|
+
[rules.SQLALCHEMY_PERSISTENCE]
|
|
18
|
+
layer = "infrastructure"
|
|
19
|
+
kind = "guidance"
|
|
20
|
+
doc = "rules/infrastructure.md"
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
# SQLAlchemy persistence
|
|
2
|
+
|
|
3
|
+
Additive to the base infrastructure rules. This pack relaxes exactly one import
|
|
4
|
+
rule: infrastructure may use `sqlalchemy` and `yaddd_sqlalchemy`.
|
|
5
|
+
|
|
6
|
+
- **Use `SqlCrudRepository`** for Core-based repositories and
|
|
7
|
+
`SqlUnitOfWork` for transaction boundaries. Do not commit inside a
|
|
8
|
+
repository.
|
|
9
|
+
- **Use `VOTypeDecorator`** for columns that hold value objects, so values are
|
|
10
|
+
validated on the way in and on the way out.
|
|
11
|
+
- **Keep SQLAlchemy out of `application`.** The pack also restricts that layer:
|
|
12
|
+
use cases depend on ports, not sessions or tables.
|
|
@@ -5,13 +5,16 @@ from typing import Any, ClassVar
|
|
|
5
5
|
|
|
6
6
|
from sqlalchemy import Column, Table, delete, insert, select, update
|
|
7
7
|
from sqlalchemy.engine import Row
|
|
8
|
+
from sqlalchemy.exc import IntegrityError
|
|
8
9
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
10
|
+
|
|
9
11
|
from yaddd.domain.entities import AggregateRoot, PrimaryKey
|
|
10
12
|
from yaddd.domain.repositories import CrudRepository
|
|
11
|
-
from yaddd.exceptions import EntityNotFoundError
|
|
13
|
+
from yaddd.exceptions import ConflictError, EntityNotFoundError
|
|
14
|
+
from yaddd_sqlalchemy.session import SqlTransaction
|
|
12
15
|
|
|
13
16
|
|
|
14
|
-
__all__ = ["SqlCrudRepository"]
|
|
17
|
+
__all__ = ["SqlCrudRepository", "SqlTransactionBoundRepository"]
|
|
15
18
|
|
|
16
19
|
|
|
17
20
|
class SqlCrudRepository[T: AggregateRoot](CrudRepository[T], ABC):
|
|
@@ -39,7 +42,11 @@ class SqlCrudRepository[T: AggregateRoot](CrudRepository[T], ABC):
|
|
|
39
42
|
table: ClassVar[Table]
|
|
40
43
|
|
|
41
44
|
def __init__(self, session: AsyncSession) -> None:
|
|
42
|
-
self.
|
|
45
|
+
self._async_session = session
|
|
46
|
+
|
|
47
|
+
@property
|
|
48
|
+
def _session(self) -> AsyncSession:
|
|
49
|
+
return self._async_session
|
|
43
50
|
|
|
44
51
|
@abstractmethod
|
|
45
52
|
def to_domain(self, row: Row[Any]) -> T:
|
|
@@ -73,15 +80,51 @@ class SqlCrudRepository[T: AggregateRoot](CrudRepository[T], ABC):
|
|
|
73
80
|
return [self.to_domain(row) for row in result.all()]
|
|
74
81
|
|
|
75
82
|
async def create(self, instance: T) -> T:
|
|
76
|
-
|
|
83
|
+
try:
|
|
84
|
+
await self._session.execute(insert(self.table).values(**self.to_row(instance)))
|
|
85
|
+
except IntegrityError as exc:
|
|
86
|
+
raise ConflictError(str(exc)) from exc
|
|
77
87
|
return instance
|
|
78
88
|
|
|
79
89
|
async def update(self, instance: T) -> T:
|
|
80
90
|
if await self.get(instance.pk) is None:
|
|
81
91
|
raise EntityNotFoundError(f"{type(instance).__name__}({instance.pk})")
|
|
82
92
|
stmt = update(self.table).where(self._pk_column == instance.pk).values(**self.to_row(instance))
|
|
83
|
-
|
|
93
|
+
try:
|
|
94
|
+
await self._session.execute(stmt)
|
|
95
|
+
except IntegrityError as exc:
|
|
96
|
+
raise ConflictError(str(exc)) from exc
|
|
84
97
|
return instance
|
|
85
98
|
|
|
86
99
|
async def delete(self, pk: PrimaryKey) -> None:
|
|
87
|
-
|
|
100
|
+
try:
|
|
101
|
+
await self._session.execute(delete(self.table).where(self._pk_column == pk))
|
|
102
|
+
except IntegrityError as exc:
|
|
103
|
+
raise ConflictError(str(exc)) from exc
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
class SqlTransactionBoundRepository[T: AggregateRoot](SqlCrudRepository[T], ABC):
|
|
107
|
+
"""SQLAlchemy repository bound to a ``SqlTransaction``.
|
|
108
|
+
|
|
109
|
+
The SQLAlchemy session is resolved lazily from the active transaction, so
|
|
110
|
+
the repository can be constructed before the transaction context is
|
|
111
|
+
entered. Created and updated aggregates are automatically tracked for
|
|
112
|
+
event publication on commit.
|
|
113
|
+
"""
|
|
114
|
+
|
|
115
|
+
def __init__(self, transaction: SqlTransaction) -> None:
|
|
116
|
+
self._transaction = transaction
|
|
117
|
+
|
|
118
|
+
@property
|
|
119
|
+
def _session(self) -> AsyncSession:
|
|
120
|
+
return self._transaction.session.session
|
|
121
|
+
|
|
122
|
+
async def create(self, instance: T) -> T:
|
|
123
|
+
result = await super().create(instance)
|
|
124
|
+
self._transaction.track(instance)
|
|
125
|
+
return result
|
|
126
|
+
|
|
127
|
+
async def update(self, instance: T) -> T:
|
|
128
|
+
result = await super().update(instance)
|
|
129
|
+
self._transaction.track(instance)
|
|
130
|
+
return result
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
"""SQLAlchemy implementation of the application ``Session`` protocol."""
|
|
2
|
+
|
|
3
|
+
from sqlalchemy.exc import IntegrityError
|
|
4
|
+
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
|
|
5
|
+
|
|
6
|
+
from yaddd.application.session import Session
|
|
7
|
+
from yaddd.exceptions import ConflictError
|
|
8
|
+
from yaddd.infrastructure.transaction import BaseTransaction
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
__all__ = ["SqlSession", "SqlTransaction"]
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class SqlSession(Session):
|
|
15
|
+
"""Transactional session over a SQLAlchemy ``AsyncSession``."""
|
|
16
|
+
|
|
17
|
+
def __init__(self, session_maker: async_sessionmaker[AsyncSession]) -> None:
|
|
18
|
+
self._session_maker = session_maker
|
|
19
|
+
self._session: AsyncSession | None = None
|
|
20
|
+
|
|
21
|
+
@property
|
|
22
|
+
def session(self) -> AsyncSession:
|
|
23
|
+
"""The currently active SQLAlchemy session."""
|
|
24
|
+
if self._session is None:
|
|
25
|
+
raise RuntimeError("SQLAlchemy session is not active")
|
|
26
|
+
return self._session
|
|
27
|
+
|
|
28
|
+
async def begin(self) -> None:
|
|
29
|
+
"""Open a new SQLAlchemy session."""
|
|
30
|
+
self._session = self._session_maker()
|
|
31
|
+
await self._session.__aenter__()
|
|
32
|
+
|
|
33
|
+
async def commit(self) -> None:
|
|
34
|
+
"""Commit the current session, translating integrity failures."""
|
|
35
|
+
if self._session is not None:
|
|
36
|
+
try:
|
|
37
|
+
await self._session.commit()
|
|
38
|
+
except IntegrityError as exc:
|
|
39
|
+
raise ConflictError(str(exc)) from exc
|
|
40
|
+
|
|
41
|
+
async def rollback(self) -> None:
|
|
42
|
+
"""Roll back the current session."""
|
|
43
|
+
if self._session is not None:
|
|
44
|
+
await self._session.rollback()
|
|
45
|
+
|
|
46
|
+
async def close(self) -> None:
|
|
47
|
+
"""Close the current session."""
|
|
48
|
+
if self._session is not None:
|
|
49
|
+
await self._session.__aexit__(None, None, None)
|
|
50
|
+
self._session = None
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class SqlTransaction(BaseTransaction[SqlSession]):
|
|
54
|
+
"""Transaction bound to a single SQLAlchemy session."""
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
"""``UnitOfWork`` implementation over a SQLAlchemy transaction."""
|
|
2
|
+
|
|
3
|
+
from types import TracebackType
|
|
4
|
+
from typing import Self, TypeVar
|
|
5
|
+
|
|
6
|
+
from yaddd.application.uow import UnitOfWork
|
|
7
|
+
from yaddd.domain.entities import AggregateRoot
|
|
8
|
+
from yaddd_sqlalchemy.session import SqlTransaction
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
R = TypeVar("R")
|
|
12
|
+
|
|
13
|
+
__all__ = ["SqlUnitOfWork"]
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class SqlUnitOfWork[R](UnitOfWork[R]):
|
|
17
|
+
"""Unit of work over a single SQLAlchemy transaction.
|
|
18
|
+
|
|
19
|
+
Repositories in ``repos`` share the underlying ``SqlTransaction``.
|
|
20
|
+
``commit()`` persists the work and publishes domain events from tracked
|
|
21
|
+
aggregates. Exiting the context without ``commit()`` — or with an
|
|
22
|
+
exception — rolls back.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
def __init__(self, transaction: SqlTransaction, repos: R) -> None:
|
|
26
|
+
self._transaction = transaction
|
|
27
|
+
self._repos = repos
|
|
28
|
+
|
|
29
|
+
@property
|
|
30
|
+
def repos(self) -> R:
|
|
31
|
+
"""Repository bundle used within this unit of work."""
|
|
32
|
+
return self._repos
|
|
33
|
+
|
|
34
|
+
def track(self, aggregate: AggregateRoot) -> None:
|
|
35
|
+
"""Register an aggregate whose events should be published on commit."""
|
|
36
|
+
self._transaction.track(aggregate)
|
|
37
|
+
|
|
38
|
+
async def __aenter__(self) -> Self:
|
|
39
|
+
await self._transaction.__aenter__()
|
|
40
|
+
return self
|
|
41
|
+
|
|
42
|
+
async def __aexit__(
|
|
43
|
+
self,
|
|
44
|
+
exc_type: type[BaseException] | None,
|
|
45
|
+
exc_value: BaseException | None,
|
|
46
|
+
traceback: TracebackType | None,
|
|
47
|
+
) -> None:
|
|
48
|
+
await self._transaction.__aexit__(exc_type, exc_value, traceback)
|
|
49
|
+
|
|
50
|
+
async def commit(self) -> None:
|
|
51
|
+
"""Persist all tracked changes and publish domain events."""
|
|
52
|
+
await self._transaction.commit()
|
|
53
|
+
|
|
54
|
+
async def rollback(self) -> None:
|
|
55
|
+
"""Discard all tracked changes."""
|
|
56
|
+
await self._transaction.rollback()
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import pytest
|
|
2
|
+
from sqlalchemy.ext.asyncio import async_sessionmaker
|
|
3
|
+
|
|
4
|
+
from yaddd_sqlalchemy import SqlSession
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
async def test_sql_session_lifecycle(engine):
|
|
8
|
+
session_maker = async_sessionmaker(engine, expire_on_commit=False)
|
|
9
|
+
sql_session = SqlSession(session_maker)
|
|
10
|
+
|
|
11
|
+
with pytest.raises(RuntimeError):
|
|
12
|
+
_ = sql_session.session
|
|
13
|
+
|
|
14
|
+
await sql_session.begin()
|
|
15
|
+
assert sql_session.session is not None
|
|
16
|
+
|
|
17
|
+
await sql_session.commit()
|
|
18
|
+
await sql_session.close()
|
|
19
|
+
|
|
20
|
+
with pytest.raises(RuntimeError):
|
|
21
|
+
_ = sql_session.session
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
async def test_sql_session_rollback(engine):
|
|
25
|
+
session_maker = async_sessionmaker(engine, expire_on_commit=False)
|
|
26
|
+
sql_session = SqlSession(session_maker)
|
|
27
|
+
|
|
28
|
+
await sql_session.begin()
|
|
29
|
+
await sql_session.rollback()
|
|
30
|
+
await sql_session.close()
|
|
31
|
+
|
|
32
|
+
assert sql_session._session is None
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
from uuid import uuid4
|
|
2
|
+
|
|
3
|
+
import pytest
|
|
4
|
+
from sqlalchemy import select
|
|
5
|
+
from sqlalchemy.ext.asyncio import async_sessionmaker
|
|
6
|
+
|
|
7
|
+
from yaddd import DomainEvent, InMemoryEventPublisher
|
|
8
|
+
from yaddd_sqlalchemy import SqlSession, SqlTransaction, SqlTransactionBoundRepository
|
|
9
|
+
|
|
10
|
+
from conftest import Order, orders_table
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class OrderPlaced(DomainEvent):
|
|
14
|
+
order_id: str
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class TxOrderRepository(SqlTransactionBoundRepository[Order]):
|
|
18
|
+
table = orders_table
|
|
19
|
+
|
|
20
|
+
def to_domain(self, row) -> Order:
|
|
21
|
+
return Order(id=str(row.id), status=str(row.status), total=int(row.total))
|
|
22
|
+
|
|
23
|
+
def to_row(self, instance: Order) -> dict[str, object]:
|
|
24
|
+
return {"id": instance.pk, "status": instance.status, "total": instance.total}
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@pytest.fixture
|
|
28
|
+
def session_maker(engine):
|
|
29
|
+
return async_sessionmaker(engine, expire_on_commit=False)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
async def test_create_tracks_aggregate_and_publishes_event(session_maker):
|
|
33
|
+
publisher = InMemoryEventPublisher()
|
|
34
|
+
events: list[DomainEvent] = []
|
|
35
|
+
|
|
36
|
+
async def collect(event: DomainEvent) -> None:
|
|
37
|
+
events.append(event)
|
|
38
|
+
|
|
39
|
+
publisher.subscribe(OrderPlaced, collect)
|
|
40
|
+
|
|
41
|
+
sql_session = SqlSession(session_maker)
|
|
42
|
+
tx = SqlTransaction(sql_session, publisher)
|
|
43
|
+
repo = TxOrderRepository(tx)
|
|
44
|
+
|
|
45
|
+
async with tx:
|
|
46
|
+
order = Order(id=uuid4().hex, status="new", total=42)
|
|
47
|
+
order.add_event(OrderPlaced(order_id=order.pk))
|
|
48
|
+
await repo.create(order)
|
|
49
|
+
await tx.commit()
|
|
50
|
+
|
|
51
|
+
assert len(events) == 1
|
|
52
|
+
assert isinstance(events[0], OrderPlaced)
|
|
53
|
+
assert events[0].order_id == order.pk
|
|
54
|
+
|
|
55
|
+
async with session_maker() as session:
|
|
56
|
+
row = (await session.execute(select(orders_table).where(orders_table.c.id == order.pk))).one()
|
|
57
|
+
assert row.total == 42
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
async def test_rollback_does_not_publish_event(session_maker):
|
|
61
|
+
publisher = InMemoryEventPublisher()
|
|
62
|
+
events: list[DomainEvent] = []
|
|
63
|
+
|
|
64
|
+
async def collect(event: DomainEvent) -> None:
|
|
65
|
+
events.append(event)
|
|
66
|
+
|
|
67
|
+
publisher.subscribe(OrderPlaced, collect)
|
|
68
|
+
|
|
69
|
+
sql_session = SqlSession(session_maker)
|
|
70
|
+
tx = SqlTransaction(sql_session, publisher)
|
|
71
|
+
repo = TxOrderRepository(tx)
|
|
72
|
+
|
|
73
|
+
with pytest.raises(RuntimeError):
|
|
74
|
+
async with tx:
|
|
75
|
+
order = Order(id=uuid4().hex, status="new", total=10)
|
|
76
|
+
order.add_event(OrderPlaced(order_id=order.pk))
|
|
77
|
+
await repo.create(order)
|
|
78
|
+
raise RuntimeError("boom")
|
|
79
|
+
|
|
80
|
+
assert len(events) == 0
|
|
81
|
+
|
|
82
|
+
async with session_maker() as session:
|
|
83
|
+
row = (await session.execute(select(orders_table).where(orders_table.c.id == order.pk))).one_or_none()
|
|
84
|
+
assert row is None
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
from dataclasses import dataclass
|
|
2
|
+
from uuid import uuid4
|
|
3
|
+
|
|
4
|
+
import pytest
|
|
5
|
+
from sqlalchemy import select
|
|
6
|
+
from sqlalchemy.ext.asyncio import AsyncEngine, async_sessionmaker
|
|
7
|
+
|
|
8
|
+
from yaddd import ConflictError, DomainEvent, InMemoryEventPublisher
|
|
9
|
+
from yaddd_sqlalchemy import SqlSession, SqlTransaction, SqlTransactionBoundRepository, SqlUnitOfWork
|
|
10
|
+
|
|
11
|
+
from conftest import Order, orders_table
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class TxOrderRepository(SqlTransactionBoundRepository[Order]):
|
|
15
|
+
table = orders_table
|
|
16
|
+
|
|
17
|
+
def to_domain(self, row) -> Order:
|
|
18
|
+
return Order(id=str(row.id), status=str(row.status), total=int(row.total))
|
|
19
|
+
|
|
20
|
+
def to_row(self, instance: Order) -> dict[str, object]:
|
|
21
|
+
return {"id": instance.pk, "status": instance.status, "total": instance.total}
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass
|
|
25
|
+
class OrderRepos:
|
|
26
|
+
orders: TxOrderRepository
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class OrderPlaced(DomainEvent):
|
|
30
|
+
order_id: str
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def build_uow(engine: AsyncEngine) -> SqlUnitOfWork[OrderRepos]:
|
|
34
|
+
session_maker = async_sessionmaker(engine, expire_on_commit=False)
|
|
35
|
+
sql_session = SqlSession(session_maker)
|
|
36
|
+
publisher = InMemoryEventPublisher()
|
|
37
|
+
tx = SqlTransaction(sql_session, publisher)
|
|
38
|
+
repos = OrderRepos(orders=TxOrderRepository(tx))
|
|
39
|
+
return SqlUnitOfWork(tx, repos)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
async def test_uow_exposes_repositories(engine: AsyncEngine):
|
|
43
|
+
uow = build_uow(engine)
|
|
44
|
+
async with uow:
|
|
45
|
+
assert isinstance(uow.repos.orders, TxOrderRepository)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
async def test_commit_persists_changes(engine: AsyncEngine):
|
|
49
|
+
uow = build_uow(engine)
|
|
50
|
+
order_id = uuid4().hex
|
|
51
|
+
|
|
52
|
+
async with uow:
|
|
53
|
+
order = Order(id=order_id, status="new", total=10)
|
|
54
|
+
await uow.repos.orders.create(order)
|
|
55
|
+
await uow.commit()
|
|
56
|
+
|
|
57
|
+
async with async_sessionmaker(engine, expire_on_commit=False)() as verify:
|
|
58
|
+
result = await verify.execute(select(orders_table).where(orders_table.c.id == order_id))
|
|
59
|
+
assert result.first() is not None
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
async def test_exit_without_commit_rolls_back(engine: AsyncEngine):
|
|
63
|
+
uow = build_uow(engine)
|
|
64
|
+
order_id = uuid4().hex
|
|
65
|
+
|
|
66
|
+
async with uow:
|
|
67
|
+
order = Order(id=order_id, status="new", total=10)
|
|
68
|
+
await uow.repos.orders.create(order)
|
|
69
|
+
|
|
70
|
+
async with async_sessionmaker(engine, expire_on_commit=False)() as verify:
|
|
71
|
+
result = await verify.execute(select(orders_table).where(orders_table.c.id == order_id))
|
|
72
|
+
assert result.first() is None
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
async def test_exception_inside_context_rolls_back(engine: AsyncEngine):
|
|
76
|
+
uow = build_uow(engine)
|
|
77
|
+
order_id = uuid4().hex
|
|
78
|
+
|
|
79
|
+
with pytest.raises(RuntimeError, match="boom"):
|
|
80
|
+
async with uow:
|
|
81
|
+
order = Order(id=order_id, status="new", total=10)
|
|
82
|
+
await uow.repos.orders.create(order)
|
|
83
|
+
raise RuntimeError("boom")
|
|
84
|
+
|
|
85
|
+
async with async_sessionmaker(engine, expire_on_commit=False)() as verify:
|
|
86
|
+
result = await verify.execute(select(orders_table).where(orders_table.c.id == order_id))
|
|
87
|
+
assert result.first() is None
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
async def test_rollback_discards_pending_changes(engine: AsyncEngine):
|
|
91
|
+
uow = build_uow(engine)
|
|
92
|
+
order_id = uuid4().hex
|
|
93
|
+
|
|
94
|
+
async with uow:
|
|
95
|
+
order = Order(id=order_id, status="new", total=10)
|
|
96
|
+
await uow.repos.orders.create(order)
|
|
97
|
+
await uow.rollback()
|
|
98
|
+
|
|
99
|
+
async with async_sessionmaker(engine, expire_on_commit=False)() as verify:
|
|
100
|
+
result = await verify.execute(select(orders_table).where(orders_table.c.id == order_id))
|
|
101
|
+
assert result.first() is None
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
async def test_commit_publishes_tracked_events(engine: AsyncEngine):
|
|
105
|
+
publisher = InMemoryEventPublisher()
|
|
106
|
+
events: list[DomainEvent] = []
|
|
107
|
+
|
|
108
|
+
async def collect(event: DomainEvent) -> None:
|
|
109
|
+
events.append(event)
|
|
110
|
+
|
|
111
|
+
publisher.subscribe(OrderPlaced, collect)
|
|
112
|
+
|
|
113
|
+
session_maker = async_sessionmaker(engine, expire_on_commit=False)
|
|
114
|
+
sql_session = SqlSession(session_maker)
|
|
115
|
+
tx = SqlTransaction(sql_session, publisher)
|
|
116
|
+
repos = OrderRepos(orders=TxOrderRepository(tx))
|
|
117
|
+
uow = SqlUnitOfWork(tx, repos)
|
|
118
|
+
|
|
119
|
+
async with uow:
|
|
120
|
+
order = Order(id=uuid4().hex, status="new", total=10)
|
|
121
|
+
order.add_event(OrderPlaced(order_id=order.pk))
|
|
122
|
+
await uow.repos.orders.create(order)
|
|
123
|
+
await uow.commit()
|
|
124
|
+
|
|
125
|
+
assert len(events) == 1
|
|
126
|
+
assert isinstance(events[0], OrderPlaced)
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
async def test_duplicate_commit_raises_conflict(engine: AsyncEngine):
|
|
130
|
+
order_id = uuid4().hex
|
|
131
|
+
|
|
132
|
+
async with build_uow(engine) as uow:
|
|
133
|
+
await uow.repos.orders.create(Order(id=order_id, status="new", total=10))
|
|
134
|
+
await uow.commit()
|
|
135
|
+
|
|
136
|
+
with pytest.raises(ConflictError):
|
|
137
|
+
async with build_uow(engine) as uow:
|
|
138
|
+
await uow.repos.orders.create(Order(id=order_id, status="new", total=10))
|
|
139
|
+
await uow.commit()
|
|
@@ -1,8 +0,0 @@
|
|
|
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"]
|
|
@@ -1,47 +0,0 @@
|
|
|
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()
|
|
@@ -1,65 +0,0 @@
|
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|