python-hexagonal 0.1.2__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.
@@ -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
 
@@ -123,6 +123,8 @@ class MessageMapper(Mapper[UUID]):
123
123
  def default_orjson_value_serializer(obj: Any) -> Any:
124
124
  if isinstance(obj, (UUID, Decimal)):
125
125
  return str(obj)
126
+ if isinstance(obj, Inmutable):
127
+ return obj.model_dump(mode="json")
126
128
  raise TypeError
127
129
 
128
130
 
@@ -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
+ ): ...
@@ -12,7 +12,11 @@ from .outbox import (
12
12
  SQLAlchemyOutboxRepository,
13
13
  SQLAlchemyPairInboxOutbox,
14
14
  )
15
- from .repository import SQLAlchemyRepositoryAdapter
15
+ from .repository import (
16
+ SQLAlchemyEntityRepositoryAdapter,
17
+ SQLAlchemyRepositoryAdapter,
18
+ SQLAlchemySearchRepositoryAdapter,
19
+ )
16
20
  from .unit_of_work import SQLAlchemyUnitOfWork
17
21
 
18
22
  __all__ = [
@@ -24,4 +28,6 @@ __all__ = [
24
28
  "SQLAlchemyInboxRepository",
25
29
  "SQLAlchemyInfrastructure",
26
30
  "SQLAlchemyPairInboxOutbox",
31
+ "SQLAlchemyEntityRepositoryAdapter",
32
+ "SQLAlchemySearchRepositoryAdapter",
27
33
  ]
@@ -19,7 +19,7 @@ metadata = MetaData()
19
19
 
20
20
  def _get_table_key(table_name: str, schema: str | None = None) -> str:
21
21
  """Get the key used by SQLAlchemy metadata to store the table."""
22
- if schema:
22
+ if schema and schema != "":
23
23
  return f"{schema}.{table_name}"
24
24
  return table_name
25
25
 
@@ -34,6 +34,7 @@ def create_aggregates_table(table_name: str, schema: str | None = None) -> Table
34
34
  Returns:
35
35
  SQLAlchemy Table object for aggregate snapshots
36
36
  """
37
+ schema = schema if schema and schema != "" else None
37
38
  full_table_name = f"aggregates_{table_name}"
38
39
  table_key = _get_table_key(full_table_name, schema)
39
40
 
@@ -45,7 +46,7 @@ def create_aggregates_table(table_name: str, schema: str | None = None) -> Table
45
46
  full_table_name,
46
47
  metadata,
47
48
  Column("originator_id", String(36), nullable=False),
48
- Column("aggregate_name", Text, nullable=False),
49
+ Column("aggregate_name", String(255), nullable=False),
49
50
  Column("originator_version", Integer, nullable=False),
50
51
  Column("topic", Text, nullable=False),
51
52
  Column("state", LargeBinary, nullable=False),
@@ -66,6 +67,7 @@ def create_events_table(table_name: str, schema: str | None = None) -> Table:
66
67
  Returns:
67
68
  SQLAlchemy Table object for aggregate events history
68
69
  """
70
+ schema = schema if schema and schema != "" else None
69
71
  full_table_name = f"aggregates_{table_name}_events"
70
72
  table_key = _get_table_key(full_table_name, schema)
71
73
 
@@ -77,7 +79,7 @@ def create_events_table(table_name: str, schema: str | None = None) -> Table:
77
79
  full_table_name,
78
80
  metadata,
79
81
  Column("originator_id", String(36), nullable=False),
80
- Column("aggregate_name", Text, nullable=False),
82
+ Column("aggregate_name", String(255), nullable=False),
81
83
  Column("originator_version", Integer, nullable=False),
82
84
  Column("topic", Text, nullable=False),
83
85
  Column("state", LargeBinary, nullable=False),
@@ -97,6 +99,7 @@ def create_outbox_table(table_name: str = "outbox", schema: str | None = None) -
97
99
  Returns:
98
100
  SQLAlchemy Table object for outbox messages
99
101
  """
102
+ schema = schema if schema and schema != "" else None
100
103
  table_key = _get_table_key(table_name, schema)
101
104
 
102
105
  # Return existing table if already defined
@@ -107,7 +110,7 @@ def create_outbox_table(table_name: str = "outbox", schema: str | None = None) -
107
110
  table_name,
108
111
  metadata,
109
112
  Column("message_id", String(36), primary_key=True),
110
- Column("topic", Text, nullable=False),
113
+ Column("topic", String(255), nullable=False),
111
114
  Column("message", LargeBinary, nullable=False),
112
115
  Column("published_at", DateTime(timezone=True), nullable=True),
113
116
  Column("failed_at", DateTime(timezone=True), nullable=True),
@@ -134,6 +137,7 @@ def create_inbox_table(table_name: str = "inbox", schema: str | None = None) ->
134
137
  Returns:
135
138
  SQLAlchemy Table object for inbox messages
136
139
  """
140
+ schema = schema if schema and schema != "" else None
137
141
  table_key = _get_table_key(table_name, schema)
138
142
 
139
143
  # Return existing table if already defined
@@ -144,7 +148,7 @@ def create_inbox_table(table_name: str = "inbox", schema: str | None = None) ->
144
148
  table_name,
145
149
  metadata,
146
150
  Column("message_id", String(36), nullable=False),
147
- Column("handler", Text, nullable=False),
151
+ Column("handler", String(255), nullable=False),
148
152
  Column("received_at", DateTime(timezone=True), nullable=False),
149
153
  Column("processed_at", DateTime(timezone=True), nullable=True),
150
154
  Column("error", Text, nullable=True),
@@ -313,7 +313,7 @@ class SQLAlchemyInboxRepository(
313
313
 
314
314
  def _cursor(self):
315
315
  """Get the current connection for executing statements."""
316
- return self._connection_manager.cursor()
316
+ return self.connection_manager.cursor()
317
317
 
318
318
  def create_tables(self) -> None:
319
319
  """Create the inbox table if it doesn't exist."""
@@ -3,7 +3,8 @@
3
3
  from __future__ import annotations
4
4
 
5
5
  import logging
6
- from typing import Any, ClassVar, Dict, Mapping, Sequence, Tuple, TypeVar, cast
6
+ from abc import abstractmethod
7
+ from typing import Any, ClassVar, Dict, Mapping, Sequence, Tuple, cast
7
8
  from uuid import UUID
8
9
 
9
10
  from eventsourcing.domain import CanMutateAggregate
@@ -11,24 +12,104 @@ from eventsourcing.persistence import StoredEvent
11
12
  from eventsourcing.utils import strtobool
12
13
  from sqlalchemy import Connection, delete, insert, select, update
13
14
 
14
- from hexagonal.adapters.drivens.repository.base import BaseAggregateRepositoryAdapter
15
+ from hexagonal.adapters.drivens.repository.base import (
16
+ BaseAggregateRepositoryAdapter,
17
+ BaseEntityRepositoryAdapter,
18
+ BaseSearchRepositoryAdapter,
19
+ )
15
20
  from hexagonal.domain import (
16
21
  AggregateNotFound,
17
- AggregateRoot,
18
22
  AggregateSnapshot,
19
23
  AggregateVersionMismatch,
20
24
  SnapshotState,
25
+ TAggregate,
26
+ TEntity,
21
27
  TIdEntity,
28
+ TQuery,
29
+ TView,
22
30
  )
23
31
 
24
32
  from .datastore import SQLAlchemyConnectionContextManager
25
33
  from .models import create_aggregates_table, create_events_table
26
34
 
27
- TAggregate = TypeVar("TAggregate", bound=AggregateRoot[Any, Any])
28
-
29
35
  logger = logging.getLogger(__name__)
30
36
 
31
37
 
38
+ class SQLAlchemyEntityRepositoryAdapter(
39
+ BaseEntityRepositoryAdapter[SQLAlchemyConnectionContextManager, TEntity, TIdEntity]
40
+ ):
41
+ """SQLAlchemy repository adapter for entities.
42
+
43
+ This adapter implements the IEntityRepository interface using SQLAlchemy
44
+ as the backing store. It handles the persistence and retrieval of entities.
45
+
46
+ Supports multiple database backends (PostgreSQL, MySQL, SQLite) through
47
+ SQLAlchemy's abstraction layer.
48
+
49
+ Args:
50
+ mapper: Mapper for converting between domain and persistence models
51
+ connection_manager: SQLAlchemy connection context manager
52
+ """
53
+
54
+ def initialize(self, env: Mapping[str, str]) -> None:
55
+ """Initialize the repository from environment variables."""
56
+ super().initialize(env)
57
+ self._schema_name: str | None = self.env.get("SCHEMA_NAME")
58
+
59
+ def _get(self, conn: Connection, id: TIdEntity) -> TEntity | None:
60
+ """Fetch entity from database."""
61
+ # Implement entity retrieval logic using SQLAlchemy
62
+ # This is a placeholder implementation and should be replaced with actual logic
63
+ return None
64
+
65
+ def _insert(self, conn: Connection, entity: TEntity) -> None:
66
+ """Save an entity to the database."""
67
+ # Implement entity persistence logic using SQLAlchemy
68
+ # This is a placeholder implementation and should be replaced with actual logic
69
+ pass
70
+
71
+ def _update(self, conn: Connection, entity: TEntity) -> None:
72
+ """Update an existing entity in the database."""
73
+ # Implement entity update logic using SQLAlchemy
74
+ # This is a placeholder implementation and should be replaced with actual logic
75
+ pass
76
+
77
+ def _delete(self, conn: Connection, id: TIdEntity) -> None:
78
+ """Delete an entity from the database."""
79
+ # Implement entity deletion logic using SQLAlchemy
80
+ # This is a placeholder implementation and should be replaced with actual logic
81
+ pass
82
+
83
+ def get(self, id: TIdEntity) -> TEntity:
84
+ """Get an entity by its ID."""
85
+ self.verify()
86
+ with self.connection_manager.cursor() as conn:
87
+ entity = self._get(conn, id)
88
+ if entity is None:
89
+ raise AggregateNotFound(f"Entity with id {id} not found")
90
+ return entity
91
+
92
+ def save(self, entity: TEntity):
93
+ """Save an entity to the repository."""
94
+ self.verify()
95
+ with self.connection_manager.cursor() as conn:
96
+ existing = self._get(conn, entity.id)
97
+ if existing is None:
98
+ self._insert(conn, entity)
99
+ else:
100
+ self._update(conn, entity)
101
+
102
+ def delete(self, id: TIdEntity) -> TEntity:
103
+ """Delete an entity from the repository."""
104
+ self.verify()
105
+ with self.connection_manager.cursor() as conn:
106
+ entity = self._get(conn, id)
107
+ if entity is None:
108
+ raise AggregateNotFound(f"Entity with id {id} not found")
109
+ self._delete(conn, id)
110
+ return entity
111
+
112
+
32
113
  class SQLAlchemyRepositoryAdapter(
33
114
  BaseAggregateRepositoryAdapter[
34
115
  SQLAlchemyConnectionContextManager, TAggregate, TIdEntity
@@ -322,3 +403,40 @@ class SQLAlchemyRepositoryAdapter(
322
403
  self._save_event_history(conn, events)
323
404
  self._delete(conn, id)
324
405
  return agg
406
+
407
+
408
+ class SQLAlchemySearchRepositoryAdapter(
409
+ BaseSearchRepositoryAdapter[SQLAlchemyConnectionContextManager, TQuery, TView]
410
+ ):
411
+ """SQLAlchemy repository adapter for search queries.
412
+
413
+ This adapter implements the ISearchRepository interface using SQLAlchemy
414
+ as the backing store. It handles executing search queries and returning
415
+ results in a view format.
416
+
417
+ Supports multiple database backends (PostgreSQL, MySQL, SQLite) through
418
+ SQLAlchemy's abstraction layer.
419
+
420
+ Args:
421
+ mapper: Mapper for converting between domain and persistence models
422
+ connection_manager: SQLAlchemy connection context manager
423
+ """
424
+
425
+ @abstractmethod
426
+ def _search(self, conn: Connection, query: TQuery) -> Sequence[TView]: ...
427
+
428
+ def search(self, query: TQuery) -> Sequence[TView]:
429
+ """Execute a search query against the repository.
430
+
431
+ Args:
432
+ query: The search query to execute
433
+
434
+ Returns:
435
+ A sequence of view objects matching the query criteria
436
+
437
+ Raises:
438
+ RuntimeError: If not attached to a unit of work
439
+ """
440
+ self.verify()
441
+ with self.connection_manager.cursor() as conn:
442
+ return self._search(conn, query)
@@ -1,15 +1,31 @@
1
- from .api import BaseAPI, GetEvent
1
+ from .api import BaseAPI, GetEvent, TBaseApp
2
2
  from .app import Application
3
3
  from .bus_app import BusAppGroup, ComposableBusApp
4
- from .handlers import CommandHandler, EventHandler, MessageHandler, QueryHandler
4
+ from .handlers import (
5
+ CommandHandler,
6
+ CommandHandlerBase,
7
+ EventHandler,
8
+ EventHandlerBase,
9
+ MessageHandler,
10
+ QueryHandler,
11
+ )
5
12
  from .infrastructure import (
6
13
  ComposableInfrastructure,
7
14
  Infrastructure,
8
15
  InfrastructureGroup,
9
16
  )
10
- from .query import AggregateView, GetById, GetByIdHandler, SearchAggregateRepository
17
+ from .query import (
18
+ AggregateView,
19
+ GetAggregateByIdHandler,
20
+ GetById,
21
+ GetByIdHandler,
22
+ GetEntityByIdHandler,
23
+ SearchAggregateRepository,
24
+ )
25
+ from .topics import RegisterTopics
11
26
 
12
27
  __all__ = [
28
+ "TBaseApp",
13
29
  "BaseAPI",
14
30
  "GetEvent",
15
31
  "Application",
@@ -26,4 +42,9 @@ __all__ = [
26
42
  "SearchAggregateRepository",
27
43
  "AggregateView",
28
44
  "GetByIdHandler",
45
+ "RegisterTopics",
46
+ "GetAggregateByIdHandler",
47
+ "GetEntityByIdHandler",
48
+ "CommandHandlerBase",
49
+ "EventHandlerBase",
29
50
  ]