python-hexagonal 0.1.0__tar.gz → 0.1.2__tar.gz

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 (66) hide show
  1. {python_hexagonal-0.1.0 → python_hexagonal-0.1.2}/PKG-INFO +2 -1
  2. {python_hexagonal-0.1.0 → python_hexagonal-0.1.2}/pyproject.toml +5 -1
  3. python_hexagonal-0.1.2/src/hexagonal/adapters/drivens/buses/__init__.py +0 -0
  4. {python_hexagonal-0.1.0 → python_hexagonal-0.1.2}/src/hexagonal/adapters/drivens/buses/base/event_bus.py +14 -2
  5. {python_hexagonal-0.1.0 → python_hexagonal-0.1.2}/src/hexagonal/adapters/drivens/mappers.py +8 -1
  6. python_hexagonal-0.1.2/src/hexagonal/adapters/drivens/repository/__init__.py +0 -0
  7. python_hexagonal-0.1.2/src/hexagonal/adapters/drivens/repository/sqlalchemy/__init__.py +27 -0
  8. python_hexagonal-0.1.2/src/hexagonal/adapters/drivens/repository/sqlalchemy/datastore.py +288 -0
  9. python_hexagonal-0.1.2/src/hexagonal/adapters/drivens/repository/sqlalchemy/env_vars.py +22 -0
  10. python_hexagonal-0.1.2/src/hexagonal/adapters/drivens/repository/sqlalchemy/infrastructure.py +41 -0
  11. python_hexagonal-0.1.2/src/hexagonal/adapters/drivens/repository/sqlalchemy/models.py +156 -0
  12. python_hexagonal-0.1.2/src/hexagonal/adapters/drivens/repository/sqlalchemy/outbox.py +456 -0
  13. python_hexagonal-0.1.2/src/hexagonal/adapters/drivens/repository/sqlalchemy/repository.py +324 -0
  14. python_hexagonal-0.1.2/src/hexagonal/adapters/drivens/repository/sqlalchemy/unit_of_work.py +60 -0
  15. {python_hexagonal-0.1.0 → python_hexagonal-0.1.2}/src/hexagonal/domain/aggregate.py +6 -0
  16. python_hexagonal-0.1.2/src/hexagonal/entrypoints/sqlalchemy.py +148 -0
  17. python_hexagonal-0.1.2/src/hexagonal/ports/__init__.py +0 -0
  18. python_hexagonal-0.1.2/src/hexagonal/py.typed +0 -0
  19. {python_hexagonal-0.1.0 → python_hexagonal-0.1.2}/README.md +0 -0
  20. {python_hexagonal-0.1.0 → python_hexagonal-0.1.2}/src/hexagonal/__init__.py +0 -0
  21. {python_hexagonal-0.1.0/src/hexagonal/ports → python_hexagonal-0.1.2/src/hexagonal/adapters}/__init__.py +0 -0
  22. /python_hexagonal-0.1.0/src/hexagonal/py.typed → /python_hexagonal-0.1.2/src/hexagonal/adapters/drivens/__init__.py +0 -0
  23. {python_hexagonal-0.1.0 → python_hexagonal-0.1.2}/src/hexagonal/adapters/drivens/buses/base/__init__.py +0 -0
  24. {python_hexagonal-0.1.0 → python_hexagonal-0.1.2}/src/hexagonal/adapters/drivens/buses/base/command_bus.py +0 -0
  25. {python_hexagonal-0.1.0 → python_hexagonal-0.1.2}/src/hexagonal/adapters/drivens/buses/base/infrastructure.py +0 -0
  26. {python_hexagonal-0.1.0 → python_hexagonal-0.1.2}/src/hexagonal/adapters/drivens/buses/base/message_bus.py +0 -0
  27. {python_hexagonal-0.1.0 → python_hexagonal-0.1.2}/src/hexagonal/adapters/drivens/buses/base/query.py +0 -0
  28. {python_hexagonal-0.1.0 → python_hexagonal-0.1.2}/src/hexagonal/adapters/drivens/buses/base/utils.py +0 -0
  29. {python_hexagonal-0.1.0 → python_hexagonal-0.1.2}/src/hexagonal/adapters/drivens/buses/inmemory/__init__.py +0 -0
  30. {python_hexagonal-0.1.0 → python_hexagonal-0.1.2}/src/hexagonal/adapters/drivens/buses/inmemory/command_bus.py +0 -0
  31. {python_hexagonal-0.1.0 → python_hexagonal-0.1.2}/src/hexagonal/adapters/drivens/buses/inmemory/event_bus.py +0 -0
  32. {python_hexagonal-0.1.0 → python_hexagonal-0.1.2}/src/hexagonal/adapters/drivens/buses/inmemory/infra.py +0 -0
  33. {python_hexagonal-0.1.0 → python_hexagonal-0.1.2}/src/hexagonal/adapters/drivens/repository/base/__init__.py +0 -0
  34. {python_hexagonal-0.1.0 → python_hexagonal-0.1.2}/src/hexagonal/adapters/drivens/repository/base/repository.py +0 -0
  35. {python_hexagonal-0.1.0 → python_hexagonal-0.1.2}/src/hexagonal/adapters/drivens/repository/base/unit_of_work.py +0 -0
  36. {python_hexagonal-0.1.0 → python_hexagonal-0.1.2}/src/hexagonal/adapters/drivens/repository/sqlite/__init__.py +0 -0
  37. {python_hexagonal-0.1.0 → python_hexagonal-0.1.2}/src/hexagonal/adapters/drivens/repository/sqlite/datastore.py +0 -0
  38. {python_hexagonal-0.1.0 → python_hexagonal-0.1.2}/src/hexagonal/adapters/drivens/repository/sqlite/env_vars.py +0 -0
  39. {python_hexagonal-0.1.0 → python_hexagonal-0.1.2}/src/hexagonal/adapters/drivens/repository/sqlite/infrastructure.py +0 -0
  40. {python_hexagonal-0.1.0 → python_hexagonal-0.1.2}/src/hexagonal/adapters/drivens/repository/sqlite/outbox.py +0 -0
  41. {python_hexagonal-0.1.0 → python_hexagonal-0.1.2}/src/hexagonal/adapters/drivens/repository/sqlite/repository.py +0 -0
  42. {python_hexagonal-0.1.0 → python_hexagonal-0.1.2}/src/hexagonal/adapters/drivens/repository/sqlite/unit_of_work.py +0 -0
  43. {python_hexagonal-0.1.0 → python_hexagonal-0.1.2}/src/hexagonal/adapters/drivers/__init__.py +0 -0
  44. {python_hexagonal-0.1.0 → python_hexagonal-0.1.2}/src/hexagonal/adapters/drivers/app.py +0 -0
  45. {python_hexagonal-0.1.0 → python_hexagonal-0.1.2}/src/hexagonal/application/__init__.py +0 -0
  46. {python_hexagonal-0.1.0 → python_hexagonal-0.1.2}/src/hexagonal/application/api.py +0 -0
  47. {python_hexagonal-0.1.0 → python_hexagonal-0.1.2}/src/hexagonal/application/app.py +0 -0
  48. {python_hexagonal-0.1.0 → python_hexagonal-0.1.2}/src/hexagonal/application/bus_app.py +0 -0
  49. {python_hexagonal-0.1.0 → python_hexagonal-0.1.2}/src/hexagonal/application/handlers.py +0 -0
  50. {python_hexagonal-0.1.0 → python_hexagonal-0.1.2}/src/hexagonal/application/infrastructure.py +0 -0
  51. {python_hexagonal-0.1.0 → python_hexagonal-0.1.2}/src/hexagonal/application/query.py +0 -0
  52. {python_hexagonal-0.1.0 → python_hexagonal-0.1.2}/src/hexagonal/domain/__init__.py +0 -0
  53. {python_hexagonal-0.1.0 → python_hexagonal-0.1.2}/src/hexagonal/domain/base.py +0 -0
  54. {python_hexagonal-0.1.0 → python_hexagonal-0.1.2}/src/hexagonal/domain/exceptions.py +0 -0
  55. {python_hexagonal-0.1.0 → python_hexagonal-0.1.2}/src/hexagonal/entrypoints/__init__.py +0 -0
  56. {python_hexagonal-0.1.0 → python_hexagonal-0.1.2}/src/hexagonal/entrypoints/app.py +0 -0
  57. {python_hexagonal-0.1.0 → python_hexagonal-0.1.2}/src/hexagonal/entrypoints/base.py +0 -0
  58. {python_hexagonal-0.1.0 → python_hexagonal-0.1.2}/src/hexagonal/entrypoints/bus.py +0 -0
  59. {python_hexagonal-0.1.0 → python_hexagonal-0.1.2}/src/hexagonal/entrypoints/sqlite.py +0 -0
  60. {python_hexagonal-0.1.0 → python_hexagonal-0.1.2}/src/hexagonal/ports/drivens/__init__.py +0 -0
  61. {python_hexagonal-0.1.0 → python_hexagonal-0.1.2}/src/hexagonal/ports/drivens/application.py +0 -0
  62. {python_hexagonal-0.1.0 → python_hexagonal-0.1.2}/src/hexagonal/ports/drivens/buses.py +0 -0
  63. {python_hexagonal-0.1.0 → python_hexagonal-0.1.2}/src/hexagonal/ports/drivens/infrastructure.py +0 -0
  64. {python_hexagonal-0.1.0 → python_hexagonal-0.1.2}/src/hexagonal/ports/drivens/repository.py +0 -0
  65. {python_hexagonal-0.1.0 → python_hexagonal-0.1.2}/src/hexagonal/ports/drivers/__init__.py +0 -0
  66. {python_hexagonal-0.1.0 → python_hexagonal-0.1.2}/src/hexagonal/ports/drivers/app.py +0 -0
