yaddd-sqlalchemy 0.1.1__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 (19) hide show
  1. {yaddd_sqlalchemy-0.1.1 → yaddd_sqlalchemy-0.2.0}/.gitignore +6 -2
  2. {yaddd_sqlalchemy-0.1.1 → yaddd_sqlalchemy-0.2.0}/PKG-INFO +2 -2
  3. {yaddd_sqlalchemy-0.1.1 → yaddd_sqlalchemy-0.2.0}/pyproject.toml +2 -2
  4. yaddd_sqlalchemy-0.2.0/src/yaddd_sqlalchemy/agents/manifest.toml +20 -0
  5. yaddd_sqlalchemy-0.2.0/src/yaddd_sqlalchemy/agents/rules/infrastructure.md +12 -0
  6. {yaddd_sqlalchemy-0.1.1 → yaddd_sqlalchemy-0.2.0}/src/yaddd_sqlalchemy/repositories.py +15 -5
  7. {yaddd_sqlalchemy-0.1.1 → yaddd_sqlalchemy-0.2.0}/src/yaddd_sqlalchemy/session.py +8 -2
  8. {yaddd_sqlalchemy-0.1.1 → yaddd_sqlalchemy-0.2.0}/src/yaddd_sqlalchemy/uow.py +0 -1
  9. {yaddd_sqlalchemy-0.1.1 → yaddd_sqlalchemy-0.2.0}/src/yaddd_sqlalchemy/value_objects.py +1 -0
  10. {yaddd_sqlalchemy-0.1.1 → yaddd_sqlalchemy-0.2.0}/tests/test_uow.py +14 -1
  11. {yaddd_sqlalchemy-0.1.1 → yaddd_sqlalchemy-0.2.0}/LICENSE +0 -0
  12. {yaddd_sqlalchemy-0.1.1 → yaddd_sqlalchemy-0.2.0}/README.md +0 -0
  13. {yaddd_sqlalchemy-0.1.1 → yaddd_sqlalchemy-0.2.0}/src/yaddd_sqlalchemy/__init__.py +0 -0
  14. {yaddd_sqlalchemy-0.1.1 → yaddd_sqlalchemy-0.2.0}/src/yaddd_sqlalchemy/py.typed +0 -0
  15. {yaddd_sqlalchemy-0.1.1 → yaddd_sqlalchemy-0.2.0}/tests/conftest.py +0 -0
  16. {yaddd_sqlalchemy-0.1.1 → yaddd_sqlalchemy-0.2.0}/tests/test_repository.py +0 -0
  17. {yaddd_sqlalchemy-0.1.1 → yaddd_sqlalchemy-0.2.0}/tests/test_session.py +0 -0
  18. {yaddd_sqlalchemy-0.1.1 → yaddd_sqlalchemy-0.2.0}/tests/test_transaction_bound_repository.py +0 -0
  19. {yaddd_sqlalchemy-0.1.1 → 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.1
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.3
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.3",
22
+ "yaddd>=0.5",
23
23
  "sqlalchemy[asyncio]>=2.0",
24
24
  ]
25
- version = "0.1.1"
25
+ version = "0.2.0"
26
26
  readme = 'README.md'
27
27
 
28
28
  [project.urls]
@@ -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,11 +5,12 @@ 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
12
-
13
+ from yaddd.exceptions import ConflictError, EntityNotFoundError
13
14
  from yaddd_sqlalchemy.session import SqlTransaction
14
15
 
15
16
 
@@ -79,18 +80,27 @@ class SqlCrudRepository[T: AggregateRoot](CrudRepository[T], ABC):
79
80
  return [self.to_domain(row) for row in result.all()]
80
81
 
81
82
  async def create(self, instance: T) -> T:
82
- 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
83
87
  return instance
84
88
 
85
89
  async def update(self, instance: T) -> T:
86
90
  if await self.get(instance.pk) is None:
87
91
  raise EntityNotFoundError(f"{type(instance).__name__}({instance.pk})")
88
92
  stmt = update(self.table).where(self._pk_column == instance.pk).values(**self.to_row(instance))
89
- 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
90
97
  return instance
91
98
 
92
99
  async def delete(self, pk: PrimaryKey) -> None:
93
- 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
94
104
 
95
105
 
96
106
  class SqlTransactionBoundRepository[T: AggregateRoot](SqlCrudRepository[T], ABC):
@@ -1,7 +1,10 @@
1
1
  """SQLAlchemy implementation of the application ``Session`` protocol."""
2
2
 
3
+ from sqlalchemy.exc import IntegrityError
3
4
  from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
5
+
4
6
  from yaddd.application.session import Session
7
+ from yaddd.exceptions import ConflictError
5
8
  from yaddd.infrastructure.transaction import BaseTransaction
6
9
 
7
10
 
@@ -28,9 +31,12 @@ class SqlSession(Session):
28
31
  await self._session.__aenter__()
29
32
 
30
33
  async def commit(self) -> None:
31
- """Commit the current session."""
34
+ """Commit the current session, translating integrity failures."""
32
35
  if self._session is not None:
33
- await self._session.commit()
36
+ try:
37
+ await self._session.commit()
38
+ except IntegrityError as exc:
39
+ raise ConflictError(str(exc)) from exc
34
40
 
35
41
  async def rollback(self) -> None:
36
42
  """Roll back the current session."""
@@ -5,7 +5,6 @@ from typing import Self, TypeVar
5
5
 
6
6
  from yaddd.application.uow import UnitOfWork
7
7
  from yaddd.domain.entities import AggregateRoot
8
-
9
8
  from yaddd_sqlalchemy.session import SqlTransaction
10
9
 
11
10
 
@@ -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
 
@@ -5,7 +5,7 @@ import pytest
5
5
  from sqlalchemy import select
6
6
  from sqlalchemy.ext.asyncio import AsyncEngine, async_sessionmaker
7
7
 
8
- from yaddd import DomainEvent, InMemoryEventPublisher
8
+ from yaddd import ConflictError, DomainEvent, InMemoryEventPublisher
9
9
  from yaddd_sqlalchemy import SqlSession, SqlTransaction, SqlTransactionBoundRepository, SqlUnitOfWork
10
10
 
11
11
  from conftest import Order, orders_table
@@ -124,3 +124,16 @@ async def test_commit_publishes_tracked_events(engine: AsyncEngine):
124
124
 
125
125
  assert len(events) == 1
126
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()