python-hexagonal 0.1.1__py3-none-any.whl → 0.2.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.
- hexagonal/adapters/drivens/buses/base/event_bus.py +14 -2
- hexagonal/adapters/drivens/buses/base/query.py +15 -5
- hexagonal/adapters/drivens/buses/inmemory/event_bus.py +5 -2
- hexagonal/adapters/drivens/mappers.py +10 -1
- hexagonal/adapters/drivens/repository/base/__init__.py +4 -2
- hexagonal/adapters/drivens/repository/base/repository.py +35 -5
- hexagonal/adapters/drivens/repository/sqlalchemy/__init__.py +33 -0
- hexagonal/adapters/drivens/repository/sqlalchemy/datastore.py +288 -0
- hexagonal/adapters/drivens/repository/sqlalchemy/env_vars.py +22 -0
- hexagonal/adapters/drivens/repository/sqlalchemy/infrastructure.py +41 -0
- hexagonal/adapters/drivens/repository/sqlalchemy/models.py +160 -0
- hexagonal/adapters/drivens/repository/sqlalchemy/outbox.py +456 -0
- hexagonal/adapters/drivens/repository/sqlalchemy/repository.py +442 -0
- hexagonal/adapters/drivens/repository/sqlalchemy/unit_of_work.py +60 -0
- hexagonal/application/__init__.py +24 -3
- hexagonal/application/api.py +168 -19
- hexagonal/application/handlers.py +40 -11
- hexagonal/application/query.py +40 -26
- hexagonal/application/topics.py +45 -0
- hexagonal/domain/__init__.py +17 -0
- hexagonal/domain/aggregate.py +32 -10
- hexagonal/domain/base.py +10 -2
- hexagonal/domain/queries.py +17 -0
- hexagonal/entrypoints/sqlalchemy.py +146 -0
- hexagonal/integrations/__init__.py +1 -0
- hexagonal/integrations/sqlalchemy.py +49 -0
- hexagonal/ports/drivens/__init__.py +4 -0
- hexagonal/ports/drivens/buses.py +14 -1
- hexagonal/ports/drivens/repository.py +27 -5
- python_hexagonal-0.2.0.dist-info/METADATA +90 -0
- {python_hexagonal-0.1.1.dist-info → python_hexagonal-0.2.0.dist-info}/RECORD +32 -19
- {python_hexagonal-0.1.1.dist-info → python_hexagonal-0.2.0.dist-info}/WHEEL +2 -2
- python_hexagonal-0.1.1.dist-info/METADATA +0 -15
|
@@ -28,9 +28,9 @@ class HandlerError(Exception):
|
|
|
28
28
|
super().__init__(f"""
|
|
29
29
|
Error al Manejar Evento {evento.__class__.__name__}
|
|
30
30
|
handler: {
|
|
31
|
-
handler.__class__.__name__
|
|
31
|
+
handler.__class__.__name__ # pyright: ignore[reportUnknownMemberType]
|
|
32
32
|
if isinstance(handler, IMessageHandler)
|
|
33
|
-
else handler.__name__
|
|
33
|
+
else handler.__name__
|
|
34
34
|
}
|
|
35
35
|
evento: {evento.type}
|
|
36
36
|
datos: {evento.model_dump_json(indent=2)}
|
|
@@ -93,11 +93,23 @@ class BaseEventBus(IEventBus[TManager], MessageBus[TManager]):
|
|
|
93
93
|
if name not in self.wait_list:
|
|
94
94
|
self.wait_list[name] = []
|
|
95
95
|
self.wait_list[name].append(handler)
|
|
96
|
+
logger.debug(
|
|
97
|
+
" [DEBUG _wait_for] Added handler to wait_list[%s], now has %s handlers",
|
|
98
|
+
name,
|
|
99
|
+
len(self.wait_list[name]),
|
|
100
|
+
)
|
|
96
101
|
|
|
97
102
|
def _handle_wait_list(self, event: TEvento):
|
|
98
103
|
event_type = type(event)
|
|
99
104
|
key = self._get_key(event_type)
|
|
105
|
+
logger.debug(" [DEBUG _handle_wait_list] Publishing event type=%s", key)
|
|
100
106
|
wait_list = self.wait_list.get(key)
|
|
107
|
+
if wait_list:
|
|
108
|
+
logger.debug(
|
|
109
|
+
" [DEBUG _handle_wait_list] Found %s handlers", len(wait_list)
|
|
110
|
+
)
|
|
111
|
+
else:
|
|
112
|
+
logger.debug(" [DEBUG _handle_wait_list] No handlers registered!")
|
|
101
113
|
while wait_list:
|
|
102
114
|
if self.raise_error:
|
|
103
115
|
handler = wait_list.pop()
|
|
@@ -7,6 +7,8 @@ from hexagonal.domain import (
|
|
|
7
7
|
HandlerAlreadyRegistered,
|
|
8
8
|
HandlerNotRegistered,
|
|
9
9
|
Query,
|
|
10
|
+
QueryBase,
|
|
11
|
+
QueryOne,
|
|
10
12
|
QueryResult,
|
|
11
13
|
QueryResults,
|
|
12
14
|
TQuery,
|
|
@@ -22,12 +24,12 @@ class QueryBus(IQueryBus[TManager], Infrastructure):
|
|
|
22
24
|
self.handlers = {}
|
|
23
25
|
super().initialize(env)
|
|
24
26
|
|
|
25
|
-
def _get_name(self, query_type: Type[
|
|
27
|
+
def _get_name(self, query_type: Type[QueryBase[TView]]) -> str:
|
|
26
28
|
return get_topic(query_type)
|
|
27
29
|
|
|
28
30
|
def _get_handler(
|
|
29
|
-
self, query:
|
|
30
|
-
) -> IQueryHandler[TManager,
|
|
31
|
+
self, query: QueryBase[TView]
|
|
32
|
+
) -> IQueryHandler[TManager, QueryBase[TView], TView] | None:
|
|
31
33
|
name = self._get_name(query.__class__)
|
|
32
34
|
return self.handlers.get(name)
|
|
33
35
|
|
|
@@ -50,6 +52,14 @@ class QueryBus(IQueryBus[TManager], Infrastructure):
|
|
|
50
52
|
else:
|
|
51
53
|
raise HandlerNotRegistered(f"Query: {name}")
|
|
52
54
|
|
|
55
|
+
@overload
|
|
56
|
+
def get(
|
|
57
|
+
self,
|
|
58
|
+
query: QueryOne[TView],
|
|
59
|
+
*,
|
|
60
|
+
one: bool = False,
|
|
61
|
+
) -> QueryResult[TView]: ...
|
|
62
|
+
|
|
53
63
|
@overload
|
|
54
64
|
def get(self, query: Query[TView], *, one: Literal[True]) -> QueryResult[TView]: ...
|
|
55
65
|
|
|
@@ -63,7 +73,7 @@ class QueryBus(IQueryBus[TManager], Infrastructure):
|
|
|
63
73
|
|
|
64
74
|
def get(
|
|
65
75
|
self,
|
|
66
|
-
query: Query[TView],
|
|
76
|
+
query: Query[TView] | QueryOne[TView],
|
|
67
77
|
*,
|
|
68
78
|
one: bool = False,
|
|
69
79
|
) -> QueryResult[TView] | QueryResults[TView]:
|
|
@@ -73,7 +83,7 @@ class QueryBus(IQueryBus[TManager], Infrastructure):
|
|
|
73
83
|
if not handler:
|
|
74
84
|
raise HandlerNotRegistered(f"Query: {name}")
|
|
75
85
|
results = handler.get(query)
|
|
76
|
-
if not one:
|
|
86
|
+
if not (one or isinstance(query, QueryOne)):
|
|
77
87
|
return results
|
|
78
88
|
if len(results) == 0:
|
|
79
89
|
raise ValueError("No results found")
|
|
@@ -5,6 +5,8 @@ import threading
|
|
|
5
5
|
from queue import Empty, Queue
|
|
6
6
|
from typing import Mapping
|
|
7
7
|
|
|
8
|
+
from eventsourcing.utils import strtobool
|
|
9
|
+
|
|
8
10
|
from hexagonal.adapters.drivens.buses.base import BaseEventBus
|
|
9
11
|
from hexagonal.domain import CloudMessage, TEvent, TEvento
|
|
10
12
|
from hexagonal.ports.drivens import TManager
|
|
@@ -28,7 +30,8 @@ class InMemoryQueueEventBus(BaseEventBus[TManager]):
|
|
|
28
30
|
def initialize(self, env: Mapping[str, str]) -> None:
|
|
29
31
|
self.queue: Queue[CloudMessage[TEvento]] = Queue() # o Queue(maxsize=...)
|
|
30
32
|
self._stop = threading.Event()
|
|
31
|
-
|
|
33
|
+
daemon = strtobool(env.get("EVENT_BUS_WORKER_DAEMON", "true"))
|
|
34
|
+
self._worker = threading.Thread(target=self._worker_loop, daemon=daemon)
|
|
32
35
|
|
|
33
36
|
super().initialize(env)
|
|
34
37
|
|
|
@@ -41,8 +44,8 @@ class InMemoryQueueEventBus(BaseEventBus[TManager]):
|
|
|
41
44
|
return self._publish_messages(*events)
|
|
42
45
|
|
|
43
46
|
def _publish_message(self, message: CloudMessage[TEvento]) -> None:
|
|
44
|
-
super()._publish_message(message)
|
|
45
47
|
self.verify()
|
|
48
|
+
super()._publish_message(message)
|
|
46
49
|
self.queue.put(message)
|
|
47
50
|
# No llamamos consume() aquí: el worker se encarga.
|
|
48
51
|
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
from dataclasses import dataclass
|
|
2
|
+
from decimal import Decimal
|
|
2
3
|
from typing import Any, Hashable, cast
|
|
3
4
|
from uuid import UUID
|
|
4
5
|
|
|
@@ -119,9 +120,17 @@ class MessageMapper(Mapper[UUID]):
|
|
|
119
120
|
return cls.model_validate(event_state)
|
|
120
121
|
|
|
121
122
|
|
|
123
|
+
def default_orjson_value_serializer(obj: Any) -> Any:
|
|
124
|
+
if isinstance(obj, (UUID, Decimal)):
|
|
125
|
+
return str(obj)
|
|
126
|
+
if isinstance(obj, Inmutable):
|
|
127
|
+
return obj.model_dump(mode="json")
|
|
128
|
+
raise TypeError
|
|
129
|
+
|
|
130
|
+
|
|
122
131
|
class OrjsonTranscoder(Transcoder):
|
|
123
132
|
def encode(self, obj: Any) -> bytes:
|
|
124
|
-
return orjson.dumps(obj)
|
|
133
|
+
return orjson.dumps(obj, default=default_orjson_value_serializer)
|
|
125
134
|
|
|
126
135
|
def decode(self, data: bytes) -> Any:
|
|
127
136
|
return orjson.loads(data)
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
from .repository import (
|
|
2
2
|
BaseAggregateRepositoryAdapter,
|
|
3
|
+
BaseEntityRepositoryAdapter,
|
|
3
4
|
BaseRepositoryAdapter,
|
|
4
|
-
|
|
5
|
+
BaseSearchRepositoryAdapter,
|
|
5
6
|
)
|
|
6
7
|
from .unit_of_work import BaseUnitOfWork
|
|
7
8
|
|
|
@@ -9,5 +10,6 @@ __all__ = [
|
|
|
9
10
|
"BaseRepositoryAdapter",
|
|
10
11
|
"BaseAggregateRepositoryAdapter",
|
|
11
12
|
"BaseUnitOfWork",
|
|
12
|
-
"
|
|
13
|
+
"BaseEntityRepositoryAdapter",
|
|
14
|
+
"BaseSearchRepositoryAdapter",
|
|
13
15
|
]
|
|
@@ -1,12 +1,10 @@
|
|
|
1
1
|
# pyright: reportMissingTypeStubs=false, reportUnknownArgumentType=false, reportMissingParameterType=none, reportGeneralTypeIssues=none
|
|
2
2
|
|
|
3
3
|
from typing import (
|
|
4
|
-
Any,
|
|
5
4
|
ClassVar,
|
|
6
5
|
Dict,
|
|
7
6
|
Mapping,
|
|
8
7
|
Type,
|
|
9
|
-
TypeVar,
|
|
10
8
|
get_args,
|
|
11
9
|
get_origin,
|
|
12
10
|
)
|
|
@@ -16,16 +14,16 @@ from eventsourcing.persistence import Mapper
|
|
|
16
14
|
from eventsourcing.utils import Environment
|
|
17
15
|
|
|
18
16
|
from hexagonal.application import Infrastructure
|
|
19
|
-
from hexagonal.domain import
|
|
17
|
+
from hexagonal.domain import TAggregate, TEntity, TIdEntity, TQuery, TView
|
|
20
18
|
from hexagonal.ports.drivens import (
|
|
21
19
|
IAggregateRepository,
|
|
22
20
|
IBaseRepository,
|
|
21
|
+
IEntityRepository,
|
|
22
|
+
ISearchRepository,
|
|
23
23
|
IUnitOfWork,
|
|
24
24
|
TManager,
|
|
25
25
|
)
|
|
26
26
|
|
|
27
|
-
TAggregate = TypeVar("TAggregate", bound=AggregateRoot[Any, Any])
|
|
28
|
-
|
|
29
27
|
|
|
30
28
|
class BaseRepositoryAdapter(IBaseRepository[TManager], Infrastructure):
|
|
31
29
|
ENV: ClassVar[Dict[str, str]] = {}
|
|
@@ -83,3 +81,35 @@ class BaseAggregateRepositoryAdapter(
|
|
|
83
81
|
@property
|
|
84
82
|
def aggregate_name(self) -> str:
|
|
85
83
|
return self._type_of_aggregate.__name__
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
class BaseEntityRepositoryAdapter(
|
|
87
|
+
BaseRepositoryAdapter[TManager],
|
|
88
|
+
IEntityRepository[TManager, TEntity, TIdEntity],
|
|
89
|
+
):
|
|
90
|
+
_type_of_entity: Type[TEntity]
|
|
91
|
+
|
|
92
|
+
def __init_subclass__(cls) -> None:
|
|
93
|
+
super().__init_subclass__()
|
|
94
|
+
# Inspect generic base to find the concrete type argument
|
|
95
|
+
for base in getattr(cls, "__orig_bases__", []):
|
|
96
|
+
origin = get_origin(base)
|
|
97
|
+
if origin and issubclass(origin, BaseEntityRepositoryAdapter):
|
|
98
|
+
args = get_args(base)
|
|
99
|
+
if args:
|
|
100
|
+
cls._type_of_entity = args[0]
|
|
101
|
+
cls.NAME = cls._type_of_entity.__name__.upper()
|
|
102
|
+
|
|
103
|
+
def __init__(self, mapper: Mapper[UUID], connection_manager: TManager):
|
|
104
|
+
super().__init__(connection_manager)
|
|
105
|
+
self._mapper = mapper
|
|
106
|
+
|
|
107
|
+
@property
|
|
108
|
+
def entity_name(self) -> str:
|
|
109
|
+
return self._type_of_entity.__name__
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
class BaseSearchRepositoryAdapter(
|
|
113
|
+
BaseRepositoryAdapter[TManager],
|
|
114
|
+
ISearchRepository[TManager, TQuery, TView],
|
|
115
|
+
): ...
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
"""SQLAlchemy adapters for the repository pattern.
|
|
2
|
+
|
|
3
|
+
This module provides SQLAlchemy-based implementations of the repository,
|
|
4
|
+
outbox, inbox, and unit of work patterns. Supports multiple database
|
|
5
|
+
backends (PostgreSQL, MySQL, SQLite) through SQLAlchemy's abstraction layer.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from .datastore import SQLAlchemyConnectionContextManager, SQLAlchemyDatastore
|
|
9
|
+
from .infrastructure import SQLAlchemyInfrastructure
|
|
10
|
+
from .outbox import (
|
|
11
|
+
SQLAlchemyInboxRepository,
|
|
12
|
+
SQLAlchemyOutboxRepository,
|
|
13
|
+
SQLAlchemyPairInboxOutbox,
|
|
14
|
+
)
|
|
15
|
+
from .repository import (
|
|
16
|
+
SQLAlchemyEntityRepositoryAdapter,
|
|
17
|
+
SQLAlchemyRepositoryAdapter,
|
|
18
|
+
SQLAlchemySearchRepositoryAdapter,
|
|
19
|
+
)
|
|
20
|
+
from .unit_of_work import SQLAlchemyUnitOfWork
|
|
21
|
+
|
|
22
|
+
__all__ = [
|
|
23
|
+
"SQLAlchemyConnectionContextManager",
|
|
24
|
+
"SQLAlchemyDatastore",
|
|
25
|
+
"SQLAlchemyRepositoryAdapter",
|
|
26
|
+
"SQLAlchemyUnitOfWork",
|
|
27
|
+
"SQLAlchemyOutboxRepository",
|
|
28
|
+
"SQLAlchemyInboxRepository",
|
|
29
|
+
"SQLAlchemyInfrastructure",
|
|
30
|
+
"SQLAlchemyPairInboxOutbox",
|
|
31
|
+
"SQLAlchemyEntityRepositoryAdapter",
|
|
32
|
+
"SQLAlchemySearchRepositoryAdapter",
|
|
33
|
+
]
|
|
@@ -0,0 +1,288 @@
|
|
|
1
|
+
"""SQLAlchemy datastore and connection context manager."""
|
|
2
|
+
|
|
3
|
+
from collections.abc import Iterator, Mapping
|
|
4
|
+
from contextlib import contextmanager
|
|
5
|
+
from typing import Any, Optional
|
|
6
|
+
|
|
7
|
+
from eventsourcing.utils import strtobool
|
|
8
|
+
from sqlalchemy import Connection, Engine, create_engine, text
|
|
9
|
+
from sqlalchemy.pool import QueuePool, StaticPool
|
|
10
|
+
|
|
11
|
+
from hexagonal.ports.drivens.repository import IConnectionManager
|
|
12
|
+
|
|
13
|
+
from .env_vars import (
|
|
14
|
+
SQLALCHEMY_DATABASE_URL,
|
|
15
|
+
SQLALCHEMY_ECHO,
|
|
16
|
+
SQLALCHEMY_MAX_OVERFLOW,
|
|
17
|
+
SQLALCHEMY_POOL_PRE_PING,
|
|
18
|
+
SQLALCHEMY_POOL_RECYCLE,
|
|
19
|
+
SQLALCHEMY_POOL_SIZE,
|
|
20
|
+
SQLALCHEMY_POOL_TIMEOUT,
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class SQLAlchemyDatastore:
|
|
25
|
+
"""Core SQLAlchemy engine and connection management.
|
|
26
|
+
|
|
27
|
+
Wraps SQLAlchemy Engine with connection pooling configuration.
|
|
28
|
+
Provides context managers for connection and transaction handling.
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
def __init__(
|
|
32
|
+
self,
|
|
33
|
+
database_url: str,
|
|
34
|
+
*,
|
|
35
|
+
echo: bool = False,
|
|
36
|
+
pool_size: int = 5,
|
|
37
|
+
pool_timeout: int = 30,
|
|
38
|
+
pool_recycle: int = 3600,
|
|
39
|
+
pool_pre_ping: bool = True,
|
|
40
|
+
max_overflow: int = 10,
|
|
41
|
+
):
|
|
42
|
+
"""Initialize SQLAlchemy datastore.
|
|
43
|
+
|
|
44
|
+
Args:
|
|
45
|
+
database_url: SQLAlchemy database URL (e.g., postgresql://user:pass@host/db)
|
|
46
|
+
echo: Enable SQL statement logging
|
|
47
|
+
pool_size: Connection pool size (ignored for SQLite)
|
|
48
|
+
pool_timeout: Connection pool timeout in seconds
|
|
49
|
+
pool_recycle: Connection recycle time in seconds
|
|
50
|
+
pool_pre_ping: Pre-ping connections before use
|
|
51
|
+
max_overflow: Maximum overflow connections (ignored for SQLite)
|
|
52
|
+
"""
|
|
53
|
+
self._database_url = database_url
|
|
54
|
+
|
|
55
|
+
# SQLite uses StaticPool for single connection (avoids database lock issues)
|
|
56
|
+
# Other databases use QueuePool with configurable settings
|
|
57
|
+
is_sqlite = database_url.startswith("sqlite")
|
|
58
|
+
|
|
59
|
+
pool_class = StaticPool if is_sqlite else QueuePool
|
|
60
|
+
pool_kwargs: dict[Any, Any] = {}
|
|
61
|
+
|
|
62
|
+
if is_sqlite:
|
|
63
|
+
# For SQLite with StaticPool, we need connect_args
|
|
64
|
+
pool_kwargs["connect_args"] = {"check_same_thread": False}
|
|
65
|
+
else:
|
|
66
|
+
pool_kwargs.update(
|
|
67
|
+
{
|
|
68
|
+
"pool_size": pool_size,
|
|
69
|
+
"pool_timeout": pool_timeout,
|
|
70
|
+
"pool_recycle": pool_recycle,
|
|
71
|
+
"pool_pre_ping": pool_pre_ping,
|
|
72
|
+
"max_overflow": max_overflow,
|
|
73
|
+
}
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
self._engine: Engine = create_engine(
|
|
77
|
+
database_url,
|
|
78
|
+
echo=echo,
|
|
79
|
+
poolclass=pool_class,
|
|
80
|
+
**pool_kwargs,
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
@property
|
|
84
|
+
def engine(self) -> Engine:
|
|
85
|
+
"""Get the SQLAlchemy engine."""
|
|
86
|
+
return self._engine
|
|
87
|
+
|
|
88
|
+
@property
|
|
89
|
+
def database_url(self) -> str:
|
|
90
|
+
"""Get the database URL."""
|
|
91
|
+
return self._database_url
|
|
92
|
+
|
|
93
|
+
@contextmanager
|
|
94
|
+
def get_connection(self) -> Iterator[Connection]:
|
|
95
|
+
"""Get a database connection from the pool.
|
|
96
|
+
|
|
97
|
+
Yields:
|
|
98
|
+
SQLAlchemy Connection object
|
|
99
|
+
|
|
100
|
+
The connection is returned to the pool when the context exits.
|
|
101
|
+
"""
|
|
102
|
+
with self._engine.connect() as connection:
|
|
103
|
+
# Enable foreign key support for SQLite
|
|
104
|
+
if self._database_url.startswith("sqlite"):
|
|
105
|
+
connection.execute(text("PRAGMA foreign_keys = ON"))
|
|
106
|
+
yield connection
|
|
107
|
+
|
|
108
|
+
@contextmanager
|
|
109
|
+
def transaction(self, commit: bool = False) -> Iterator[Connection]:
|
|
110
|
+
"""Get a connection with transaction management.
|
|
111
|
+
|
|
112
|
+
Args:
|
|
113
|
+
commit: If True, commit the transaction on success
|
|
114
|
+
|
|
115
|
+
Yields:
|
|
116
|
+
SQLAlchemy Connection object within a transaction
|
|
117
|
+
|
|
118
|
+
Raises:
|
|
119
|
+
RuntimeError: If the transaction fails (wraps original exception)
|
|
120
|
+
"""
|
|
121
|
+
with self.get_connection() as connection:
|
|
122
|
+
trans = connection.begin()
|
|
123
|
+
try:
|
|
124
|
+
yield connection
|
|
125
|
+
if commit:
|
|
126
|
+
trans.commit()
|
|
127
|
+
else:
|
|
128
|
+
trans.rollback()
|
|
129
|
+
except Exception as exc:
|
|
130
|
+
trans.rollback()
|
|
131
|
+
raise RuntimeError("Transaction failed") from exc
|
|
132
|
+
|
|
133
|
+
def dispose(self) -> None:
|
|
134
|
+
"""Dispose of the engine and all connections."""
|
|
135
|
+
self._engine.dispose()
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
class SQLAlchemyConnectionContextManager(IConnectionManager):
|
|
139
|
+
"""Connection context manager implementing IConnectionManager interface.
|
|
140
|
+
|
|
141
|
+
Manages SQLAlchemy connection lifecycle for repositories.
|
|
142
|
+
Maintains a current connection reference for transaction coordination.
|
|
143
|
+
"""
|
|
144
|
+
|
|
145
|
+
def __init__(self, datastore: Optional[SQLAlchemyDatastore] = None):
|
|
146
|
+
"""Initialize connection context manager.
|
|
147
|
+
|
|
148
|
+
Args:
|
|
149
|
+
datastore: Optional SQLAlchemyDatastore instance.
|
|
150
|
+
If not provided, must call initialize() before use.
|
|
151
|
+
"""
|
|
152
|
+
self._datastore = datastore
|
|
153
|
+
self._current_connection: Optional[Connection] = None
|
|
154
|
+
self._connection_ctx: Optional[Any] = None
|
|
155
|
+
self._initialized = datastore is not None
|
|
156
|
+
|
|
157
|
+
@property
|
|
158
|
+
def initialized(self) -> bool:
|
|
159
|
+
"""Check if the connection manager is initialized."""
|
|
160
|
+
return self._initialized
|
|
161
|
+
|
|
162
|
+
@property
|
|
163
|
+
def datastore(self) -> SQLAlchemyDatastore:
|
|
164
|
+
"""Get the underlying datastore.
|
|
165
|
+
|
|
166
|
+
Raises:
|
|
167
|
+
RuntimeError: If datastore is not initialized
|
|
168
|
+
"""
|
|
169
|
+
if self._datastore is None:
|
|
170
|
+
raise RuntimeError("Datastore not initialized. Call initialize() first.")
|
|
171
|
+
return self._datastore
|
|
172
|
+
|
|
173
|
+
def initialize(self, env: Mapping[str, str]) -> None:
|
|
174
|
+
"""Initialize the datastore from environment variables.
|
|
175
|
+
|
|
176
|
+
Args:
|
|
177
|
+
env: Environment variables mapping
|
|
178
|
+
|
|
179
|
+
Environment variables:
|
|
180
|
+
SQLALCHEMY_DATABASE_URL: Database connection URL (required)
|
|
181
|
+
SQLALCHEMY_ECHO: Enable SQL logging (default: False)
|
|
182
|
+
SQLALCHEMY_POOL_SIZE: Pool size (default: 5)
|
|
183
|
+
SQLALCHEMY_POOL_TIMEOUT: Pool timeout (default: 30)
|
|
184
|
+
SQLALCHEMY_POOL_RECYCLE: Pool recycle time (default: 3600)
|
|
185
|
+
SQLALCHEMY_POOL_PRE_PING: Pre-ping connections (default: True)
|
|
186
|
+
SQLALCHEMY_MAX_OVERFLOW: Max overflow (default: 10)
|
|
187
|
+
"""
|
|
188
|
+
if self._datastore is not None:
|
|
189
|
+
self._initialized = True
|
|
190
|
+
return
|
|
191
|
+
|
|
192
|
+
database_url = env.get(SQLALCHEMY_DATABASE_URL)
|
|
193
|
+
if database_url is None:
|
|
194
|
+
raise ValueError(
|
|
195
|
+
f"Database configuration is missing. Set {SQLALCHEMY_DATABASE_URL}."
|
|
196
|
+
)
|
|
197
|
+
|
|
198
|
+
echo = strtobool(env.get(SQLALCHEMY_ECHO, "False"))
|
|
199
|
+
pool_size = int(env.get(SQLALCHEMY_POOL_SIZE, "5"))
|
|
200
|
+
pool_timeout = int(env.get(SQLALCHEMY_POOL_TIMEOUT, "30"))
|
|
201
|
+
pool_recycle = int(env.get(SQLALCHEMY_POOL_RECYCLE, "3600"))
|
|
202
|
+
pool_pre_ping = strtobool(env.get(SQLALCHEMY_POOL_PRE_PING, "True"))
|
|
203
|
+
max_overflow = int(env.get(SQLALCHEMY_MAX_OVERFLOW, "10"))
|
|
204
|
+
|
|
205
|
+
self._datastore = SQLAlchemyDatastore(
|
|
206
|
+
database_url,
|
|
207
|
+
echo=bool(echo),
|
|
208
|
+
pool_size=pool_size,
|
|
209
|
+
pool_timeout=pool_timeout,
|
|
210
|
+
pool_recycle=pool_recycle,
|
|
211
|
+
pool_pre_ping=bool(pool_pre_ping),
|
|
212
|
+
max_overflow=max_overflow,
|
|
213
|
+
)
|
|
214
|
+
self._initialized = True
|
|
215
|
+
|
|
216
|
+
def start_connection(self):
|
|
217
|
+
"""Start a new connection context.
|
|
218
|
+
|
|
219
|
+
Returns:
|
|
220
|
+
The connection context manager (already entered)
|
|
221
|
+
|
|
222
|
+
This manually enters the connection context and sets it as current.
|
|
223
|
+
Call the returned context's __exit__ to clean up.
|
|
224
|
+
"""
|
|
225
|
+
self._connection_ctx = self.datastore.get_connection()
|
|
226
|
+
self._current_connection = self._connection_ctx.__enter__()
|
|
227
|
+
return self._connection_ctx
|
|
228
|
+
|
|
229
|
+
@property
|
|
230
|
+
def current_connection(self) -> Connection:
|
|
231
|
+
"""Get the current active connection.
|
|
232
|
+
|
|
233
|
+
Returns:
|
|
234
|
+
The current SQLAlchemy Connection
|
|
235
|
+
|
|
236
|
+
Note:
|
|
237
|
+
If no connection exists or it's closed, a new one will be created.
|
|
238
|
+
#"""
|
|
239
|
+
if self._current_connection is None or self._current_connection.closed:
|
|
240
|
+
self._connection_ctx = self.datastore.get_connection()
|
|
241
|
+
self._current_connection = self._connection_ctx.__enter__()
|
|
242
|
+
if self._current_connection is None:
|
|
243
|
+
raise RuntimeError("Failed to establish a database connection.")
|
|
244
|
+
return self._current_connection
|
|
245
|
+
|
|
246
|
+
@current_connection.setter
|
|
247
|
+
def current_connection(self, connection: Optional[Connection]) -> None:
|
|
248
|
+
"""Set the current connection, cleaning up any existing one.
|
|
249
|
+
|
|
250
|
+
Args:
|
|
251
|
+
connection: The new connection or None to clear
|
|
252
|
+
"""
|
|
253
|
+
if self._connection_ctx is not None:
|
|
254
|
+
try:
|
|
255
|
+
self._connection_ctx.__exit__(None, None, None)
|
|
256
|
+
except Exception:
|
|
257
|
+
pass # Ignore errors during cleanup
|
|
258
|
+
self._connection_ctx = None
|
|
259
|
+
self._current_connection = connection
|
|
260
|
+
|
|
261
|
+
@contextmanager
|
|
262
|
+
def cursor(self, commit: bool = True) -> Iterator[Connection]:
|
|
263
|
+
"""Get a connection for executing statements.
|
|
264
|
+
|
|
265
|
+
This is provided for compatibility with the SQLite interface.
|
|
266
|
+
In SQLAlchemy, we use the connection directly instead of a cursor.
|
|
267
|
+
|
|
268
|
+
Handles closed connections by re-establishing.
|
|
269
|
+
|
|
270
|
+
Args:
|
|
271
|
+
commit: Whether to commit after the context exits (default: True)
|
|
272
|
+
|
|
273
|
+
Yields:
|
|
274
|
+
Current connection for executing statements
|
|
275
|
+
"""
|
|
276
|
+
try:
|
|
277
|
+
# Check if connection is usable
|
|
278
|
+
conn = self.current_connection
|
|
279
|
+
if conn.closed:
|
|
280
|
+
self._current_connection = None
|
|
281
|
+
conn = self.current_connection
|
|
282
|
+
yield conn
|
|
283
|
+
if commit:
|
|
284
|
+
conn.commit()
|
|
285
|
+
except Exception:
|
|
286
|
+
# Reset connection on error
|
|
287
|
+
self._current_connection = None
|
|
288
|
+
raise
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
"""Environment variable constants for SQLAlchemy adapter configuration."""
|
|
2
|
+
|
|
3
|
+
# Database connection URL (e.g., postgresql://user:pass@host/db, sqlite:///path.db)
|
|
4
|
+
SQLALCHEMY_DATABASE_URL = "SQLALCHEMY_DATABASE_URL"
|
|
5
|
+
|
|
6
|
+
# Enable SQL statement logging
|
|
7
|
+
SQLALCHEMY_ECHO = "SQLALCHEMY_ECHO"
|
|
8
|
+
|
|
9
|
+
# Connection pool size (default: 5)
|
|
10
|
+
SQLALCHEMY_POOL_SIZE = "SQLALCHEMY_POOL_SIZE"
|
|
11
|
+
|
|
12
|
+
# Connection pool timeout in seconds (default: 30)
|
|
13
|
+
SQLALCHEMY_POOL_TIMEOUT = "SQLALCHEMY_POOL_TIMEOUT"
|
|
14
|
+
|
|
15
|
+
# Connection recycle time in seconds (default: 3600)
|
|
16
|
+
SQLALCHEMY_POOL_RECYCLE = "SQLALCHEMY_POOL_RECYCLE"
|
|
17
|
+
|
|
18
|
+
# Pre-ping connections before use (default: True)
|
|
19
|
+
SQLALCHEMY_POOL_PRE_PING = "SQLALCHEMY_POOL_PRE_PING"
|
|
20
|
+
|
|
21
|
+
# Maximum overflow connections (default: 10)
|
|
22
|
+
SQLALCHEMY_MAX_OVERFLOW = "SQLALCHEMY_MAX_OVERFLOW"
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
"""SQLAlchemy infrastructure grouping."""
|
|
2
|
+
|
|
3
|
+
from hexagonal.adapters.drivens.mappers import MessageMapper
|
|
4
|
+
from hexagonal.application import ComposableInfrastructure
|
|
5
|
+
|
|
6
|
+
from .datastore import SQLAlchemyConnectionContextManager, SQLAlchemyDatastore
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class SQLAlchemyInfrastructure(ComposableInfrastructure):
|
|
10
|
+
"""Groups SQLAlchemy connection manager and mapper for dependency injection.
|
|
11
|
+
|
|
12
|
+
Provides a convenient way to initialize and access the core
|
|
13
|
+
SQLAlchemy infrastructure components needed by repositories.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
def __init__(
|
|
17
|
+
self,
|
|
18
|
+
mapper: MessageMapper,
|
|
19
|
+
datastore: SQLAlchemyDatastore | None = None,
|
|
20
|
+
):
|
|
21
|
+
"""Initialize SQLAlchemy infrastructure.
|
|
22
|
+
|
|
23
|
+
Args:
|
|
24
|
+
mapper: Message mapper for serialization/deserialization
|
|
25
|
+
datastore: Optional SQLAlchemyDatastore instance.
|
|
26
|
+
If not provided, connection_manager.initialize() must be called.
|
|
27
|
+
"""
|
|
28
|
+
self._datastore = datastore
|
|
29
|
+
self._mapper = mapper
|
|
30
|
+
self._connection_manager = SQLAlchemyConnectionContextManager(datastore)
|
|
31
|
+
super().__init__(self._connection_manager)
|
|
32
|
+
|
|
33
|
+
@property
|
|
34
|
+
def connection_manager(self) -> SQLAlchemyConnectionContextManager:
|
|
35
|
+
"""Get the connection context manager."""
|
|
36
|
+
return self._connection_manager
|
|
37
|
+
|
|
38
|
+
@property
|
|
39
|
+
def mapper(self) -> MessageMapper:
|
|
40
|
+
"""Get the message mapper."""
|
|
41
|
+
return self._mapper
|