@@ -1,12 +1,13 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: python-hexagonal
3
- Version: 0.1.0
3
+ Version: 0.1.2
4
4
  Summary: Framework to build hexagonal architecture applications in Python.
5
5
  Author: jose-matos-9281
6
6
  Author-email: jose-matos-9281 <58991817+jose-matos-9281@users.noreply.github.com>
7
7
  Requires-Dist: eventsourcing>=9.4.6
8
8
  Requires-Dist: orjson>=3.11.5
9
9
  Requires-Dist: pydantic>=2.12.5
10
+ Requires-Dist: sqlalchemy>=2.0.45
10
11
  Requires-Dist: uuid6>=2025.0.1
11
12
  Requires-Python: >=3.12
12
13
  Description-Content-Type: text/markdown
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "python-hexagonal"
3
- version = "0.1.0"
3
+ version = "0.1.2"
4
4
  description = "Framework to build hexagonal architecture applications in Python."
5
5
  readme = "README.md"
6
6
  authors = [
@@ -11,6 +11,7 @@ dependencies = [
11
11
  "eventsourcing>=9.4.6",
12
12
  "orjson>=3.11.5",
13
13
  "pydantic>=2.12.5",
14
+ "sqlalchemy>=2.0.45",
14
15
  "uuid6>=2025.0.1",
15
16
  ]
16
17
 
@@ -29,3 +30,6 @@ fixable = ["I", "E", "F", "B"]
29
30
  dev = [
30
31
  "pytest>=9.0.2",
31
32
  ]
33
+ sqlalchemy = [
34
+ "sqlalchemy>=2.0.45",
35
+ ]
@@ -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()
@@ -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,15 @@ 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
+ raise TypeError
127
+
128
+
122
129
  class OrjsonTranscoder(Transcoder):
123
130
  def encode(self, obj: Any) -> bytes:
124
- return orjson.dumps(obj)
131
+ return orjson.dumps(obj, default=default_orjson_value_serializer)
125
132
 
126
133
  def decode(self, data: bytes) -> Any:
127
134
  return orjson.loads(data)
@@ -0,0 +1,27 @@
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 SQLAlchemyRepositoryAdapter
16
+ from .unit_of_work import SQLAlchemyUnitOfWork
17
+
18
+ __all__ = [
19
+ "SQLAlchemyConnectionContextManager",
20
+ "SQLAlchemyDatastore",
21
+ "SQLAlchemyRepositoryAdapter",
22
+ "SQLAlchemyUnitOfWork",
23
+ "SQLAlchemyOutboxRepository",
24
+ "SQLAlchemyInboxRepository",
25
+ "SQLAlchemyInfrastructure",
26
+ "SQLAlchemyPairInboxOutbox",
27
+ ]
@@ -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
@@ -0,0 +1,156 @@
1
+ """SQLAlchemy table definitions for aggregates, events, outbox and inbox."""
2
+
3
+ from sqlalchemy import (
4
+ Column,
5
+ DateTime,
6
+ Index,
7
+ Integer,
8
+ LargeBinary,
9
+ MetaData,
10
+ PrimaryKeyConstraint,
11
+ String,
12
+ Table,
13
+ Text,
14
+ )
15
+
16
+ # Global metadata for table definitions
17
+ metadata = MetaData()
18
+
19
+
20
+ def _get_table_key(table_name: str, schema: str | None = None) -> str:
21
+ """Get the key used by SQLAlchemy metadata to store the table."""
22
+ if schema:
23
+ return f"{schema}.{table_name}"
24
+ return table_name
25
+
26
+
27
+ def create_aggregates_table(table_name: str, schema: str | None = None) -> Table:
28
+ """Create the aggregates snapshots table definition.
29
+
30
+ Args:
31
+ table_name: Base name for the table (will be prefixed with 'aggregates_')
32
+ schema: Optional database schema name
33
+
34
+ Returns:
35
+ SQLAlchemy Table object for aggregate snapshots
36
+ """
37
+ full_table_name = f"aggregates_{table_name}"
38
+ table_key = _get_table_key(full_table_name, schema)
39
+
40
+ # Return existing table if already defined
41
+ if table_key in metadata.tables:
42
+ return metadata.tables[table_key]
43
+
44
+ return Table(
45
+ full_table_name,
46
+ metadata,
47
+ Column("originator_id", String(36), nullable=False),
48
+ Column("aggregate_name", Text, nullable=False),
49
+ Column("originator_version", Integer, nullable=False),
50
+ Column("topic", Text, nullable=False),
51
+ Column("state", LargeBinary, nullable=False),
52
+ Column("timestamp", DateTime(timezone=True), nullable=False),
53
+ PrimaryKeyConstraint("originator_id", "aggregate_name"),
54
+ schema=schema,
55
+ )
56
+
57
+
58
+ def create_events_table(table_name: str, schema: str | None = None) -> Table:
59
+ """Create the aggregates events history table definition.
60
+
61
+ Args:
62
+ table_name: Base name for the table (prefixed with 'aggregates_',
63
+ suffixed with '_events')
64
+ schema: Optional database schema name
65
+
66
+ Returns:
67
+ SQLAlchemy Table object for aggregate events history
68
+ """
69
+ full_table_name = f"aggregates_{table_name}_events"
70
+ table_key = _get_table_key(full_table_name, schema)
71
+
72
+ # Return existing table if already defined
73
+ if table_key in metadata.tables:
74
+ return metadata.tables[table_key]
75
+
76
+ return Table(
77
+ full_table_name,
78
+ metadata,
79
+ Column("originator_id", String(36), nullable=False),
80
+ Column("aggregate_name", Text, nullable=False),
81
+ Column("originator_version", Integer, nullable=False),
82
+ Column("topic", Text, nullable=False),
83
+ Column("state", LargeBinary, nullable=False),
84
+ Column("timestamp", DateTime(timezone=True), nullable=False),
85
+ PrimaryKeyConstraint("originator_id", "aggregate_name", "originator_version"),
86
+ schema=schema,
87
+ )
88
+
89
+
90
+ def create_outbox_table(table_name: str = "outbox", schema: str | None = None) -> Table:
91
+ """Create the outbox table definition.
92
+
93
+ Args:
94
+ table_name: Name for the outbox table (default: 'outbox')
95
+ schema: Optional database schema name
96
+
97
+ Returns:
98
+ SQLAlchemy Table object for outbox messages
99
+ """
100
+ table_key = _get_table_key(table_name, schema)
101
+
102
+ # Return existing table if already defined
103
+ if table_key in metadata.tables:
104
+ return metadata.tables[table_key]
105
+
106
+ return Table(
107
+ table_name,
108
+ metadata,
109
+ Column("message_id", String(36), primary_key=True),
110
+ Column("topic", Text, nullable=False),
111
+ Column("message", LargeBinary, nullable=False),
112
+ Column("published_at", DateTime(timezone=True), nullable=True),
113
+ Column("failed_at", DateTime(timezone=True), nullable=True),
114
+ Column("error", Text, nullable=True),
115
+ Column("retry_count", Integer, nullable=False, default=0),
116
+ Column(
117
+ "created_at",
118
+ DateTime(timezone=True),
119
+ nullable=False,
120
+ ),
121
+ Index(f"idx_{table_name}_published", "published_at"),
122
+ Index(f"idx_{table_name}_topic", "topic"),
123
+ schema=schema,
124
+ )
125
+
126
+
127
+ def create_inbox_table(table_name: str = "inbox", schema: str | None = None) -> Table:
128
+ """Create the inbox table definition.
129
+
130
+ Args:
131
+ table_name: Name for the inbox table (default: 'inbox')
132
+ schema: Optional database schema name
133
+
134
+ Returns:
135
+ SQLAlchemy Table object for inbox messages
136
+ """
137
+ table_key = _get_table_key(table_name, schema)
138
+
139
+ # Return existing table if already defined
140
+ if table_key in metadata.tables:
141
+ return metadata.tables[table_key]
142
+
143
+ return Table(
144
+ table_name,
145
+ metadata,
146
+ Column("message_id", String(36), nullable=False),
147
+ Column("handler", Text, nullable=False),
148
+ Column("received_at", DateTime(timezone=True), nullable=False),
149
+ Column("processed_at", DateTime(timezone=True), nullable=True),
150
+ Column("error", Text, nullable=True),
151
+ Column("retry_count", Integer, nullable=False, default=0),
152
+ Column("failed_at", DateTime(timezone=True), nullable=True),
153
+ PrimaryKeyConstraint("message_id", "handler"),
154
+ Index(f"idx_{table_name}_processed", "processed_at"),
155
+ schema=schema,
156
+ )