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.
- {python_hexagonal-0.1.0 → python_hexagonal-0.1.2}/PKG-INFO +2 -1
- {python_hexagonal-0.1.0 → python_hexagonal-0.1.2}/pyproject.toml +5 -1
- python_hexagonal-0.1.2/src/hexagonal/adapters/drivens/buses/__init__.py +0 -0
- {python_hexagonal-0.1.0 → python_hexagonal-0.1.2}/src/hexagonal/adapters/drivens/buses/base/event_bus.py +14 -2
- {python_hexagonal-0.1.0 → python_hexagonal-0.1.2}/src/hexagonal/adapters/drivens/mappers.py +8 -1
- python_hexagonal-0.1.2/src/hexagonal/adapters/drivens/repository/__init__.py +0 -0
- python_hexagonal-0.1.2/src/hexagonal/adapters/drivens/repository/sqlalchemy/__init__.py +27 -0
- python_hexagonal-0.1.2/src/hexagonal/adapters/drivens/repository/sqlalchemy/datastore.py +288 -0
- python_hexagonal-0.1.2/src/hexagonal/adapters/drivens/repository/sqlalchemy/env_vars.py +22 -0
- python_hexagonal-0.1.2/src/hexagonal/adapters/drivens/repository/sqlalchemy/infrastructure.py +41 -0
- python_hexagonal-0.1.2/src/hexagonal/adapters/drivens/repository/sqlalchemy/models.py +156 -0
- python_hexagonal-0.1.2/src/hexagonal/adapters/drivens/repository/sqlalchemy/outbox.py +456 -0
- python_hexagonal-0.1.2/src/hexagonal/adapters/drivens/repository/sqlalchemy/repository.py +324 -0
- python_hexagonal-0.1.2/src/hexagonal/adapters/drivens/repository/sqlalchemy/unit_of_work.py +60 -0
- {python_hexagonal-0.1.0 → python_hexagonal-0.1.2}/src/hexagonal/domain/aggregate.py +6 -0
- python_hexagonal-0.1.2/src/hexagonal/entrypoints/sqlalchemy.py +148 -0
- python_hexagonal-0.1.2/src/hexagonal/ports/__init__.py +0 -0
- python_hexagonal-0.1.2/src/hexagonal/py.typed +0 -0
- {python_hexagonal-0.1.0 → python_hexagonal-0.1.2}/README.md +0 -0
- {python_hexagonal-0.1.0 → python_hexagonal-0.1.2}/src/hexagonal/__init__.py +0 -0
- {python_hexagonal-0.1.0/src/hexagonal/ports → python_hexagonal-0.1.2/src/hexagonal/adapters}/__init__.py +0 -0
- /python_hexagonal-0.1.0/src/hexagonal/py.typed → /python_hexagonal-0.1.2/src/hexagonal/adapters/drivens/__init__.py +0 -0
- {python_hexagonal-0.1.0 → python_hexagonal-0.1.2}/src/hexagonal/adapters/drivens/buses/base/__init__.py +0 -0
- {python_hexagonal-0.1.0 → python_hexagonal-0.1.2}/src/hexagonal/adapters/drivens/buses/base/command_bus.py +0 -0
- {python_hexagonal-0.1.0 → python_hexagonal-0.1.2}/src/hexagonal/adapters/drivens/buses/base/infrastructure.py +0 -0
- {python_hexagonal-0.1.0 → python_hexagonal-0.1.2}/src/hexagonal/adapters/drivens/buses/base/message_bus.py +0 -0
- {python_hexagonal-0.1.0 → python_hexagonal-0.1.2}/src/hexagonal/adapters/drivens/buses/base/query.py +0 -0
- {python_hexagonal-0.1.0 → python_hexagonal-0.1.2}/src/hexagonal/adapters/drivens/buses/base/utils.py +0 -0
- {python_hexagonal-0.1.0 → python_hexagonal-0.1.2}/src/hexagonal/adapters/drivens/buses/inmemory/__init__.py +0 -0
- {python_hexagonal-0.1.0 → python_hexagonal-0.1.2}/src/hexagonal/adapters/drivens/buses/inmemory/command_bus.py +0 -0
- {python_hexagonal-0.1.0 → python_hexagonal-0.1.2}/src/hexagonal/adapters/drivens/buses/inmemory/event_bus.py +0 -0
- {python_hexagonal-0.1.0 → python_hexagonal-0.1.2}/src/hexagonal/adapters/drivens/buses/inmemory/infra.py +0 -0
- {python_hexagonal-0.1.0 → python_hexagonal-0.1.2}/src/hexagonal/adapters/drivens/repository/base/__init__.py +0 -0
- {python_hexagonal-0.1.0 → python_hexagonal-0.1.2}/src/hexagonal/adapters/drivens/repository/base/repository.py +0 -0
- {python_hexagonal-0.1.0 → python_hexagonal-0.1.2}/src/hexagonal/adapters/drivens/repository/base/unit_of_work.py +0 -0
- {python_hexagonal-0.1.0 → python_hexagonal-0.1.2}/src/hexagonal/adapters/drivens/repository/sqlite/__init__.py +0 -0
- {python_hexagonal-0.1.0 → python_hexagonal-0.1.2}/src/hexagonal/adapters/drivens/repository/sqlite/datastore.py +0 -0
- {python_hexagonal-0.1.0 → python_hexagonal-0.1.2}/src/hexagonal/adapters/drivens/repository/sqlite/env_vars.py +0 -0
- {python_hexagonal-0.1.0 → python_hexagonal-0.1.2}/src/hexagonal/adapters/drivens/repository/sqlite/infrastructure.py +0 -0
- {python_hexagonal-0.1.0 → python_hexagonal-0.1.2}/src/hexagonal/adapters/drivens/repository/sqlite/outbox.py +0 -0
- {python_hexagonal-0.1.0 → python_hexagonal-0.1.2}/src/hexagonal/adapters/drivens/repository/sqlite/repository.py +0 -0
- {python_hexagonal-0.1.0 → python_hexagonal-0.1.2}/src/hexagonal/adapters/drivens/repository/sqlite/unit_of_work.py +0 -0
- {python_hexagonal-0.1.0 → python_hexagonal-0.1.2}/src/hexagonal/adapters/drivers/__init__.py +0 -0
- {python_hexagonal-0.1.0 → python_hexagonal-0.1.2}/src/hexagonal/adapters/drivers/app.py +0 -0
- {python_hexagonal-0.1.0 → python_hexagonal-0.1.2}/src/hexagonal/application/__init__.py +0 -0
- {python_hexagonal-0.1.0 → python_hexagonal-0.1.2}/src/hexagonal/application/api.py +0 -0
- {python_hexagonal-0.1.0 → python_hexagonal-0.1.2}/src/hexagonal/application/app.py +0 -0
- {python_hexagonal-0.1.0 → python_hexagonal-0.1.2}/src/hexagonal/application/bus_app.py +0 -0
- {python_hexagonal-0.1.0 → python_hexagonal-0.1.2}/src/hexagonal/application/handlers.py +0 -0
- {python_hexagonal-0.1.0 → python_hexagonal-0.1.2}/src/hexagonal/application/infrastructure.py +0 -0
- {python_hexagonal-0.1.0 → python_hexagonal-0.1.2}/src/hexagonal/application/query.py +0 -0
- {python_hexagonal-0.1.0 → python_hexagonal-0.1.2}/src/hexagonal/domain/__init__.py +0 -0
- {python_hexagonal-0.1.0 → python_hexagonal-0.1.2}/src/hexagonal/domain/base.py +0 -0
- {python_hexagonal-0.1.0 → python_hexagonal-0.1.2}/src/hexagonal/domain/exceptions.py +0 -0
- {python_hexagonal-0.1.0 → python_hexagonal-0.1.2}/src/hexagonal/entrypoints/__init__.py +0 -0
- {python_hexagonal-0.1.0 → python_hexagonal-0.1.2}/src/hexagonal/entrypoints/app.py +0 -0
- {python_hexagonal-0.1.0 → python_hexagonal-0.1.2}/src/hexagonal/entrypoints/base.py +0 -0
- {python_hexagonal-0.1.0 → python_hexagonal-0.1.2}/src/hexagonal/entrypoints/bus.py +0 -0
- {python_hexagonal-0.1.0 → python_hexagonal-0.1.2}/src/hexagonal/entrypoints/sqlite.py +0 -0
- {python_hexagonal-0.1.0 → python_hexagonal-0.1.2}/src/hexagonal/ports/drivens/__init__.py +0 -0
- {python_hexagonal-0.1.0 → python_hexagonal-0.1.2}/src/hexagonal/ports/drivens/application.py +0 -0
- {python_hexagonal-0.1.0 → python_hexagonal-0.1.2}/src/hexagonal/ports/drivens/buses.py +0 -0
- {python_hexagonal-0.1.0 → python_hexagonal-0.1.2}/src/hexagonal/ports/drivens/infrastructure.py +0 -0
- {python_hexagonal-0.1.0 → python_hexagonal-0.1.2}/src/hexagonal/ports/drivens/repository.py +0 -0
- {python_hexagonal-0.1.0 → python_hexagonal-0.1.2}/src/hexagonal/ports/drivers/__init__.py +0 -0
- {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.
|
|
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.
|
|
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
|
+
]
|
|
File without changes
|
|
@@ -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()
|
|
@@ -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)
|
|
File without changes
|
|
@@ -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
|
+
)
|