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.
Files changed (33) hide show
  1. hexagonal/adapters/drivens/buses/base/event_bus.py +14 -2
  2. hexagonal/adapters/drivens/buses/base/query.py +15 -5
  3. hexagonal/adapters/drivens/buses/inmemory/event_bus.py +5 -2
  4. hexagonal/adapters/drivens/mappers.py +10 -1
  5. hexagonal/adapters/drivens/repository/base/__init__.py +4 -2
  6. hexagonal/adapters/drivens/repository/base/repository.py +35 -5
  7. hexagonal/adapters/drivens/repository/sqlalchemy/__init__.py +33 -0
  8. hexagonal/adapters/drivens/repository/sqlalchemy/datastore.py +288 -0
  9. hexagonal/adapters/drivens/repository/sqlalchemy/env_vars.py +22 -0
  10. hexagonal/adapters/drivens/repository/sqlalchemy/infrastructure.py +41 -0
  11. hexagonal/adapters/drivens/repository/sqlalchemy/models.py +160 -0
  12. hexagonal/adapters/drivens/repository/sqlalchemy/outbox.py +456 -0
  13. hexagonal/adapters/drivens/repository/sqlalchemy/repository.py +442 -0
  14. hexagonal/adapters/drivens/repository/sqlalchemy/unit_of_work.py +60 -0
  15. hexagonal/application/__init__.py +24 -3
  16. hexagonal/application/api.py +168 -19
  17. hexagonal/application/handlers.py +40 -11
  18. hexagonal/application/query.py +40 -26
  19. hexagonal/application/topics.py +45 -0
  20. hexagonal/domain/__init__.py +17 -0
  21. hexagonal/domain/aggregate.py +32 -10
  22. hexagonal/domain/base.py +10 -2
  23. hexagonal/domain/queries.py +17 -0
  24. hexagonal/entrypoints/sqlalchemy.py +146 -0
  25. hexagonal/integrations/__init__.py +1 -0
  26. hexagonal/integrations/sqlalchemy.py +49 -0
  27. hexagonal/ports/drivens/__init__.py +4 -0
  28. hexagonal/ports/drivens/buses.py +14 -1
  29. hexagonal/ports/drivens/repository.py +27 -5
  30. python_hexagonal-0.2.0.dist-info/METADATA +90 -0
  31. {python_hexagonal-0.1.1.dist-info → python_hexagonal-0.2.0.dist-info}/RECORD +32 -19
  32. {python_hexagonal-0.1.1.dist-info → python_hexagonal-0.2.0.dist-info}/WHEEL +2 -2
  33. 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__ # pyright: ignore[reportUnknownMemberType]
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[Query[TView]]) -> str:
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: Query[TView]
30
- ) -> IQueryHandler[TManager, Query[TView], TView] | None:
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
- self._worker = threading.Thread(target=self._worker_loop, daemon=True)
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
- TAggregate,
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
- "TAggregate",
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 AggregateRoot, TIdEntity
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