python-hexagonal 0.1.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/__init__.py +2 -0
- hexagonal/adapters/drivens/buses/base/__init__.py +15 -0
- hexagonal/adapters/drivens/buses/base/command_bus.py +69 -0
- hexagonal/adapters/drivens/buses/base/event_bus.py +160 -0
- hexagonal/adapters/drivens/buses/base/infrastructure.py +38 -0
- hexagonal/adapters/drivens/buses/base/message_bus.py +73 -0
- hexagonal/adapters/drivens/buses/base/query.py +82 -0
- hexagonal/adapters/drivens/buses/base/utils.py +1 -0
- hexagonal/adapters/drivens/buses/inmemory/__init__.py +12 -0
- hexagonal/adapters/drivens/buses/inmemory/command_bus.py +70 -0
- hexagonal/adapters/drivens/buses/inmemory/event_bus.py +69 -0
- hexagonal/adapters/drivens/buses/inmemory/infra.py +49 -0
- hexagonal/adapters/drivens/mappers.py +127 -0
- hexagonal/adapters/drivens/repository/base/__init__.py +13 -0
- hexagonal/adapters/drivens/repository/base/repository.py +85 -0
- hexagonal/adapters/drivens/repository/base/unit_of_work.py +75 -0
- hexagonal/adapters/drivens/repository/sqlite/__init__.py +18 -0
- hexagonal/adapters/drivens/repository/sqlite/datastore.py +197 -0
- hexagonal/adapters/drivens/repository/sqlite/env_vars.py +2 -0
- hexagonal/adapters/drivens/repository/sqlite/infrastructure.py +20 -0
- hexagonal/adapters/drivens/repository/sqlite/outbox.py +405 -0
- hexagonal/adapters/drivens/repository/sqlite/repository.py +286 -0
- hexagonal/adapters/drivens/repository/sqlite/unit_of_work.py +25 -0
- hexagonal/adapters/drivers/__init__.py +5 -0
- hexagonal/adapters/drivers/app.py +38 -0
- hexagonal/application/__init__.py +29 -0
- hexagonal/application/api.py +61 -0
- hexagonal/application/app.py +76 -0
- hexagonal/application/bus_app.py +70 -0
- hexagonal/application/handlers.py +107 -0
- hexagonal/application/infrastructure.py +64 -0
- hexagonal/application/query.py +71 -0
- hexagonal/domain/__init__.py +77 -0
- hexagonal/domain/aggregate.py +159 -0
- hexagonal/domain/base.py +169 -0
- hexagonal/domain/exceptions.py +38 -0
- hexagonal/entrypoints/__init__.py +4 -0
- hexagonal/entrypoints/app.py +53 -0
- hexagonal/entrypoints/base.py +105 -0
- hexagonal/entrypoints/bus.py +68 -0
- hexagonal/entrypoints/sqlite.py +49 -0
- hexagonal/ports/__init__.py +0 -0
- hexagonal/ports/drivens/__init__.py +43 -0
- hexagonal/ports/drivens/application.py +35 -0
- hexagonal/ports/drivens/buses.py +148 -0
- hexagonal/ports/drivens/infrastructure.py +19 -0
- hexagonal/ports/drivens/repository.py +152 -0
- hexagonal/ports/drivers/__init__.py +3 -0
- hexagonal/ports/drivers/app.py +58 -0
- hexagonal/py.typed +0 -0
- python_hexagonal-0.1.0.dist-info/METADATA +15 -0
- python_hexagonal-0.1.0.dist-info/RECORD +53 -0
- python_hexagonal-0.1.0.dist-info/WHEEL +4 -0
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
from dataclasses import dataclass
|
|
2
|
+
from typing import Any, Hashable, cast
|
|
3
|
+
from uuid import UUID
|
|
4
|
+
|
|
5
|
+
import orjson
|
|
6
|
+
from eventsourcing.domain import DomainEventProtocol
|
|
7
|
+
from eventsourcing.persistence import (
|
|
8
|
+
Mapper,
|
|
9
|
+
MapperDeserialisationError,
|
|
10
|
+
StoredEvent,
|
|
11
|
+
Transcoder,
|
|
12
|
+
find_id_convertor,
|
|
13
|
+
)
|
|
14
|
+
from eventsourcing.utils import get_topic, resolve_topic
|
|
15
|
+
|
|
16
|
+
from hexagonal.domain import (
|
|
17
|
+
CloudMessage,
|
|
18
|
+
Command,
|
|
19
|
+
DomainEvent,
|
|
20
|
+
IntegrationEvent,
|
|
21
|
+
)
|
|
22
|
+
from hexagonal.domain.aggregate import AggregateSnapshot
|
|
23
|
+
from hexagonal.domain.base import Inmutable
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass
|
|
27
|
+
class StoredMessage:
|
|
28
|
+
topic: str
|
|
29
|
+
state: bytes
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
TMessage = Command | DomainEvent | IntegrationEvent | CloudMessage[Any]
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class MessageMapper(Mapper[UUID]):
|
|
36
|
+
def to_stored_event(self, domain_event: DomainEventProtocol[UUID]) -> StoredEvent:
|
|
37
|
+
"""Converts the given domain event to a :class:`StoredEvent` object."""
|
|
38
|
+
topic = get_topic(domain_event.__class__)
|
|
39
|
+
event_state = (
|
|
40
|
+
domain_event.model_dump(mode="json")
|
|
41
|
+
if isinstance(domain_event, Inmutable)
|
|
42
|
+
else domain_event.__dict__.copy()
|
|
43
|
+
)
|
|
44
|
+
originator_id = event_state.pop("originator_id")
|
|
45
|
+
originator_version = event_state.pop("originator_version")
|
|
46
|
+
class_version = getattr(type(domain_event), "class_version", 1)
|
|
47
|
+
if class_version > 1:
|
|
48
|
+
event_state["class_version"] = class_version
|
|
49
|
+
stored_state = self.transcoder.encode(event_state)
|
|
50
|
+
if self.compressor:
|
|
51
|
+
stored_state = self.compressor.compress(stored_state)
|
|
52
|
+
if self.cipher:
|
|
53
|
+
stored_state = self.cipher.encrypt(stored_state)
|
|
54
|
+
return StoredEvent(
|
|
55
|
+
originator_id=originator_id,
|
|
56
|
+
originator_version=originator_version,
|
|
57
|
+
topic=topic,
|
|
58
|
+
state=stored_state,
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
def to_domain_event(self, stored_event: StoredEvent) -> DomainEventProtocol[UUID]:
|
|
62
|
+
"""Converts the given :class:`StoredEvent` to a domain event object."""
|
|
63
|
+
cls = resolve_topic(stored_event.topic)
|
|
64
|
+
|
|
65
|
+
stored_state = stored_event.state
|
|
66
|
+
try:
|
|
67
|
+
if self.cipher:
|
|
68
|
+
stored_state = self.cipher.decrypt(stored_state)
|
|
69
|
+
if self.compressor:
|
|
70
|
+
stored_state = self.compressor.decompress(stored_state)
|
|
71
|
+
event_state: dict[str, Any] = self.transcoder.decode(stored_state)
|
|
72
|
+
except Exception as e:
|
|
73
|
+
msg = (
|
|
74
|
+
f"Failed to deserialise state of stored event with "
|
|
75
|
+
f"topic '{stored_event.topic}', "
|
|
76
|
+
f"originator_id '{stored_event.originator_id}' and "
|
|
77
|
+
f"originator_version {stored_event.originator_version}: {e}"
|
|
78
|
+
)
|
|
79
|
+
raise MapperDeserialisationError(msg) from e
|
|
80
|
+
|
|
81
|
+
id_convertor = find_id_convertor(
|
|
82
|
+
cls, cast(Hashable, type(stored_event.originator_id))
|
|
83
|
+
)
|
|
84
|
+
# print("ID of convertor:", id(convertor))
|
|
85
|
+
event_state["originator_id"] = id_convertor(stored_event.originator_id)
|
|
86
|
+
event_state["originator_version"] = stored_event.originator_version
|
|
87
|
+
class_version = getattr(cls, "class_version", 1)
|
|
88
|
+
from_version = event_state.pop("class_version", 1)
|
|
89
|
+
while from_version < class_version:
|
|
90
|
+
getattr(cls, f"upcast_v{from_version}_v{from_version + 1}")(event_state)
|
|
91
|
+
from_version += 1
|
|
92
|
+
if issubclass(cls, AggregateSnapshot):
|
|
93
|
+
return cls.model_validate(event_state) # type: ignore[return-value]
|
|
94
|
+
domain_event = object.__new__(cls)
|
|
95
|
+
domain_event.__dict__.update(event_state)
|
|
96
|
+
return domain_event
|
|
97
|
+
|
|
98
|
+
def to_stored_message(self, message: TMessage) -> StoredMessage:
|
|
99
|
+
topic = get_topic(message.__class__)
|
|
100
|
+
event_state = message.model_dump(mode="json")
|
|
101
|
+
stored_state = self.transcoder.encode(event_state)
|
|
102
|
+
if self.compressor:
|
|
103
|
+
stored_state = self.compressor.compress(stored_state)
|
|
104
|
+
if self.cipher:
|
|
105
|
+
stored_state = self.cipher.encrypt(stored_state)
|
|
106
|
+
return StoredMessage(
|
|
107
|
+
topic=topic,
|
|
108
|
+
state=stored_state,
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
def to_message(self, stored_message: StoredMessage) -> TMessage:
|
|
112
|
+
stored_state = stored_message.state
|
|
113
|
+
if self.cipher:
|
|
114
|
+
stored_state = self.cipher.decrypt(stored_state)
|
|
115
|
+
if self.compressor:
|
|
116
|
+
stored_state = self.compressor.decompress(stored_state)
|
|
117
|
+
event_state: dict[str, Any] = self.transcoder.decode(stored_state)
|
|
118
|
+
cls = cast(TMessage, resolve_topic(stored_message.topic))
|
|
119
|
+
return cls.model_validate(event_state)
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
class OrjsonTranscoder(Transcoder):
|
|
123
|
+
def encode(self, obj: Any) -> bytes:
|
|
124
|
+
return orjson.dumps(obj)
|
|
125
|
+
|
|
126
|
+
def decode(self, data: bytes) -> Any:
|
|
127
|
+
return orjson.loads(data)
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
from .repository import (
|
|
2
|
+
BaseAggregateRepositoryAdapter,
|
|
3
|
+
BaseRepositoryAdapter,
|
|
4
|
+
TAggregate,
|
|
5
|
+
)
|
|
6
|
+
from .unit_of_work import BaseUnitOfWork
|
|
7
|
+
|
|
8
|
+
__all__ = [
|
|
9
|
+
"BaseRepositoryAdapter",
|
|
10
|
+
"BaseAggregateRepositoryAdapter",
|
|
11
|
+
"BaseUnitOfWork",
|
|
12
|
+
"TAggregate",
|
|
13
|
+
]
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
# pyright: reportMissingTypeStubs=false, reportUnknownArgumentType=false, reportMissingParameterType=none, reportGeneralTypeIssues=none
|
|
2
|
+
|
|
3
|
+
from typing import (
|
|
4
|
+
Any,
|
|
5
|
+
ClassVar,
|
|
6
|
+
Dict,
|
|
7
|
+
Mapping,
|
|
8
|
+
Type,
|
|
9
|
+
TypeVar,
|
|
10
|
+
get_args,
|
|
11
|
+
get_origin,
|
|
12
|
+
)
|
|
13
|
+
from uuid import UUID
|
|
14
|
+
|
|
15
|
+
from eventsourcing.persistence import Mapper
|
|
16
|
+
from eventsourcing.utils import Environment
|
|
17
|
+
|
|
18
|
+
from hexagonal.application import Infrastructure
|
|
19
|
+
from hexagonal.domain import AggregateRoot, TIdEntity
|
|
20
|
+
from hexagonal.ports.drivens import (
|
|
21
|
+
IAggregateRepository,
|
|
22
|
+
IBaseRepository,
|
|
23
|
+
IUnitOfWork,
|
|
24
|
+
TManager,
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
TAggregate = TypeVar("TAggregate", bound=AggregateRoot[Any, Any])
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class BaseRepositoryAdapter(IBaseRepository[TManager], Infrastructure):
|
|
31
|
+
ENV: ClassVar[Dict[str, str]] = {}
|
|
32
|
+
NAME: ClassVar[str | None] = None
|
|
33
|
+
|
|
34
|
+
def __init__(self, connection_manager: TManager):
|
|
35
|
+
super().__init__()
|
|
36
|
+
self._connection_manager = connection_manager
|
|
37
|
+
|
|
38
|
+
def initialize(self, env: Mapping[str, str]) -> None:
|
|
39
|
+
env2 = self.ENV.copy()
|
|
40
|
+
name = self.NAME or self.__class__.__name__.upper()
|
|
41
|
+
env2.update(env)
|
|
42
|
+
self.env = Environment(name, env2)
|
|
43
|
+
self._attached_to_uow = False
|
|
44
|
+
self._manager_at_uow: TManager | None = None
|
|
45
|
+
super().initialize(self.env)
|
|
46
|
+
|
|
47
|
+
def attach_to_unit_of_work(self, uow: IUnitOfWork[TManager]) -> None:
|
|
48
|
+
self._attached_to_uow = True
|
|
49
|
+
self._manager_at_uow = uow.connection_manager
|
|
50
|
+
|
|
51
|
+
def detach_from_unit_of_work(self) -> None:
|
|
52
|
+
self._attached_to_uow = False
|
|
53
|
+
self._manager_at_uow = None
|
|
54
|
+
|
|
55
|
+
@property
|
|
56
|
+
def connection_manager(self) -> TManager:
|
|
57
|
+
if self._attached_to_uow and self._manager_at_uow is not None:
|
|
58
|
+
return self._manager_at_uow
|
|
59
|
+
return self._connection_manager
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class BaseAggregateRepositoryAdapter(
|
|
63
|
+
BaseRepositoryAdapter[TManager],
|
|
64
|
+
IAggregateRepository[TManager, TAggregate, TIdEntity],
|
|
65
|
+
):
|
|
66
|
+
_type_of_aggregate: Type[TAggregate]
|
|
67
|
+
|
|
68
|
+
def __init_subclass__(cls) -> None:
|
|
69
|
+
super().__init_subclass__()
|
|
70
|
+
# Inspect generic base to find the concrete type argument
|
|
71
|
+
for base in getattr(cls, "__orig_bases__", []):
|
|
72
|
+
origin = get_origin(base)
|
|
73
|
+
if origin and issubclass(origin, BaseAggregateRepositoryAdapter):
|
|
74
|
+
args = get_args(base)
|
|
75
|
+
if args:
|
|
76
|
+
cls._type_of_aggregate = args[0]
|
|
77
|
+
cls.NAME = cls._type_of_aggregate.__name__.upper()
|
|
78
|
+
|
|
79
|
+
def __init__(self, mapper: Mapper[UUID], connection_manager: TManager):
|
|
80
|
+
super().__init__(connection_manager)
|
|
81
|
+
self._mapper = mapper
|
|
82
|
+
|
|
83
|
+
@property
|
|
84
|
+
def aggregate_name(self) -> str:
|
|
85
|
+
return self._type_of_aggregate.__name__
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
from typing import Mapping
|
|
2
|
+
|
|
3
|
+
from eventsourcing.utils import get_topic
|
|
4
|
+
|
|
5
|
+
from hexagonal.application import InfrastructureGroup
|
|
6
|
+
from hexagonal.ports.drivens import IBaseRepository, IUnitOfWork, TManager
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class BaseUnitOfWork(IUnitOfWork[TManager], InfrastructureGroup):
|
|
10
|
+
def __init__(
|
|
11
|
+
self,
|
|
12
|
+
*repositories: IBaseRepository[TManager],
|
|
13
|
+
connection_manager: TManager,
|
|
14
|
+
):
|
|
15
|
+
self._repositories = {get_topic(repo.__class__): repo for repo in repositories}
|
|
16
|
+
self._initialized = False
|
|
17
|
+
self._manager = connection_manager
|
|
18
|
+
super().__init__(self._manager, *repositories)
|
|
19
|
+
|
|
20
|
+
def initialize(self, env: Mapping[str, str]) -> None:
|
|
21
|
+
self._initialized = True
|
|
22
|
+
self._active = False
|
|
23
|
+
return super().initialize(env)
|
|
24
|
+
|
|
25
|
+
@property
|
|
26
|
+
def initialized(self) -> bool:
|
|
27
|
+
return self._initialized and super().initialized
|
|
28
|
+
|
|
29
|
+
def attach_repo(self, repo: IBaseRepository[TManager]):
|
|
30
|
+
topic = get_topic(repo.__class__)
|
|
31
|
+
if topic in self._repositories:
|
|
32
|
+
return
|
|
33
|
+
|
|
34
|
+
self._repositories[topic] = repo
|
|
35
|
+
if self.initialized and self._active:
|
|
36
|
+
repo.attach_to_unit_of_work(self)
|
|
37
|
+
|
|
38
|
+
def detach_repo(self, repo: IBaseRepository[TManager]) -> None:
|
|
39
|
+
topic = get_topic(repo.__class__)
|
|
40
|
+
if topic not in self._repositories:
|
|
41
|
+
return
|
|
42
|
+
|
|
43
|
+
if self.initialized and self._active:
|
|
44
|
+
repo.detach_from_unit_of_work()
|
|
45
|
+
del self._repositories[topic]
|
|
46
|
+
|
|
47
|
+
@property
|
|
48
|
+
def connection_manager(self):
|
|
49
|
+
return self._manager
|
|
50
|
+
|
|
51
|
+
def __enter__(self):
|
|
52
|
+
self.verify()
|
|
53
|
+
if self._active:
|
|
54
|
+
return self
|
|
55
|
+
# get connection from manager, the connection is entered yet
|
|
56
|
+
self._ctx = self._manager.start_connection()
|
|
57
|
+
for repo in self._repositories.values():
|
|
58
|
+
repo.attach_to_unit_of_work(self)
|
|
59
|
+
self._active = True
|
|
60
|
+
return self
|
|
61
|
+
|
|
62
|
+
def __exit__(self, exc_type, exc_val, exc_tb): # type: ignore
|
|
63
|
+
self._active = False
|
|
64
|
+
try:
|
|
65
|
+
if exc_type is None:
|
|
66
|
+
self.commit()
|
|
67
|
+
else:
|
|
68
|
+
self.rollback()
|
|
69
|
+
except Exception as e:
|
|
70
|
+
# Log or handle commit/rollback errors
|
|
71
|
+
raise RuntimeError(f"Failed to finalize transaction: {e}") from e
|
|
72
|
+
finally:
|
|
73
|
+
for repo in self._repositories.values():
|
|
74
|
+
repo.detach_from_unit_of_work()
|
|
75
|
+
self._ctx.__exit__(exc_type, exc_val, exc_tb) # type: ignore
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
"""SQLite adapters for the repository pattern."""
|
|
2
|
+
|
|
3
|
+
from .datastore import SQLiteConnectionContextManager, SQLiteDatastore
|
|
4
|
+
from .infrastructure import SQLiteInfrastructure
|
|
5
|
+
from .outbox import SQLiteInboxRepository, SQLiteOutboxRepository, SQLitePairInboxOutbox
|
|
6
|
+
from .repository import SQLiteRepositoryAdapter
|
|
7
|
+
from .unit_of_work import SQLiteUnitOfWork
|
|
8
|
+
|
|
9
|
+
__all__ = [
|
|
10
|
+
"SQLiteConnectionContextManager",
|
|
11
|
+
"SQLiteDatastore",
|
|
12
|
+
"SQLiteRepositoryAdapter",
|
|
13
|
+
"SQLiteUnitOfWork",
|
|
14
|
+
"SQLiteOutboxRepository",
|
|
15
|
+
"SQLiteInboxRepository",
|
|
16
|
+
"SQLiteInfrastructure",
|
|
17
|
+
"SQLitePairInboxOutbox",
|
|
18
|
+
]
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
"""SQLite datastore implementation for the repository pattern."""
|
|
2
|
+
|
|
3
|
+
import sqlite3
|
|
4
|
+
from contextlib import contextmanager
|
|
5
|
+
from typing import Callable, Iterator, Literal, Mapping, Optional, TypeVar
|
|
6
|
+
|
|
7
|
+
from eventsourcing.utils import strtobool
|
|
8
|
+
|
|
9
|
+
from hexagonal.application import Infrastructure
|
|
10
|
+
from hexagonal.ports.drivens import IConnectionManager
|
|
11
|
+
|
|
12
|
+
from .env_vars import SQLITE_CHECK_SAME_THREAD, SQLITE_DB_PATH
|
|
13
|
+
|
|
14
|
+
# Type variable for the connection type
|
|
15
|
+
T = TypeVar("T", bound=sqlite3.Connection)
|
|
16
|
+
|
|
17
|
+
# Type alias for SQLite isolation levels
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class SQLiteDatastore(Infrastructure):
|
|
21
|
+
"""SQLite datastore adapter that provides connection and transaction management."""
|
|
22
|
+
|
|
23
|
+
def __init__(
|
|
24
|
+
self,
|
|
25
|
+
database: str,
|
|
26
|
+
*,
|
|
27
|
+
timeout: float = 5.0,
|
|
28
|
+
detect_types: int = 0,
|
|
29
|
+
isolation_level: Optional[Literal["DEFERRED", "IMMEDIATE", "EXCLUSIVE"]] = None,
|
|
30
|
+
check_same_thread: bool = True,
|
|
31
|
+
factory: Optional[Callable[..., sqlite3.Connection]] = None,
|
|
32
|
+
cached_statements: int = 128,
|
|
33
|
+
uri: bool = False,
|
|
34
|
+
autocommit: bool = False,
|
|
35
|
+
):
|
|
36
|
+
"""Initialize SQLite datastore.
|
|
37
|
+
|
|
38
|
+
Args:
|
|
39
|
+
database: Path to the SQLite database file
|
|
40
|
+
timeout: How many seconds the connection should wait
|
|
41
|
+
before raising an exception
|
|
42
|
+
detect_types: Control the type detection
|
|
43
|
+
isolation_level: See sqlite3.Connection.isolation_level
|
|
44
|
+
check_same_thread: If True, only the creating thread may use the connection
|
|
45
|
+
factory: Custom connection factory
|
|
46
|
+
(must be a callable that returns a Connection)
|
|
47
|
+
cached_statements: Number of statements to cache
|
|
48
|
+
uri: If True, database is interpreted as a URI
|
|
49
|
+
autocommit: If True, the connection will be in autocommit mode
|
|
50
|
+
"""
|
|
51
|
+
self.database = database
|
|
52
|
+
self.timeout = timeout
|
|
53
|
+
self.detect_types = detect_types
|
|
54
|
+
self.isolation_level = isolation_level
|
|
55
|
+
self.check_same_thread = check_same_thread
|
|
56
|
+
self.factory = factory
|
|
57
|
+
self.cached_statements = cached_statements
|
|
58
|
+
self.uri = uri
|
|
59
|
+
self.autocommit = autocommit
|
|
60
|
+
self._connection: Optional[sqlite3.Connection] = None
|
|
61
|
+
|
|
62
|
+
@contextmanager
|
|
63
|
+
def get_connection(self) -> Iterator[sqlite3.Connection]:
|
|
64
|
+
"""Get a database connection from the pool.
|
|
65
|
+
|
|
66
|
+
Yields:
|
|
67
|
+
A database connection that will be automatically
|
|
68
|
+
closed when the context exits
|
|
69
|
+
|
|
70
|
+
Example:
|
|
71
|
+
with datastore.get_connection() as conn:
|
|
72
|
+
cursor = conn.cursor()
|
|
73
|
+
cursor.execute('SELECT 1')
|
|
74
|
+
result = cursor.fetchone()
|
|
75
|
+
"""
|
|
76
|
+
conn = sqlite3.connect(
|
|
77
|
+
database=self.database,
|
|
78
|
+
timeout=self.timeout,
|
|
79
|
+
detect_types=self.detect_types,
|
|
80
|
+
check_same_thread=self.check_same_thread,
|
|
81
|
+
cached_statements=self.cached_statements,
|
|
82
|
+
uri=self.uri,
|
|
83
|
+
)
|
|
84
|
+
try:
|
|
85
|
+
# Enable foreign key constraints
|
|
86
|
+
conn.execute("PRAGMA foreign_keys = ON")
|
|
87
|
+
yield conn
|
|
88
|
+
except sqlite3.DatabaseError as e:
|
|
89
|
+
raise Exception(f"SQLite DatabaseError: {e}") from e
|
|
90
|
+
except sqlite3.InterfaceError as e:
|
|
91
|
+
raise Exception(f"SQLite InterfaceError: {e}") from e
|
|
92
|
+
# except Exception as e:
|
|
93
|
+
# raise Exception(f"SQLite Unknown error: {e}") from e
|
|
94
|
+
finally:
|
|
95
|
+
conn.close()
|
|
96
|
+
|
|
97
|
+
@contextmanager
|
|
98
|
+
def transaction(self, commit: bool = False) -> Iterator[sqlite3.Cursor]:
|
|
99
|
+
"""Execute a transaction on the database.
|
|
100
|
+
|
|
101
|
+
Args:
|
|
102
|
+
commit: If True, commit the transaction after the block completes.
|
|
103
|
+
If False, rollback the transaction if an exception occurs.
|
|
104
|
+
|
|
105
|
+
Yields:
|
|
106
|
+
A database cursor for executing SQL statements.
|
|
107
|
+
|
|
108
|
+
Example:
|
|
109
|
+
with datastore.transaction(commit=True) as cursor:
|
|
110
|
+
cursor.execute('INSERT INTO table VALUES (?)', (value,))
|
|
111
|
+
"""
|
|
112
|
+
with self.get_connection() as conn:
|
|
113
|
+
cursor = conn.cursor()
|
|
114
|
+
try:
|
|
115
|
+
yield cursor
|
|
116
|
+
if commit:
|
|
117
|
+
conn.commit()
|
|
118
|
+
except Exception as exc:
|
|
119
|
+
conn.rollback()
|
|
120
|
+
raise RuntimeError("Transaction failed") from exc
|
|
121
|
+
finally:
|
|
122
|
+
cursor.close()
|
|
123
|
+
|
|
124
|
+
def close(self) -> None:
|
|
125
|
+
"""Close any open connection."""
|
|
126
|
+
if self._connection:
|
|
127
|
+
self._connection.close()
|
|
128
|
+
self._connection = None
|
|
129
|
+
|
|
130
|
+
def __enter__(self):
|
|
131
|
+
self._connection = self.get_connection().__enter__()
|
|
132
|
+
self._connection.row_factory = sqlite3.Row
|
|
133
|
+
return self
|
|
134
|
+
|
|
135
|
+
def __exit__(self, exc_type, exc_val, exc_tb): # type: ignore
|
|
136
|
+
self.close()
|
|
137
|
+
|
|
138
|
+
def __del__(self):
|
|
139
|
+
self.close()
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def dict_factory(cursor: sqlite3.Cursor, row: sqlite3.Row):
|
|
143
|
+
fields = [column[0] for column in cursor.description]
|
|
144
|
+
return {key: value for key, value in zip(fields, row, strict=False)}
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
class SQLiteConnectionContextManager(IConnectionManager, Infrastructure):
|
|
148
|
+
def __init__(self, datastore: SQLiteDatastore | None = None):
|
|
149
|
+
self._current_connection: sqlite3.Connection | None = None
|
|
150
|
+
self._setted_datastore: bool = datastore is not None
|
|
151
|
+
if datastore is not None:
|
|
152
|
+
self._datastore = datastore
|
|
153
|
+
super().__init__()
|
|
154
|
+
|
|
155
|
+
def initialize(self, env: Mapping[str, str]) -> None:
|
|
156
|
+
if self._setted_datastore:
|
|
157
|
+
return super().initialize(env)
|
|
158
|
+
db_path = env.get(SQLITE_DB_PATH, ":memory:")
|
|
159
|
+
check_same_thread = strtobool(env.get(SQLITE_CHECK_SAME_THREAD, "True"))
|
|
160
|
+
self._datastore = SQLiteDatastore(
|
|
161
|
+
database=db_path,
|
|
162
|
+
check_same_thread=check_same_thread,
|
|
163
|
+
)
|
|
164
|
+
super().initialize(env)
|
|
165
|
+
|
|
166
|
+
@property
|
|
167
|
+
def datastore(self) -> SQLiteDatastore:
|
|
168
|
+
self.verify()
|
|
169
|
+
return self._datastore
|
|
170
|
+
|
|
171
|
+
def start_connection(self):
|
|
172
|
+
self._ctx_con = self.datastore.get_connection()
|
|
173
|
+
self._current_connection = self._ctx_con.__enter__()
|
|
174
|
+
return self._ctx_con
|
|
175
|
+
|
|
176
|
+
@property
|
|
177
|
+
def current_connection(self) -> sqlite3.Connection:
|
|
178
|
+
if self._current_connection is None:
|
|
179
|
+
self._ctx_con = self.datastore.get_connection()
|
|
180
|
+
self._current_connection = self._ctx_con.__enter__()
|
|
181
|
+
return self._current_connection
|
|
182
|
+
|
|
183
|
+
@current_connection.setter
|
|
184
|
+
def current_connection(self, connection: sqlite3.Connection) -> None:
|
|
185
|
+
if getattr(self, "_ctx_con", None):
|
|
186
|
+
self._ctx_con.__exit__(None, None, None)
|
|
187
|
+
self._current_connection = connection
|
|
188
|
+
|
|
189
|
+
@contextmanager
|
|
190
|
+
def cursor(self) -> Iterator[sqlite3.Cursor]:
|
|
191
|
+
try:
|
|
192
|
+
cursor = self.current_connection.cursor()
|
|
193
|
+
except sqlite3.ProgrammingError:
|
|
194
|
+
self._current_connection = None
|
|
195
|
+
cursor = self.current_connection.cursor()
|
|
196
|
+
yield cursor
|
|
197
|
+
cursor.close()
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
from hexagonal.adapters.drivens.mappers import MessageMapper
|
|
2
|
+
from hexagonal.application import ComposableInfrastructure
|
|
3
|
+
|
|
4
|
+
from .datastore import SQLiteConnectionContextManager, SQLiteDatastore
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class SQLiteInfrastructure(ComposableInfrastructure):
|
|
8
|
+
def __init__(self, mapper: MessageMapper, datastore: SQLiteDatastore | None = None):
|
|
9
|
+
self._datastore = datastore
|
|
10
|
+
self._mapper = mapper
|
|
11
|
+
self._connection_manager = SQLiteConnectionContextManager(datastore)
|
|
12
|
+
super().__init__(self._connection_manager)
|
|
13
|
+
|
|
14
|
+
@property
|
|
15
|
+
def connection_manager(self) -> SQLiteConnectionContextManager:
|
|
16
|
+
return self._connection_manager
|
|
17
|
+
|
|
18
|
+
@property
|
|
19
|
+
def mapper(self) -> MessageMapper:
|
|
20
|
+
return self._mapper
|