hexastack-db 0.0.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.
Potentially problematic release.
This version of hexastack-db might be problematic. Click here for more details.
- hexastack_db/__init__.py +7 -0
- hexastack_db/adapters/__init__.py +23 -0
- hexastack_db/adapters/repository.py +420 -0
- hexastack_db/adapters/unit_of_work.py +184 -0
- hexastack_db/adapters/vector.py +267 -0
- hexastack_db/domain/__init__.py +13 -0
- hexastack_db/domain/exceptions.py +64 -0
- hexastack_db/infra/__init__.py +59 -0
- hexastack_db/infra/bootstrap.py +209 -0
- hexastack_db/infra/config.py +182 -0
- hexastack_db/infra/engine.py +142 -0
- hexastack_db/infra/migrations.py +309 -0
- hexastack_db/infra/mixins.py +63 -0
- hexastack_db/infra/registries/__init__.py +0 -0
- hexastack_db/infra/registries/metadata.py +53 -0
- hexastack_db/py.typed +1 -0
- hexastack_db-0.0.0.dist-info/METADATA +225 -0
- hexastack_db-0.0.0.dist-info/RECORD +20 -0
- hexastack_db-0.0.0.dist-info/WHEEL +4 -0
- hexastack_db-0.0.0.dist-info/entry_points.txt +3 -0
hexastack_db/__init__.py
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
from hexastack_db.adapters.repository import (
|
|
2
|
+
AsyncSqlAlchemyRepository,
|
|
3
|
+
SqlAlchemyRepository,
|
|
4
|
+
)
|
|
5
|
+
from hexastack_db.adapters.unit_of_work import (
|
|
6
|
+
AsyncSqlAlchemyUnitOfWork,
|
|
7
|
+
SqlAlchemyUnitOfWork,
|
|
8
|
+
)
|
|
9
|
+
from hexastack_db.adapters.vector import (
|
|
10
|
+
AsyncPgVectorStoreAdapter,
|
|
11
|
+
PgVectorStoreAdapter,
|
|
12
|
+
create_vector_table,
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
__all__ = [
|
|
16
|
+
"AsyncPgVectorStoreAdapter",
|
|
17
|
+
"AsyncSqlAlchemyRepository",
|
|
18
|
+
"AsyncSqlAlchemyUnitOfWork",
|
|
19
|
+
"create_vector_table",
|
|
20
|
+
"PgVectorStoreAdapter",
|
|
21
|
+
"SqlAlchemyRepository",
|
|
22
|
+
"SqlAlchemyUnitOfWork",
|
|
23
|
+
]
|
|
@@ -0,0 +1,420 @@
|
|
|
1
|
+
from collections.abc import Sequence
|
|
2
|
+
from typing import Any
|
|
3
|
+
|
|
4
|
+
from sqlalchemy import func, select
|
|
5
|
+
from sqlalchemy.exc import IntegrityError
|
|
6
|
+
from sqlalchemy.ext.asyncio import AsyncSession
|
|
7
|
+
from sqlalchemy.orm import Session
|
|
8
|
+
|
|
9
|
+
from hexastack_core.ports.repository import (
|
|
10
|
+
AsyncRepositoryPort,
|
|
11
|
+
RepositoryPort,
|
|
12
|
+
)
|
|
13
|
+
from hexastack_db.domain.exceptions import (
|
|
14
|
+
DatabaseError,
|
|
15
|
+
UniqueConstraintViolationError,
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class SqlAlchemyRepository[T, ID](RepositoryPort[T, ID]):
|
|
20
|
+
"""SQLAlchemy implementation of the generic Repository port.
|
|
21
|
+
|
|
22
|
+
Notes/Architectural Intent:
|
|
23
|
+
Implements synchronous CRUD operations over declarative SQLAlchemy models,
|
|
24
|
+
translating raw integrity errors into domain-level database exceptions.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
def __init__(self, session: Session, model_cls: type[T]) -> None:
|
|
28
|
+
"""Initialize repository with active session and target model class.
|
|
29
|
+
|
|
30
|
+
Args:
|
|
31
|
+
session: Active SQLAlchemy Session instance.
|
|
32
|
+
model_cls: Declarative SQLAlchemy model class.
|
|
33
|
+
"""
|
|
34
|
+
self._session = session
|
|
35
|
+
self._model_cls = model_cls
|
|
36
|
+
|
|
37
|
+
def add(self, entity: T) -> None:
|
|
38
|
+
"""Persist a new entity to the database session.
|
|
39
|
+
|
|
40
|
+
Args:
|
|
41
|
+
entity: Domain model entity instance.
|
|
42
|
+
|
|
43
|
+
Returns:
|
|
44
|
+
None.
|
|
45
|
+
|
|
46
|
+
Raises:
|
|
47
|
+
UniqueConstraintViolationError: If a uniqueness constraint is violated.
|
|
48
|
+
DatabaseError: If database persistence fails.
|
|
49
|
+
"""
|
|
50
|
+
try:
|
|
51
|
+
self._session.add(entity)
|
|
52
|
+
self._session.flush()
|
|
53
|
+
except IntegrityError as exc:
|
|
54
|
+
raise UniqueConstraintViolationError(str(exc.orig or exc)) from exc
|
|
55
|
+
except Exception as exc:
|
|
56
|
+
raise DatabaseError(str(exc)) from exc
|
|
57
|
+
|
|
58
|
+
def add_many(self, entities: Sequence[T]) -> None:
|
|
59
|
+
"""Persist multiple entities in batch.
|
|
60
|
+
|
|
61
|
+
Args:
|
|
62
|
+
entities: Sequence of entities to persist.
|
|
63
|
+
|
|
64
|
+
Returns:
|
|
65
|
+
None.
|
|
66
|
+
|
|
67
|
+
Raises:
|
|
68
|
+
UniqueConstraintViolationError: If a uniqueness constraint is violated.
|
|
69
|
+
DatabaseError: If database persistence fails.
|
|
70
|
+
"""
|
|
71
|
+
try:
|
|
72
|
+
self._session.add_all(entities)
|
|
73
|
+
self._session.flush()
|
|
74
|
+
except IntegrityError as exc:
|
|
75
|
+
raise UniqueConstraintViolationError(str(exc.orig or exc)) from exc
|
|
76
|
+
except Exception as exc:
|
|
77
|
+
raise DatabaseError(str(exc)) from exc
|
|
78
|
+
|
|
79
|
+
def count(self, **filters: Any) -> int:
|
|
80
|
+
"""Return count of matching records.
|
|
81
|
+
|
|
82
|
+
Args:
|
|
83
|
+
**filters: Field-value filtering criteria.
|
|
84
|
+
|
|
85
|
+
Returns:
|
|
86
|
+
Total count of matching records.
|
|
87
|
+
|
|
88
|
+
Raises:
|
|
89
|
+
DatabaseError: If query execution fails.
|
|
90
|
+
"""
|
|
91
|
+
try:
|
|
92
|
+
stmt = select(func.count()).select_from(self._model_cls)
|
|
93
|
+
for k, v in filters.items():
|
|
94
|
+
if hasattr(self._model_cls, k):
|
|
95
|
+
stmt = stmt.where(getattr(self._model_cls, k) == v)
|
|
96
|
+
result = self._session.scalar(stmt)
|
|
97
|
+
return int(result or 0)
|
|
98
|
+
except Exception as exc:
|
|
99
|
+
raise DatabaseError(str(exc)) from exc
|
|
100
|
+
|
|
101
|
+
def delete(self, entity_id: ID) -> bool:
|
|
102
|
+
"""Delete an entity by primary key.
|
|
103
|
+
|
|
104
|
+
Args:
|
|
105
|
+
entity_id: Primary key identifier.
|
|
106
|
+
|
|
107
|
+
Returns:
|
|
108
|
+
True if an entity was found and deleted, otherwise False.
|
|
109
|
+
|
|
110
|
+
Raises:
|
|
111
|
+
DatabaseError: If deletion fails.
|
|
112
|
+
"""
|
|
113
|
+
try:
|
|
114
|
+
entity = self._session.get(self._model_cls, entity_id)
|
|
115
|
+
if entity is None:
|
|
116
|
+
return False
|
|
117
|
+
self._session.delete(entity)
|
|
118
|
+
self._session.flush()
|
|
119
|
+
return True
|
|
120
|
+
except Exception as exc:
|
|
121
|
+
raise DatabaseError(str(exc)) from exc
|
|
122
|
+
|
|
123
|
+
def get(self, entity_id: ID) -> T | None:
|
|
124
|
+
"""Retrieve entity by primary key identifier.
|
|
125
|
+
|
|
126
|
+
Args:
|
|
127
|
+
entity_id: Primary key identifier.
|
|
128
|
+
|
|
129
|
+
Returns:
|
|
130
|
+
Entity instance if found, otherwise None.
|
|
131
|
+
|
|
132
|
+
Raises:
|
|
133
|
+
DatabaseError: If query execution fails.
|
|
134
|
+
"""
|
|
135
|
+
return self.get_by_id(entity_id)
|
|
136
|
+
|
|
137
|
+
def get_by_id(self, entity_id: ID) -> T | None:
|
|
138
|
+
"""Retrieve entity by unique identifier (satisfies Repository port).
|
|
139
|
+
|
|
140
|
+
Args:
|
|
141
|
+
entity_id: Primary key identifier.
|
|
142
|
+
|
|
143
|
+
Returns:
|
|
144
|
+
Entity instance if found, otherwise None.
|
|
145
|
+
|
|
146
|
+
Raises:
|
|
147
|
+
DatabaseError: If query execution fails.
|
|
148
|
+
"""
|
|
149
|
+
try:
|
|
150
|
+
return self._session.get(self._model_cls, entity_id)
|
|
151
|
+
except Exception as exc:
|
|
152
|
+
raise DatabaseError(str(exc)) from exc
|
|
153
|
+
|
|
154
|
+
def list(self, offset: int = 0, limit: int = 100, **filters: Any) -> Sequence[T]:
|
|
155
|
+
"""Query a page of entities matching optional attribute filters.
|
|
156
|
+
|
|
157
|
+
Args:
|
|
158
|
+
offset: Pagination offset index.
|
|
159
|
+
limit: Maximum number of records to return.
|
|
160
|
+
**filters: Field-value filtering criteria.
|
|
161
|
+
|
|
162
|
+
Returns:
|
|
163
|
+
Sequence of matching entities.
|
|
164
|
+
|
|
165
|
+
Raises:
|
|
166
|
+
DatabaseError: If query execution fails.
|
|
167
|
+
"""
|
|
168
|
+
try:
|
|
169
|
+
stmt = select(self._model_cls).offset(offset).limit(limit)
|
|
170
|
+
for k, v in filters.items():
|
|
171
|
+
if hasattr(self._model_cls, k):
|
|
172
|
+
stmt = stmt.where(getattr(self._model_cls, k) == v)
|
|
173
|
+
result = self._session.execute(stmt).scalars().all()
|
|
174
|
+
return list(result)
|
|
175
|
+
except Exception as exc:
|
|
176
|
+
raise DatabaseError(str(exc)) from exc
|
|
177
|
+
|
|
178
|
+
def remove(self, entity_id: ID) -> None:
|
|
179
|
+
"""Remove an entity instance by primary key (satisfies Repository port).
|
|
180
|
+
|
|
181
|
+
Args:
|
|
182
|
+
entity_id: Primary key identifier.
|
|
183
|
+
|
|
184
|
+
Returns:
|
|
185
|
+
None.
|
|
186
|
+
|
|
187
|
+
Raises:
|
|
188
|
+
DatabaseError: If deletion fails.
|
|
189
|
+
"""
|
|
190
|
+
self.delete(entity_id)
|
|
191
|
+
|
|
192
|
+
def update(self, entity: T) -> T:
|
|
193
|
+
"""Update an existing entity in the session.
|
|
194
|
+
|
|
195
|
+
Args:
|
|
196
|
+
entity: Modified entity instance.
|
|
197
|
+
|
|
198
|
+
Returns:
|
|
199
|
+
The merged entity instance.
|
|
200
|
+
|
|
201
|
+
Raises:
|
|
202
|
+
UniqueConstraintViolationError: If updating violates a constraint.
|
|
203
|
+
DatabaseError: If update fails.
|
|
204
|
+
"""
|
|
205
|
+
try:
|
|
206
|
+
merged = self._session.merge(entity)
|
|
207
|
+
self._session.flush()
|
|
208
|
+
return merged
|
|
209
|
+
except IntegrityError as exc:
|
|
210
|
+
raise UniqueConstraintViolationError(str(exc.orig or exc)) from exc
|
|
211
|
+
except Exception as exc:
|
|
212
|
+
raise DatabaseError(str(exc)) from exc
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
class AsyncSqlAlchemyRepository[T, ID](AsyncRepositoryPort[T, ID]):
|
|
216
|
+
"""Asynchronous SQLAlchemy repository implementation.
|
|
217
|
+
|
|
218
|
+
Notes/Architectural Intent:
|
|
219
|
+
Implements async CRUD operations over AsyncSession for non-blocking I/O.
|
|
220
|
+
"""
|
|
221
|
+
|
|
222
|
+
def __init__(self, session: AsyncSession, model_cls: type[T]) -> None:
|
|
223
|
+
"""Initialize async repository with AsyncSession and model class.
|
|
224
|
+
|
|
225
|
+
Args:
|
|
226
|
+
session: Active AsyncSession instance.
|
|
227
|
+
model_cls: Declarative SQLAlchemy model class.
|
|
228
|
+
"""
|
|
229
|
+
self._session = session
|
|
230
|
+
self._model_cls = model_cls
|
|
231
|
+
|
|
232
|
+
async def add(self, entity: T) -> None:
|
|
233
|
+
"""Persist a new entity asynchronously.
|
|
234
|
+
|
|
235
|
+
Args:
|
|
236
|
+
entity: Domain model entity instance.
|
|
237
|
+
|
|
238
|
+
Returns:
|
|
239
|
+
None.
|
|
240
|
+
|
|
241
|
+
Raises:
|
|
242
|
+
UniqueConstraintViolationError: If constraint violation occurs.
|
|
243
|
+
DatabaseError: If persistence fails.
|
|
244
|
+
"""
|
|
245
|
+
try:
|
|
246
|
+
self._session.add(entity)
|
|
247
|
+
await self._session.flush()
|
|
248
|
+
except IntegrityError as exc:
|
|
249
|
+
raise UniqueConstraintViolationError(str(exc.orig or exc)) from exc
|
|
250
|
+
except Exception as exc:
|
|
251
|
+
raise DatabaseError(str(exc)) from exc
|
|
252
|
+
|
|
253
|
+
async def add_many(self, entities: Sequence[T]) -> None:
|
|
254
|
+
"""Persist multiple entities in batch asynchronously.
|
|
255
|
+
|
|
256
|
+
Args:
|
|
257
|
+
entities: Sequence of entities to persist.
|
|
258
|
+
|
|
259
|
+
Returns:
|
|
260
|
+
None.
|
|
261
|
+
|
|
262
|
+
Raises:
|
|
263
|
+
UniqueConstraintViolationError: If constraint violation occurs.
|
|
264
|
+
DatabaseError: If persistence fails.
|
|
265
|
+
"""
|
|
266
|
+
try:
|
|
267
|
+
self._session.add_all(entities)
|
|
268
|
+
await self._session.flush()
|
|
269
|
+
except IntegrityError as exc:
|
|
270
|
+
raise UniqueConstraintViolationError(str(exc.orig or exc)) from exc
|
|
271
|
+
except Exception as exc:
|
|
272
|
+
raise DatabaseError(str(exc)) from exc
|
|
273
|
+
|
|
274
|
+
async def count(self, **filters: Any) -> int:
|
|
275
|
+
"""Return count of matching records asynchronously.
|
|
276
|
+
|
|
277
|
+
Args:
|
|
278
|
+
**filters: Field-value filtering criteria.
|
|
279
|
+
|
|
280
|
+
Returns:
|
|
281
|
+
Total count of matching records.
|
|
282
|
+
|
|
283
|
+
Raises:
|
|
284
|
+
DatabaseError: If query execution fails.
|
|
285
|
+
"""
|
|
286
|
+
try:
|
|
287
|
+
stmt = select(func.count()).select_from(self._model_cls)
|
|
288
|
+
for k, v in filters.items():
|
|
289
|
+
if hasattr(self._model_cls, k):
|
|
290
|
+
stmt = stmt.where(getattr(self._model_cls, k) == v)
|
|
291
|
+
result = await self._session.execute(stmt)
|
|
292
|
+
return int(result.scalar_one())
|
|
293
|
+
except Exception as exc:
|
|
294
|
+
raise DatabaseError(str(exc)) from exc
|
|
295
|
+
|
|
296
|
+
async def delete(self, entity_id: ID) -> bool:
|
|
297
|
+
"""Delete an entity by primary key asynchronously.
|
|
298
|
+
|
|
299
|
+
Args:
|
|
300
|
+
entity_id: Primary key identifier.
|
|
301
|
+
|
|
302
|
+
Returns:
|
|
303
|
+
True if entity was found and deleted, otherwise False.
|
|
304
|
+
|
|
305
|
+
Raises:
|
|
306
|
+
DatabaseError: If deletion fails.
|
|
307
|
+
"""
|
|
308
|
+
try:
|
|
309
|
+
entity = await self._session.get(self._model_cls, entity_id)
|
|
310
|
+
if entity is None:
|
|
311
|
+
return False
|
|
312
|
+
await self._session.delete(entity)
|
|
313
|
+
await self._session.flush()
|
|
314
|
+
return True
|
|
315
|
+
except Exception as exc:
|
|
316
|
+
raise DatabaseError(str(exc)) from exc
|
|
317
|
+
|
|
318
|
+
async def get(self, entity_id: ID) -> T | None:
|
|
319
|
+
"""Retrieve entity by primary key identifier asynchronously.
|
|
320
|
+
|
|
321
|
+
Args:
|
|
322
|
+
entity_id: Primary key identifier.
|
|
323
|
+
|
|
324
|
+
Returns:
|
|
325
|
+
Entity instance if found, otherwise None.
|
|
326
|
+
|
|
327
|
+
Raises:
|
|
328
|
+
DatabaseError: If query execution fails.
|
|
329
|
+
"""
|
|
330
|
+
return await self.get_by_id(entity_id)
|
|
331
|
+
|
|
332
|
+
async def get_by_id(self, entity_id: ID) -> T | None:
|
|
333
|
+
"""Retrieve entity by unique identifier asynchronously.
|
|
334
|
+
|
|
335
|
+
Args:
|
|
336
|
+
entity_id: Primary key identifier.
|
|
337
|
+
|
|
338
|
+
Returns:
|
|
339
|
+
Entity instance if found, otherwise None.
|
|
340
|
+
|
|
341
|
+
Raises:
|
|
342
|
+
DatabaseError: If query execution fails.
|
|
343
|
+
"""
|
|
344
|
+
try:
|
|
345
|
+
return await self._session.get(self._model_cls, entity_id)
|
|
346
|
+
except Exception as exc:
|
|
347
|
+
raise DatabaseError(str(exc)) from exc
|
|
348
|
+
|
|
349
|
+
async def list(
|
|
350
|
+
self, offset: int = 0, limit: int = 100, **filters: Any
|
|
351
|
+
) -> Sequence[T]:
|
|
352
|
+
"""Query a page of entities matching optional attribute filters asynchronously.
|
|
353
|
+
|
|
354
|
+
Args:
|
|
355
|
+
offset: Pagination offset index.
|
|
356
|
+
limit: Maximum number of records to return.
|
|
357
|
+
**filters: Field-value filtering criteria.
|
|
358
|
+
|
|
359
|
+
Returns:
|
|
360
|
+
Sequence of matching entities.
|
|
361
|
+
|
|
362
|
+
Raises:
|
|
363
|
+
DatabaseError: If query execution fails.
|
|
364
|
+
"""
|
|
365
|
+
try:
|
|
366
|
+
stmt = select(self._model_cls).offset(offset).limit(limit)
|
|
367
|
+
for k, v in filters.items():
|
|
368
|
+
if hasattr(self._model_cls, k):
|
|
369
|
+
stmt = stmt.where(getattr(self._model_cls, k) == v)
|
|
370
|
+
result = await self._session.execute(stmt)
|
|
371
|
+
return list(result.scalars().all())
|
|
372
|
+
except Exception as exc:
|
|
373
|
+
raise DatabaseError(str(exc)) from exc
|
|
374
|
+
|
|
375
|
+
async def remove(self, entity_id: ID) -> None:
|
|
376
|
+
"""Remove an entity by unique identifier asynchronously.
|
|
377
|
+
|
|
378
|
+
Args:
|
|
379
|
+
entity_id: Primary key identifier.
|
|
380
|
+
|
|
381
|
+
Returns:
|
|
382
|
+
None.
|
|
383
|
+
|
|
384
|
+
Raises:
|
|
385
|
+
DatabaseError: If deletion fails.
|
|
386
|
+
"""
|
|
387
|
+
await self.delete(entity_id)
|
|
388
|
+
|
|
389
|
+
async def update(self, entity: T) -> T:
|
|
390
|
+
"""Update an existing entity in the session asynchronously.
|
|
391
|
+
|
|
392
|
+
Args:
|
|
393
|
+
entity: Modified entity instance.
|
|
394
|
+
|
|
395
|
+
Returns:
|
|
396
|
+
The merged entity instance.
|
|
397
|
+
|
|
398
|
+
Raises:
|
|
399
|
+
UniqueConstraintViolationError: If constraint violation occurs.
|
|
400
|
+
DatabaseError: If update fails.
|
|
401
|
+
"""
|
|
402
|
+
try:
|
|
403
|
+
merged = await self._session.merge(entity)
|
|
404
|
+
await self._session.flush()
|
|
405
|
+
return merged
|
|
406
|
+
except IntegrityError as exc:
|
|
407
|
+
raise UniqueConstraintViolationError(str(exc.orig or exc)) from exc
|
|
408
|
+
except Exception as exc:
|
|
409
|
+
raise DatabaseError(str(exc)) from exc
|
|
410
|
+
|
|
411
|
+
# Aliases satisfying AsyncRepositoryPort interface
|
|
412
|
+
add_async = add
|
|
413
|
+
get_by_id_async = get_by_id
|
|
414
|
+
remove_async = remove
|
|
415
|
+
|
|
416
|
+
|
|
417
|
+
__all__ = [
|
|
418
|
+
"AsyncSqlAlchemyRepository",
|
|
419
|
+
"SqlAlchemyRepository",
|
|
420
|
+
]
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
import contextlib
|
|
2
|
+
from collections.abc import Callable
|
|
3
|
+
from types import TracebackType
|
|
4
|
+
from typing import Self
|
|
5
|
+
|
|
6
|
+
from sqlalchemy.exc import SQLAlchemyError
|
|
7
|
+
from sqlalchemy.ext.asyncio import (
|
|
8
|
+
AsyncSession,
|
|
9
|
+
async_sessionmaker,
|
|
10
|
+
)
|
|
11
|
+
from sqlalchemy.orm import Session, sessionmaker
|
|
12
|
+
|
|
13
|
+
from hexastack_core.domain.exceptions import UnitOfWorkError
|
|
14
|
+
from hexastack_core.ports.unit_of_work import (
|
|
15
|
+
AsyncUnitOfWorkPort,
|
|
16
|
+
UnitOfWorkPort,
|
|
17
|
+
)
|
|
18
|
+
from hexastack_db.domain.exceptions import DatabaseError
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class SqlAlchemyUnitOfWork(UnitOfWorkPort):
|
|
22
|
+
"""SQLAlchemy implementation of UnitOfWorkPort.
|
|
23
|
+
|
|
24
|
+
Notes/Architectural Intent:
|
|
25
|
+
Manages transactional boundaries using SQLAlchemy Session lifecycle,
|
|
26
|
+
automatically committing clean execution blocks and rolling back on failure.
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
def __init__(
|
|
30
|
+
self,
|
|
31
|
+
session_factory: sessionmaker[Session],
|
|
32
|
+
reraise: bool = False,
|
|
33
|
+
) -> None:
|
|
34
|
+
"""Initialize Unit of Work with sessionmaker factory.
|
|
35
|
+
|
|
36
|
+
Args:
|
|
37
|
+
session_factory: Configured SQLAlchemy sessionmaker.
|
|
38
|
+
reraise: If True, wraps exceptions in UnitOfWorkError.
|
|
39
|
+
"""
|
|
40
|
+
super().__init__(reraise=reraise)
|
|
41
|
+
self._session_factory = session_factory
|
|
42
|
+
self._session: Session | None = None
|
|
43
|
+
|
|
44
|
+
def __enter__(self) -> Self:
|
|
45
|
+
"""Enter transactional scope and acquire fresh database Session."""
|
|
46
|
+
self._session = self._session_factory()
|
|
47
|
+
return self
|
|
48
|
+
|
|
49
|
+
def __exit__(
|
|
50
|
+
self,
|
|
51
|
+
exc_type: type[BaseException] | None,
|
|
52
|
+
exc: BaseException | None,
|
|
53
|
+
trace: TracebackType | None,
|
|
54
|
+
) -> None:
|
|
55
|
+
"""Exit transactional scope, commit or rollback, and close session."""
|
|
56
|
+
try:
|
|
57
|
+
super().__exit__(exc_type, exc, trace)
|
|
58
|
+
finally:
|
|
59
|
+
if self._session is not None:
|
|
60
|
+
self._session.close()
|
|
61
|
+
self._session = None
|
|
62
|
+
|
|
63
|
+
def commit(self) -> None:
|
|
64
|
+
"""Commit all pending database changes in active session.
|
|
65
|
+
|
|
66
|
+
Raises:
|
|
67
|
+
UnitOfWorkError: If committing transaction fails.
|
|
68
|
+
"""
|
|
69
|
+
if self._session is not None:
|
|
70
|
+
try:
|
|
71
|
+
self._session.commit()
|
|
72
|
+
except SQLAlchemyError as exc:
|
|
73
|
+
self._session.rollback()
|
|
74
|
+
raise UnitOfWorkError() from exc
|
|
75
|
+
|
|
76
|
+
def rollback(self) -> None:
|
|
77
|
+
"""Roll back all pending database changes in active session."""
|
|
78
|
+
if self._session is not None:
|
|
79
|
+
with contextlib.suppress(SQLAlchemyError):
|
|
80
|
+
self._session.rollback()
|
|
81
|
+
|
|
82
|
+
@property
|
|
83
|
+
def session(self) -> Session:
|
|
84
|
+
"""Active SQLAlchemy Session in current transactional scope.
|
|
85
|
+
|
|
86
|
+
Returns:
|
|
87
|
+
Current Session.
|
|
88
|
+
|
|
89
|
+
Raises:
|
|
90
|
+
DatabaseError: If accessed outside of transactional context manager.
|
|
91
|
+
"""
|
|
92
|
+
if self._session is None:
|
|
93
|
+
raise DatabaseError(
|
|
94
|
+
"UnitOfWork session is not active. Use within 'with uow:' context."
|
|
95
|
+
)
|
|
96
|
+
return self._session
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
class AsyncSqlAlchemyUnitOfWork(AsyncUnitOfWorkPort):
|
|
100
|
+
"""Asynchronous SQLAlchemy Unit of Work implementation.
|
|
101
|
+
|
|
102
|
+
Notes/Architectural Intent:
|
|
103
|
+
Wraps an asynchronous session and guarantees rollback on error
|
|
104
|
+
and session closure when exiting async context blocks.
|
|
105
|
+
"""
|
|
106
|
+
|
|
107
|
+
def __init__(
|
|
108
|
+
self,
|
|
109
|
+
session_factory: (
|
|
110
|
+
async_sessionmaker[AsyncSession] | Callable[[], AsyncSession]
|
|
111
|
+
),
|
|
112
|
+
reraise: bool = False,
|
|
113
|
+
) -> None:
|
|
114
|
+
super().__init__(reraise=reraise)
|
|
115
|
+
self._session_factory = session_factory
|
|
116
|
+
self._session: AsyncSession | None = None
|
|
117
|
+
|
|
118
|
+
async def __aenter__(self) -> Self:
|
|
119
|
+
"""Enter asynchronous transactional scope and acquire fresh AsyncSession."""
|
|
120
|
+
self._session = self._session_factory()
|
|
121
|
+
return self
|
|
122
|
+
|
|
123
|
+
async def __aexit__(
|
|
124
|
+
self,
|
|
125
|
+
exc_type: type[BaseException] | None,
|
|
126
|
+
exc: BaseException | None,
|
|
127
|
+
trace: TracebackType | None,
|
|
128
|
+
) -> None:
|
|
129
|
+
"""Exit asynchronous transactional scope, commit or rollback, and close session."""
|
|
130
|
+
try:
|
|
131
|
+
await super().__aexit__(exc_type, exc, trace)
|
|
132
|
+
finally:
|
|
133
|
+
if self._session is not None:
|
|
134
|
+
await self._session.close()
|
|
135
|
+
self._session = None
|
|
136
|
+
|
|
137
|
+
async def commit(self) -> None:
|
|
138
|
+
"""Commit all pending database changes asynchronously.
|
|
139
|
+
|
|
140
|
+
Raises:
|
|
141
|
+
UnitOfWorkError: If committing transaction fails.
|
|
142
|
+
"""
|
|
143
|
+
if self._session is not None:
|
|
144
|
+
try:
|
|
145
|
+
await self._session.commit()
|
|
146
|
+
except SQLAlchemyError as exc:
|
|
147
|
+
await self._session.rollback()
|
|
148
|
+
raise UnitOfWorkError() from exc
|
|
149
|
+
|
|
150
|
+
async def commit_async(self) -> None:
|
|
151
|
+
"""Asynchronously commit all pending transactional changes."""
|
|
152
|
+
await self.commit()
|
|
153
|
+
|
|
154
|
+
async def rollback(self) -> None:
|
|
155
|
+
"""Roll back all pending database changes asynchronously."""
|
|
156
|
+
if self._session is not None:
|
|
157
|
+
with contextlib.suppress(SQLAlchemyError):
|
|
158
|
+
await self._session.rollback()
|
|
159
|
+
|
|
160
|
+
async def rollback_async(self) -> None:
|
|
161
|
+
"""Asynchronously roll back all pending transactional changes."""
|
|
162
|
+
await self.rollback()
|
|
163
|
+
|
|
164
|
+
@property
|
|
165
|
+
def session(self) -> AsyncSession:
|
|
166
|
+
"""Active AsyncSession in current transactional scope.
|
|
167
|
+
|
|
168
|
+
Returns:
|
|
169
|
+
Current AsyncSession.
|
|
170
|
+
|
|
171
|
+
Raises:
|
|
172
|
+
DatabaseError: If accessed outside of async transactional context.
|
|
173
|
+
"""
|
|
174
|
+
if self._session is None:
|
|
175
|
+
raise DatabaseError(
|
|
176
|
+
"AsyncUnitOfWork session is not active. Use within 'async with uow:' context."
|
|
177
|
+
)
|
|
178
|
+
return self._session
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
__all__ = [
|
|
182
|
+
"AsyncSqlAlchemyUnitOfWork",
|
|
183
|
+
"SqlAlchemyUnitOfWork",
|
|
184
|
+
]
|