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.
Files changed (22) hide show
  1. {yaddd_sqlalchemy-0.1.0 → yaddd_sqlalchemy-0.2.0}/.gitignore +6 -2
  2. {yaddd_sqlalchemy-0.1.0 → yaddd_sqlalchemy-0.2.0}/PKG-INFO +2 -2
  3. {yaddd_sqlalchemy-0.1.0 → yaddd_sqlalchemy-0.2.0}/pyproject.toml +2 -2
  4. yaddd_sqlalchemy-0.2.0/src/yaddd_sqlalchemy/__init__.py +16 -0
  5. yaddd_sqlalchemy-0.2.0/src/yaddd_sqlalchemy/agents/manifest.toml +20 -0
  6. yaddd_sqlalchemy-0.2.0/src/yaddd_sqlalchemy/agents/rules/infrastructure.md +12 -0
  7. {yaddd_sqlalchemy-0.1.0 → yaddd_sqlalchemy-0.2.0}/src/yaddd_sqlalchemy/repositories.py +49 -6
  8. yaddd_sqlalchemy-0.2.0/src/yaddd_sqlalchemy/session.py +54 -0
  9. yaddd_sqlalchemy-0.2.0/src/yaddd_sqlalchemy/uow.py +56 -0
  10. {yaddd_sqlalchemy-0.1.0 → yaddd_sqlalchemy-0.2.0}/src/yaddd_sqlalchemy/value_objects.py +1 -0
  11. yaddd_sqlalchemy-0.2.0/tests/test_session.py +32 -0
  12. yaddd_sqlalchemy-0.2.0/tests/test_transaction_bound_repository.py +84 -0
  13. yaddd_sqlalchemy-0.2.0/tests/test_uow.py +139 -0
  14. yaddd_sqlalchemy-0.1.0/src/yaddd_sqlalchemy/__init__.py +0 -8
  15. yaddd_sqlalchemy-0.1.0/src/yaddd_sqlalchemy/uow.py +0 -47
  16. yaddd_sqlalchemy-0.1.0/tests/test_uow.py +0 -65
  17. {yaddd_sqlalchemy-0.1.0 → yaddd_sqlalchemy-0.2.0}/LICENSE +0 -0
  18. {yaddd_sqlalchemy-0.1.0 → yaddd_sqlalchemy-0.2.0}/README.md +0 -0
  19. {yaddd_sqlalchemy-0.1.0 → yaddd_sqlalchemy-0.2.0}/src/yaddd_sqlalchemy/py.typed +0 -0
  20. {yaddd_sqlalchemy-0.1.0 → yaddd_sqlalchemy-0.2.0}/tests/conftest.py +0 -0
  21. {yaddd_sqlalchemy-0.1.0 → yaddd_sqlalchemy-0.2.0}/tests/test_repository.py +0 -0
  22. {yaddd_sqlalchemy-0.1.0 → yaddd_sqlalchemy-0.2.0}/tests/test_value_objects.py +0 -0
@@ -140,10 +140,14 @@ venv.bak/
140
140
  # mkdocs documentation
141
141
  /site
142
142
 
143
+ # ruff
144
+ .ruff_cache/
145
+
143
146
  # mypy
144
147
  .mypy_cache/
145
- .dmypy.json
146
- dmypy.json
148
+
149
+ # ty
150
+ .ty/
147
151
 
148
152
  # Pyre type checker
149
153
  .pyre/
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: yaddd-sqlalchemy
3
- Version: 0.1.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.1
20
+ Requires-Dist: yaddd>=0.5
21
21
  Description-Content-Type: text/markdown
22
22
 
23
23
  # yaddd-sqlalchemy
@@ -19,10 +19,10 @@ classifiers = [
19
19
  ]
20
20
  requires-python = '>=3.12'
21
21
  dependencies = [
22
- "yaddd>=0.1",
22
+ "yaddd>=0.5",
23
23
  "sqlalchemy[asyncio]>=2.0",
24
24
  ]
25
- version = "0.1.0"
25
+ version = "0.2.0"
26
26
  readme = 'README.md'
27
27
 
28
28
  [project.urls]
@@ -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._session = session
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
- await self._session.execute(insert(self.table).values(**self.to_row(instance)))
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
- await self._session.execute(stmt)
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
- await self._session.execute(delete(self.table).where(self._pk_column == pk))
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()
@@ -4,6 +4,7 @@ from typing import Any, ClassVar
4
4
 
5
5
  from sqlalchemy.engine import Dialect
6
6
  from sqlalchemy.types import TypeDecorator
7
+
7
8
  from yaddd.domain.value_object import ValueObject
8
9
 
9
10
 
@@ -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