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.

@@ -0,0 +1,7 @@
1
+ from hexastack_db import adapters, domain, infra
2
+
3
+ __all__ = [
4
+ "adapters",
5
+ "domain",
6
+ "infra",
7
+ ]
@@ -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
+ ]