yaddd-sqlalchemy 0.1.0__py3-none-any.whl
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/__init__.py +8 -0
- yaddd_sqlalchemy/py.typed +0 -0
- yaddd_sqlalchemy/repositories.py +87 -0
- yaddd_sqlalchemy/uow.py +47 -0
- yaddd_sqlalchemy/value_objects.py +33 -0
- yaddd_sqlalchemy-0.1.0.dist-info/METADATA +31 -0
- yaddd_sqlalchemy-0.1.0.dist-info/RECORD +9 -0
- yaddd_sqlalchemy-0.1.0.dist-info/WHEEL +4 -0
- yaddd_sqlalchemy-0.1.0.dist-info/licenses/LICENSE +0 -0
|
@@ -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))
|
yaddd_sqlalchemy/uow.py
ADDED
|
@@ -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,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/__init__.py,sha256=if6Kyp_p9P6aLWVF5fqnUSE5dcbTcDYgsImYYqCfoZk,277
|
|
2
|
+
yaddd_sqlalchemy/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
3
|
+
yaddd_sqlalchemy/repositories.py,sha256=tKlhzIEUxbLGZ_85MRT7lSkyOl1UVBKdZXnWdIqtEa0,3452
|
|
4
|
+
yaddd_sqlalchemy/uow.py,sha256=GCcFfLkZICPMuxP8tLzIJEZ3Ph4_GDiENWTc3QmnqrE,1376
|
|
5
|
+
yaddd_sqlalchemy/value_objects.py,sha256=X1M6yoJeylhcibzJ3t6lcNiDZRI7pPAsi90j4Y_n3t4,1112
|
|
6
|
+
yaddd_sqlalchemy-0.1.0.dist-info/METADATA,sha256=euvzt9HtmCtarllZH5v9LmcSdLJTo2nOenfjsIZOn3k,1345
|
|
7
|
+
yaddd_sqlalchemy-0.1.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
|
|
8
|
+
yaddd_sqlalchemy-0.1.0.dist-info/licenses/LICENSE,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
9
|
+
yaddd_sqlalchemy-0.1.0.dist-info/RECORD,,
|
|
File without changes
|