python-cqrs 5.0.0__py3-none-any.whl → 5.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.
- cqrs/__init__.py +14 -0
- cqrs/outbox/sqlalchemy.py +7 -5
- cqrs/outbox/sqlmodel.py +189 -0
- cqrs/saga/storage/sqlalchemy.py +72 -45
- cqrs/saga/storage/sqlmodel.py +203 -0
- cqrs/sqlmodel_base.py +45 -0
- {python_cqrs-5.0.0.dist-info → python_cqrs-5.1.0.dist-info}/METADATA +9 -43
- {python_cqrs-5.0.0.dist-info → python_cqrs-5.1.0.dist-info}/RECORD +11 -8
- {python_cqrs-5.0.0.dist-info → python_cqrs-5.1.0.dist-info}/WHEEL +0 -0
- {python_cqrs-5.0.0.dist-info → python_cqrs-5.1.0.dist-info}/licenses/LICENSE +0 -0
- {python_cqrs-5.0.0.dist-info → python_cqrs-5.1.0.dist-info}/top_level.txt +0 -0
cqrs/__init__.py
CHANGED
|
@@ -12,6 +12,7 @@ if typing.TYPE_CHECKING:
|
|
|
12
12
|
SqlAlchemyOutboxedEventRepository,
|
|
13
13
|
rebind_outbox_model,
|
|
14
14
|
)
|
|
15
|
+
from cqrs.outbox.sqlmodel import SqlModelOutboxedEventRepository
|
|
15
16
|
from cqrs.sqlalchemy_types import (
|
|
16
17
|
Binary16,
|
|
17
18
|
DialectAwareType,
|
|
@@ -129,6 +130,7 @@ __all__ = (
|
|
|
129
130
|
"PydanticResponse",
|
|
130
131
|
"OutboxedEventRepository",
|
|
131
132
|
"SqlAlchemyOutboxedEventRepository",
|
|
133
|
+
"SqlModelOutboxedEventRepository",
|
|
132
134
|
"EventProducer",
|
|
133
135
|
"EventSerializer",
|
|
134
136
|
"EventCodec",
|
|
@@ -187,6 +189,13 @@ _LAZY_SQLALCHEMY = {
|
|
|
187
189
|
"PayloadBinary": ("cqrs.sqlalchemy_types", "PayloadBinary"),
|
|
188
190
|
}
|
|
189
191
|
|
|
192
|
+
_LAZY_SQLMODEL = {
|
|
193
|
+
"SqlModelOutboxedEventRepository": (
|
|
194
|
+
"cqrs.outbox.sqlmodel",
|
|
195
|
+
"SqlModelOutboxedEventRepository",
|
|
196
|
+
),
|
|
197
|
+
}
|
|
198
|
+
|
|
190
199
|
|
|
191
200
|
def __getattr__(name: str) -> typing.Any:
|
|
192
201
|
if name in _LAZY_PYDANTIC:
|
|
@@ -201,4 +210,9 @@ def __getattr__(name: str) -> typing.Any:
|
|
|
201
210
|
# ImportError message is raised by the sqlalchemy modules themselves.
|
|
202
211
|
module = __import__(module_name, fromlist=[attr])
|
|
203
212
|
return getattr(module, attr)
|
|
213
|
+
if name in _LAZY_SQLMODEL:
|
|
214
|
+
module_name, attr = _LAZY_SQLMODEL[name]
|
|
215
|
+
# ImportError message is raised by the sqlmodel modules themselves.
|
|
216
|
+
module = __import__(module_name, fromlist=[attr])
|
|
217
|
+
return getattr(module, attr)
|
|
204
218
|
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
cqrs/outbox/sqlalchemy.py
CHANGED
|
@@ -173,11 +173,13 @@ class SqlAlchemyOutboxedEventRepository(repository.OutboxedEventRepository):
|
|
|
173
173
|
*,
|
|
174
174
|
serializer: EventCodec | None = None,
|
|
175
175
|
event_map: map.OutboxedEventMap | None = None,
|
|
176
|
+
model: typing.Any = None,
|
|
176
177
|
):
|
|
177
178
|
self.session = session
|
|
178
179
|
self._compressor = compressor
|
|
179
180
|
self._serializer = serializer or JsonEventSerializer()
|
|
180
181
|
self._event_map = event_map if event_map is not None else map.OutboxedEventMap
|
|
182
|
+
self._model = OutboxModel if model is None else model
|
|
181
183
|
|
|
182
184
|
def _resolve_codec(self, event_name: str) -> EventCodec:
|
|
183
185
|
return self._event_map.get_serializer(event_name) or self._serializer
|
|
@@ -211,7 +213,7 @@ class SqlAlchemyOutboxedEventRepository(repository.OutboxedEventRepository):
|
|
|
211
213
|
bytes_payload = self._compressor.compress(bytes_payload)
|
|
212
214
|
|
|
213
215
|
self.session.add(
|
|
214
|
-
|
|
216
|
+
self._model(
|
|
215
217
|
event_id=event.event_id,
|
|
216
218
|
event_id_bin=event.event_id.bytes,
|
|
217
219
|
event_name=event.event_name,
|
|
@@ -221,7 +223,7 @@ class SqlAlchemyOutboxedEventRepository(repository.OutboxedEventRepository):
|
|
|
221
223
|
),
|
|
222
224
|
)
|
|
223
225
|
|
|
224
|
-
def _process_events(self, model:
|
|
226
|
+
def _process_events(self, model: typing.Any) -> repository.OutboxedEvent | None:
|
|
225
227
|
event_dict = model.row_to_dict()
|
|
226
228
|
event_name = event_dict["event_name"]
|
|
227
229
|
|
|
@@ -260,8 +262,8 @@ class SqlAlchemyOutboxedEventRepository(repository.OutboxedEventRepository):
|
|
|
260
262
|
batch_size: int = 100,
|
|
261
263
|
topic: typing.Text | None = None,
|
|
262
264
|
) -> typing.List[repository.OutboxedEvent]:
|
|
263
|
-
events: typing.Sequence[
|
|
264
|
-
(await self.session.execute(
|
|
265
|
+
events: typing.Sequence[typing.Any] = (
|
|
266
|
+
(await self.session.execute(self._model.get_batch_query(batch_size, topic))).scalars().all()
|
|
265
267
|
)
|
|
266
268
|
|
|
267
269
|
result = []
|
|
@@ -285,7 +287,7 @@ class SqlAlchemyOutboxedEventRepository(repository.OutboxedEventRepository):
|
|
|
285
287
|
new_status: repository.EventStatus,
|
|
286
288
|
) -> None:
|
|
287
289
|
await self.session.execute(
|
|
288
|
-
statement=
|
|
290
|
+
statement=self._model.update_status_query(outboxed_event_id, new_status),
|
|
289
291
|
)
|
|
290
292
|
|
|
291
293
|
async def commit(self):
|
cqrs/outbox/sqlmodel.py
ADDED
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
"""SQLModel outbox: same table, queries, and repository behavior as SQLAlchemy."""
|
|
2
|
+
|
|
3
|
+
import datetime
|
|
4
|
+
import typing
|
|
5
|
+
import uuid
|
|
6
|
+
|
|
7
|
+
from cqrs import compressors
|
|
8
|
+
from cqrs.mapping import outbox as map
|
|
9
|
+
from cqrs.outbox import repository
|
|
10
|
+
from cqrs.serializers.protocol import EventCodec
|
|
11
|
+
|
|
12
|
+
try:
|
|
13
|
+
from cqrs.outbox.sqlalchemy import (
|
|
14
|
+
MAX_FLUSH_COUNTER_VALUE,
|
|
15
|
+
BinaryUUID,
|
|
16
|
+
OutboxModel as SqlAlchemyOutboxModel,
|
|
17
|
+
SqlAlchemyOutboxedEventRepository,
|
|
18
|
+
rebind_outbox_model,
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
import sqlalchemy
|
|
22
|
+
from sqlalchemy import func
|
|
23
|
+
from sqlmodel import Field
|
|
24
|
+
from sqlmodel.ext.asyncio.session import AsyncSession
|
|
25
|
+
|
|
26
|
+
from cqrs import sqlalchemy_types
|
|
27
|
+
from cqrs.sqlmodel_base import CqrsSQLModel, SqlAlchemyExecuteSession
|
|
28
|
+
except ImportError:
|
|
29
|
+
raise ImportError(
|
|
30
|
+
"You are trying to use the SQLModel outbox implementation, "
|
|
31
|
+
"but 'sqlmodel' is not installed. "
|
|
32
|
+
"Please install it using: pip install python-cqrs[sqlmodel]",
|
|
33
|
+
) from None
|
|
34
|
+
|
|
35
|
+
Base = CqrsSQLModel
|
|
36
|
+
|
|
37
|
+
DEFAULT_OUTBOX_TABLE_NAME = "outbox"
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class OutboxModel(Base, table=True):
|
|
41
|
+
__tablename__ = DEFAULT_OUTBOX_TABLE_NAME # type: ignore[assignment]
|
|
42
|
+
|
|
43
|
+
__table_args__ = (
|
|
44
|
+
sqlalchemy.UniqueConstraint(
|
|
45
|
+
"event_id_bin",
|
|
46
|
+
"event_name",
|
|
47
|
+
name="event_id_unique_index",
|
|
48
|
+
),
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
id: int | None = Field(
|
|
52
|
+
default=None,
|
|
53
|
+
sa_column=sqlalchemy.Column(
|
|
54
|
+
sqlalchemy.BigInteger,
|
|
55
|
+
sqlalchemy.Identity(),
|
|
56
|
+
primary_key=True,
|
|
57
|
+
nullable=False,
|
|
58
|
+
autoincrement=True,
|
|
59
|
+
comment="Identity",
|
|
60
|
+
),
|
|
61
|
+
)
|
|
62
|
+
event_id: uuid.UUID = Field(
|
|
63
|
+
sa_column=sqlalchemy.Column(
|
|
64
|
+
sqlalchemy_types.UUIDBinary,
|
|
65
|
+
nullable=False,
|
|
66
|
+
comment="Event idempotency id",
|
|
67
|
+
),
|
|
68
|
+
)
|
|
69
|
+
event_id_bin: bytes = Field(
|
|
70
|
+
sa_column=sqlalchemy.Column(
|
|
71
|
+
sqlalchemy_types.Binary16,
|
|
72
|
+
nullable=False,
|
|
73
|
+
comment="Event idempotency id in 16 bit presentation",
|
|
74
|
+
),
|
|
75
|
+
)
|
|
76
|
+
event_status: repository.EventStatus = Field(
|
|
77
|
+
default=repository.EventStatus.NEW,
|
|
78
|
+
sa_column=sqlalchemy.Column(
|
|
79
|
+
sqlalchemy.Enum(repository.EventStatus),
|
|
80
|
+
nullable=False,
|
|
81
|
+
default=repository.EventStatus.NEW,
|
|
82
|
+
comment="Event producing status",
|
|
83
|
+
),
|
|
84
|
+
)
|
|
85
|
+
flush_counter: int = Field(
|
|
86
|
+
default=0,
|
|
87
|
+
sa_column=sqlalchemy.Column(
|
|
88
|
+
sqlalchemy.SmallInteger,
|
|
89
|
+
nullable=False,
|
|
90
|
+
default=0,
|
|
91
|
+
comment="Event producing flush counter",
|
|
92
|
+
),
|
|
93
|
+
)
|
|
94
|
+
event_name: str = Field(
|
|
95
|
+
sa_column=sqlalchemy.Column(
|
|
96
|
+
sqlalchemy.String(255),
|
|
97
|
+
nullable=False,
|
|
98
|
+
comment="Event name",
|
|
99
|
+
),
|
|
100
|
+
)
|
|
101
|
+
topic: str = Field(
|
|
102
|
+
default="",
|
|
103
|
+
sa_column=sqlalchemy.Column(
|
|
104
|
+
sqlalchemy.String(255),
|
|
105
|
+
nullable=False,
|
|
106
|
+
default="",
|
|
107
|
+
comment="Event topic",
|
|
108
|
+
),
|
|
109
|
+
)
|
|
110
|
+
created_at: datetime.datetime | None = Field(
|
|
111
|
+
default=None,
|
|
112
|
+
sa_column=sqlalchemy.Column(
|
|
113
|
+
sqlalchemy.DateTime,
|
|
114
|
+
nullable=False,
|
|
115
|
+
server_default=func.now(),
|
|
116
|
+
comment="Event creation timestamp",
|
|
117
|
+
),
|
|
118
|
+
)
|
|
119
|
+
payload: bytes = Field(
|
|
120
|
+
sa_column=sqlalchemy.Column(
|
|
121
|
+
sqlalchemy_types.PayloadBinary,
|
|
122
|
+
nullable=False,
|
|
123
|
+
comment="Event payload",
|
|
124
|
+
),
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
def row_to_dict(self) -> typing.Dict[typing.Text, typing.Any]:
|
|
128
|
+
table = typing.cast(typing.Any, self).__table__
|
|
129
|
+
return {column.name: getattr(self, column.name) for column in table.columns}
|
|
130
|
+
|
|
131
|
+
@classmethod
|
|
132
|
+
def get_batch_query(
|
|
133
|
+
cls,
|
|
134
|
+
size: int,
|
|
135
|
+
topic: typing.Text | None = None,
|
|
136
|
+
) -> sqlalchemy.Select:
|
|
137
|
+
query = SqlAlchemyOutboxModel.get_batch_query
|
|
138
|
+
return query.__func__(cls, size, topic) # type: ignore[attr-defined]
|
|
139
|
+
|
|
140
|
+
@classmethod
|
|
141
|
+
def update_status_query(
|
|
142
|
+
cls,
|
|
143
|
+
outboxed_event_id: int,
|
|
144
|
+
status: repository.EventStatus,
|
|
145
|
+
) -> sqlalchemy.Update:
|
|
146
|
+
query = SqlAlchemyOutboxModel.update_status_query
|
|
147
|
+
return query.__func__(cls, outboxed_event_id, status) # type: ignore[attr-defined]
|
|
148
|
+
|
|
149
|
+
@classmethod
|
|
150
|
+
def status_sorting_case(cls) -> sqlalchemy.Case:
|
|
151
|
+
query = SqlAlchemyOutboxModel.status_sorting_case
|
|
152
|
+
return query.__func__(cls) # type: ignore[attr-defined]
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
class SqlModelOutboxedEventRepository(SqlAlchemyOutboxedEventRepository):
|
|
156
|
+
"""Outbox repository that stores rows as SQLModel ``OutboxModel`` instances.
|
|
157
|
+
|
|
158
|
+
Pass a ``sqlmodel.ext.asyncio.session.AsyncSession``. Behavior matches
|
|
159
|
+
``SqlAlchemyOutboxedEventRepository``: the same batch query, flush budget,
|
|
160
|
+
status ordering, codecs, and compressor hooks.
|
|
161
|
+
"""
|
|
162
|
+
|
|
163
|
+
def __init__(
|
|
164
|
+
self,
|
|
165
|
+
session: AsyncSession,
|
|
166
|
+
compressor: compressors.Compressor | None = None,
|
|
167
|
+
*,
|
|
168
|
+
serializer: EventCodec | None = None,
|
|
169
|
+
event_map: map.OutboxedEventMap | None = None,
|
|
170
|
+
):
|
|
171
|
+
super().__init__(
|
|
172
|
+
typing.cast(typing.Any, SqlAlchemyExecuteSession(session)),
|
|
173
|
+
compressor,
|
|
174
|
+
serializer=serializer,
|
|
175
|
+
event_map=event_map,
|
|
176
|
+
model=OutboxModel,
|
|
177
|
+
)
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
__all__ = (
|
|
181
|
+
"AsyncSession",
|
|
182
|
+
"Base",
|
|
183
|
+
"BinaryUUID",
|
|
184
|
+
"DEFAULT_OUTBOX_TABLE_NAME",
|
|
185
|
+
"MAX_FLUSH_COUNTER_VALUE",
|
|
186
|
+
"OutboxModel",
|
|
187
|
+
"SqlModelOutboxedEventRepository",
|
|
188
|
+
"rebind_outbox_model",
|
|
189
|
+
)
|
cqrs/saga/storage/sqlalchemy.py
CHANGED
|
@@ -145,14 +145,23 @@ class SagaLogModel(Base):
|
|
|
145
145
|
class _SqlAlchemySagaStorageRun(SagaStorageRun):
|
|
146
146
|
"""Scoped run: one session, no commit inside methods; caller calls commit()."""
|
|
147
147
|
|
|
148
|
-
def __init__(
|
|
148
|
+
def __init__(
|
|
149
|
+
self,
|
|
150
|
+
session: AsyncSession,
|
|
151
|
+
execution_model: type[typing.Any] = SagaExecutionModel,
|
|
152
|
+
log_model: type[typing.Any] = SagaLogModel,
|
|
153
|
+
) -> None:
|
|
149
154
|
"""
|
|
150
155
|
Initialize the run wrapper with an async SQLAlchemy session.
|
|
151
156
|
|
|
152
157
|
Parameters:
|
|
153
158
|
session (AsyncSession): The AsyncSession instance scoped to this run, used for all database operations.
|
|
159
|
+
execution_model: Mapped class for saga execution rows. Defaults to the SQLAlchemy model.
|
|
160
|
+
log_model: Mapped class for saga log rows. Defaults to the SQLAlchemy model.
|
|
154
161
|
"""
|
|
155
162
|
self._session = session
|
|
163
|
+
self._execution_model = execution_model
|
|
164
|
+
self._log_model = log_model
|
|
156
165
|
|
|
157
166
|
async def create_saga(
|
|
158
167
|
self,
|
|
@@ -170,7 +179,7 @@ class _SqlAlchemySagaStorageRun(SagaStorageRun):
|
|
|
170
179
|
name (str): Human-readable name of the saga.
|
|
171
180
|
context (dict[str, Any]): Initial saga context to be stored (will be serialized to the model's JSON column).
|
|
172
181
|
"""
|
|
173
|
-
execution =
|
|
182
|
+
execution = self._execution_model(
|
|
174
183
|
id=saga_id,
|
|
175
184
|
name=name,
|
|
176
185
|
status=SagaStatus.PENDING,
|
|
@@ -197,20 +206,20 @@ class _SqlAlchemySagaStorageRun(SagaStorageRun):
|
|
|
197
206
|
Raises:
|
|
198
207
|
SagaConcurrencyError: If an optimistic version check fails (indicating a concurrent modification) or if the saga does not exist when a version was supplied.
|
|
199
208
|
"""
|
|
200
|
-
stmt = sqlalchemy.update(
|
|
201
|
-
|
|
209
|
+
stmt = sqlalchemy.update(self._execution_model).where(
|
|
210
|
+
self._execution_model.id == saga_id,
|
|
202
211
|
)
|
|
203
212
|
if current_version is not None:
|
|
204
|
-
stmt = stmt.where(
|
|
213
|
+
stmt = stmt.where(self._execution_model.version == current_version)
|
|
205
214
|
stmt = stmt.values(
|
|
206
215
|
context=context,
|
|
207
|
-
version=
|
|
216
|
+
version=self._execution_model.version + 1,
|
|
208
217
|
)
|
|
209
218
|
result = await self._session.execute(stmt)
|
|
210
219
|
if result.rowcount == 0: # type: ignore[attr-defined]
|
|
211
220
|
if current_version is not None:
|
|
212
|
-
check_stmt = sqlalchemy.select(
|
|
213
|
-
|
|
221
|
+
check_stmt = sqlalchemy.select(self._execution_model.id).where(
|
|
222
|
+
self._execution_model.id == saga_id,
|
|
214
223
|
)
|
|
215
224
|
check_result = await self._session.execute(check_stmt)
|
|
216
225
|
if check_result.scalar_one_or_none():
|
|
@@ -240,11 +249,11 @@ class _SqlAlchemySagaStorageRun(SagaStorageRun):
|
|
|
240
249
|
SagaConcurrencyError: If no row was updated (saga does not exist or was modified concurrently).
|
|
241
250
|
"""
|
|
242
251
|
result = await self._session.execute(
|
|
243
|
-
sqlalchemy.update(
|
|
244
|
-
.where(
|
|
252
|
+
sqlalchemy.update(self._execution_model)
|
|
253
|
+
.where(self._execution_model.id == saga_id)
|
|
245
254
|
.values(
|
|
246
255
|
status=status,
|
|
247
|
-
version=
|
|
256
|
+
version=self._execution_model.version + 1,
|
|
248
257
|
),
|
|
249
258
|
)
|
|
250
259
|
if result.rowcount == 0: # pyright: ignore[reportAttributeAccessIssue]
|
|
@@ -270,7 +279,7 @@ class _SqlAlchemySagaStorageRun(SagaStorageRun):
|
|
|
270
279
|
status (SagaStepStatus): The step's outcome status.
|
|
271
280
|
details (str | None): Optional free-form details or error message associated with the step.
|
|
272
281
|
"""
|
|
273
|
-
log_entry =
|
|
282
|
+
log_entry = self._log_model(
|
|
274
283
|
saga_id=saga_id,
|
|
275
284
|
step_name=step_name,
|
|
276
285
|
action=action,
|
|
@@ -298,8 +307,8 @@ class _SqlAlchemySagaStorageRun(SagaStorageRun):
|
|
|
298
307
|
Raises:
|
|
299
308
|
ValueError: If no saga with the given id exists.
|
|
300
309
|
"""
|
|
301
|
-
stmt = sqlalchemy.select(
|
|
302
|
-
|
|
310
|
+
stmt = sqlalchemy.select(self._execution_model).where(
|
|
311
|
+
self._execution_model.id == saga_id,
|
|
303
312
|
)
|
|
304
313
|
if read_for_update:
|
|
305
314
|
stmt = stmt.with_for_update()
|
|
@@ -330,7 +339,9 @@ class _SqlAlchemySagaStorageRun(SagaStorageRun):
|
|
|
330
339
|
is normalized to UTC if not already timezone-aware.
|
|
331
340
|
"""
|
|
332
341
|
result = await self._session.execute(
|
|
333
|
-
sqlalchemy.select(
|
|
342
|
+
sqlalchemy.select(self._log_model)
|
|
343
|
+
.where(self._log_model.saga_id == saga_id)
|
|
344
|
+
.order_by(self._log_model.created_at),
|
|
334
345
|
)
|
|
335
346
|
rows = result.scalars().all()
|
|
336
347
|
return [
|
|
@@ -367,14 +378,24 @@ class _SqlAlchemySagaStorageRun(SagaStorageRun):
|
|
|
367
378
|
|
|
368
379
|
|
|
369
380
|
class SqlAlchemySagaStorage(ISagaStorage):
|
|
370
|
-
def __init__(
|
|
381
|
+
def __init__(
|
|
382
|
+
self,
|
|
383
|
+
session_factory: async_sessionmaker[AsyncSession],
|
|
384
|
+
*,
|
|
385
|
+
execution_model: type[typing.Any] = SagaExecutionModel,
|
|
386
|
+
log_model: type[typing.Any] = SagaLogModel,
|
|
387
|
+
):
|
|
371
388
|
"""
|
|
372
389
|
Initialize the SQLAlchemy-based saga storage with a factory for creating async sessions.
|
|
373
390
|
|
|
374
391
|
Parameters:
|
|
375
392
|
session_factory (async_sessionmaker[AsyncSession]): Factory that produces new AsyncSession instances used for each storage run and operation.
|
|
393
|
+
execution_model: Mapped class for saga execution rows. Defaults to the SQLAlchemy model.
|
|
394
|
+
log_model: Mapped class for saga log rows. Defaults to the SQLAlchemy model.
|
|
376
395
|
"""
|
|
377
396
|
self.session_factory = session_factory
|
|
397
|
+
self._execution_model = execution_model
|
|
398
|
+
self._log_model = log_model
|
|
378
399
|
|
|
379
400
|
def create_run(
|
|
380
401
|
self,
|
|
@@ -391,7 +412,11 @@ class SqlAlchemySagaStorage(ISagaStorage):
|
|
|
391
412
|
@contextlib.asynccontextmanager
|
|
392
413
|
async def _run() -> typing.AsyncGenerator[SagaStorageRun, None]:
|
|
393
414
|
async with self.session_factory() as session:
|
|
394
|
-
run = _SqlAlchemySagaStorageRun(
|
|
415
|
+
run = _SqlAlchemySagaStorageRun(
|
|
416
|
+
session,
|
|
417
|
+
self._execution_model,
|
|
418
|
+
self._log_model,
|
|
419
|
+
)
|
|
395
420
|
try:
|
|
396
421
|
yield run
|
|
397
422
|
except BaseException:
|
|
@@ -422,7 +447,7 @@ class SqlAlchemySagaStorage(ISagaStorage):
|
|
|
422
447
|
"""
|
|
423
448
|
async with self.session_factory() as session:
|
|
424
449
|
try:
|
|
425
|
-
execution =
|
|
450
|
+
execution = self._execution_model(
|
|
426
451
|
id=saga_id,
|
|
427
452
|
name=name,
|
|
428
453
|
status=SagaStatus.PENDING,
|
|
@@ -444,14 +469,14 @@ class SqlAlchemySagaStorage(ISagaStorage):
|
|
|
444
469
|
) -> None:
|
|
445
470
|
async with self.session_factory() as session:
|
|
446
471
|
try:
|
|
447
|
-
stmt = sqlalchemy.update(
|
|
448
|
-
|
|
472
|
+
stmt = sqlalchemy.update(self._execution_model).where(
|
|
473
|
+
self._execution_model.id == saga_id,
|
|
449
474
|
)
|
|
450
475
|
if current_version is not None:
|
|
451
|
-
stmt = stmt.where(
|
|
476
|
+
stmt = stmt.where(self._execution_model.version == current_version)
|
|
452
477
|
stmt = stmt.values(
|
|
453
478
|
context=context,
|
|
454
|
-
version=
|
|
479
|
+
version=self._execution_model.version + 1,
|
|
455
480
|
)
|
|
456
481
|
|
|
457
482
|
result = await session.execute(stmt)
|
|
@@ -462,8 +487,8 @@ class SqlAlchemySagaStorage(ISagaStorage):
|
|
|
462
487
|
# But for now, we assume if rowcount is 0 and we checked version, it's concurrency
|
|
463
488
|
if current_version is not None:
|
|
464
489
|
# Double check if saga exists
|
|
465
|
-
check_stmt = sqlalchemy.select(
|
|
466
|
-
|
|
490
|
+
check_stmt = sqlalchemy.select(self._execution_model.id).where(
|
|
491
|
+
self._execution_model.id == saga_id,
|
|
467
492
|
)
|
|
468
493
|
check_result = await session.execute(check_stmt)
|
|
469
494
|
if check_result.scalar_one_or_none():
|
|
@@ -492,11 +517,11 @@ class SqlAlchemySagaStorage(ISagaStorage):
|
|
|
492
517
|
async with self.session_factory() as session:
|
|
493
518
|
try:
|
|
494
519
|
await session.execute(
|
|
495
|
-
sqlalchemy.update(
|
|
496
|
-
.where(
|
|
520
|
+
sqlalchemy.update(self._execution_model)
|
|
521
|
+
.where(self._execution_model.id == saga_id)
|
|
497
522
|
.values(
|
|
498
523
|
status=status,
|
|
499
|
-
version=
|
|
524
|
+
version=self._execution_model.version + 1,
|
|
500
525
|
),
|
|
501
526
|
)
|
|
502
527
|
await session.commit()
|
|
@@ -514,7 +539,7 @@ class SqlAlchemySagaStorage(ISagaStorage):
|
|
|
514
539
|
) -> None:
|
|
515
540
|
async with self.session_factory() as session:
|
|
516
541
|
try:
|
|
517
|
-
log_entry =
|
|
542
|
+
log_entry = self._log_model(
|
|
518
543
|
saga_id=saga_id,
|
|
519
544
|
step_name=step_name,
|
|
520
545
|
action=action,
|
|
@@ -534,8 +559,8 @@ class SqlAlchemySagaStorage(ISagaStorage):
|
|
|
534
559
|
read_for_update: bool = False,
|
|
535
560
|
) -> tuple[SagaStatus, dict[str, typing.Any], int]:
|
|
536
561
|
async with self.session_factory() as session:
|
|
537
|
-
stmt = sqlalchemy.select(
|
|
538
|
-
|
|
562
|
+
stmt = sqlalchemy.select(self._execution_model).where(
|
|
563
|
+
self._execution_model.id == saga_id,
|
|
539
564
|
)
|
|
540
565
|
if read_for_update:
|
|
541
566
|
stmt = stmt.with_for_update()
|
|
@@ -560,9 +585,9 @@ class SqlAlchemySagaStorage(ISagaStorage):
|
|
|
560
585
|
) -> list[SagaLogEntry]:
|
|
561
586
|
async with self.session_factory() as session:
|
|
562
587
|
result = await session.execute(
|
|
563
|
-
sqlalchemy.select(
|
|
564
|
-
.where(
|
|
565
|
-
.order_by(
|
|
588
|
+
sqlalchemy.select(self._log_model)
|
|
589
|
+
.where(self._log_model.saga_id == saga_id)
|
|
590
|
+
.order_by(self._log_model.created_at),
|
|
566
591
|
)
|
|
567
592
|
rows = result.scalars().all()
|
|
568
593
|
|
|
@@ -596,20 +621,20 @@ class SqlAlchemySagaStorage(ISagaStorage):
|
|
|
596
621
|
)
|
|
597
622
|
async with self.session_factory() as session:
|
|
598
623
|
stmt = (
|
|
599
|
-
sqlalchemy.select(
|
|
600
|
-
.where(
|
|
601
|
-
.where(
|
|
624
|
+
sqlalchemy.select(self._execution_model.id)
|
|
625
|
+
.where(self._execution_model.status.in_(recoverable))
|
|
626
|
+
.where(self._execution_model.recovery_attempts < max_recovery_attempts)
|
|
602
627
|
)
|
|
603
628
|
if saga_name is not None:
|
|
604
|
-
stmt = stmt.where(
|
|
629
|
+
stmt = stmt.where(self._execution_model.name == saga_name)
|
|
605
630
|
if stale_after_seconds is not None:
|
|
606
631
|
threshold = datetime.datetime.now(
|
|
607
632
|
datetime.timezone.utc,
|
|
608
633
|
) - datetime.timedelta(
|
|
609
634
|
seconds=stale_after_seconds,
|
|
610
635
|
)
|
|
611
|
-
stmt = stmt.where(
|
|
612
|
-
stmt = stmt.order_by(
|
|
636
|
+
stmt = stmt.where(self._execution_model.updated_at < threshold)
|
|
637
|
+
stmt = stmt.order_by(self._execution_model.updated_at.asc()).limit(limit)
|
|
613
638
|
result = await session.execute(stmt)
|
|
614
639
|
rows = result.scalars().all()
|
|
615
640
|
return [typing.cast(uuid.UUID, row) for row in rows]
|
|
@@ -633,13 +658,15 @@ class SqlAlchemySagaStorage(ISagaStorage):
|
|
|
633
658
|
async with self.session_factory() as session:
|
|
634
659
|
try:
|
|
635
660
|
values: dict[str, typing.Any] = {
|
|
636
|
-
"recovery_attempts":
|
|
637
|
-
"version":
|
|
661
|
+
"recovery_attempts": self._execution_model.recovery_attempts + 1,
|
|
662
|
+
"version": self._execution_model.version + 1,
|
|
638
663
|
}
|
|
639
664
|
if new_status is not None:
|
|
640
665
|
values["status"] = new_status
|
|
641
666
|
result = await session.execute(
|
|
642
|
-
sqlalchemy.update(
|
|
667
|
+
sqlalchemy.update(self._execution_model)
|
|
668
|
+
.where(self._execution_model.id == saga_id)
|
|
669
|
+
.values(**values),
|
|
643
670
|
)
|
|
644
671
|
if result.rowcount == 0: # type: ignore[attr-defined]
|
|
645
672
|
raise ValueError(f"Saga {saga_id} not found")
|
|
@@ -656,11 +683,11 @@ class SqlAlchemySagaStorage(ISagaStorage):
|
|
|
656
683
|
async with self.session_factory() as session:
|
|
657
684
|
try:
|
|
658
685
|
result = await session.execute(
|
|
659
|
-
sqlalchemy.update(
|
|
660
|
-
.where(
|
|
686
|
+
sqlalchemy.update(self._execution_model)
|
|
687
|
+
.where(self._execution_model.id == saga_id)
|
|
661
688
|
.values(
|
|
662
689
|
recovery_attempts=attempts,
|
|
663
|
-
version=
|
|
690
|
+
version=self._execution_model.version + 1,
|
|
664
691
|
),
|
|
665
692
|
)
|
|
666
693
|
if result.rowcount == 0: # type: ignore[attr-defined]
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
"""SQLModel saga storage: same schema and operations as the SQLAlchemy storage."""
|
|
2
|
+
|
|
3
|
+
import contextlib
|
|
4
|
+
import datetime
|
|
5
|
+
import typing
|
|
6
|
+
import uuid
|
|
7
|
+
|
|
8
|
+
try:
|
|
9
|
+
from cqrs.saga.storage.sqlalchemy import (
|
|
10
|
+
DEFAULT_SAGA_EXECUTION_TABLE_NAME,
|
|
11
|
+
DEFAULT_SAGA_LOG_TABLE_NAME,
|
|
12
|
+
SAGA_EXECUTION_TABLE_NAME,
|
|
13
|
+
SAGA_LOG_TABLE_NAME,
|
|
14
|
+
SqlAlchemySagaStorage,
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
import sqlalchemy
|
|
18
|
+
from sqlalchemy import func
|
|
19
|
+
from sqlalchemy.ext.asyncio import async_sessionmaker
|
|
20
|
+
from sqlmodel import Field
|
|
21
|
+
from sqlmodel.ext.asyncio.session import AsyncSession
|
|
22
|
+
|
|
23
|
+
from cqrs.saga.storage.enums import SagaStatus, SagaStepStatus
|
|
24
|
+
from cqrs.sqlalchemy_types import JSONType
|
|
25
|
+
from cqrs.sqlmodel_base import CqrsSQLModel, SqlAlchemyExecuteSession
|
|
26
|
+
except ImportError:
|
|
27
|
+
raise ImportError(
|
|
28
|
+
"You are trying to use the SQLModel saga storage implementation, "
|
|
29
|
+
"but 'sqlmodel' is not installed. "
|
|
30
|
+
"Please install it using: pip install python-cqrs[sqlmodel]",
|
|
31
|
+
) from None
|
|
32
|
+
|
|
33
|
+
Base = CqrsSQLModel
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class SagaExecutionModel(Base, table=True):
|
|
37
|
+
__tablename__ = SAGA_EXECUTION_TABLE_NAME # type: ignore[assignment]
|
|
38
|
+
|
|
39
|
+
id: uuid.UUID = Field(
|
|
40
|
+
sa_column=sqlalchemy.Column(
|
|
41
|
+
sqlalchemy.Uuid,
|
|
42
|
+
primary_key=True,
|
|
43
|
+
nullable=False,
|
|
44
|
+
comment="Saga ID",
|
|
45
|
+
),
|
|
46
|
+
)
|
|
47
|
+
name: str = Field(
|
|
48
|
+
sa_column=sqlalchemy.Column(
|
|
49
|
+
sqlalchemy.String(255),
|
|
50
|
+
nullable=False,
|
|
51
|
+
comment="Saga Name",
|
|
52
|
+
),
|
|
53
|
+
)
|
|
54
|
+
status: SagaStatus = Field(
|
|
55
|
+
default=SagaStatus.PENDING,
|
|
56
|
+
sa_column=sqlalchemy.Column(
|
|
57
|
+
sqlalchemy.Enum(SagaStatus),
|
|
58
|
+
nullable=False,
|
|
59
|
+
default=SagaStatus.PENDING,
|
|
60
|
+
comment="Current status of the saga",
|
|
61
|
+
),
|
|
62
|
+
)
|
|
63
|
+
context: dict[str, typing.Any] = Field(
|
|
64
|
+
sa_column=sqlalchemy.Column(
|
|
65
|
+
JSONType,
|
|
66
|
+
nullable=False,
|
|
67
|
+
comment="Serialized context",
|
|
68
|
+
),
|
|
69
|
+
)
|
|
70
|
+
version: int = Field(
|
|
71
|
+
default=1,
|
|
72
|
+
sa_column=sqlalchemy.Column(
|
|
73
|
+
sqlalchemy.Integer,
|
|
74
|
+
nullable=False,
|
|
75
|
+
default=1,
|
|
76
|
+
comment="Optimistic locking version",
|
|
77
|
+
),
|
|
78
|
+
)
|
|
79
|
+
created_at: datetime.datetime | None = Field(
|
|
80
|
+
default=None,
|
|
81
|
+
sa_column=sqlalchemy.Column(
|
|
82
|
+
sqlalchemy.DateTime(timezone=True),
|
|
83
|
+
nullable=False,
|
|
84
|
+
server_default=func.now(),
|
|
85
|
+
comment="Creation timestamp",
|
|
86
|
+
),
|
|
87
|
+
)
|
|
88
|
+
updated_at: datetime.datetime | None = Field(
|
|
89
|
+
default=None,
|
|
90
|
+
sa_column=sqlalchemy.Column(
|
|
91
|
+
sqlalchemy.DateTime(timezone=True),
|
|
92
|
+
nullable=False,
|
|
93
|
+
server_default=func.now(),
|
|
94
|
+
onupdate=func.now(),
|
|
95
|
+
comment="Last update timestamp",
|
|
96
|
+
),
|
|
97
|
+
)
|
|
98
|
+
recovery_attempts: int = Field(
|
|
99
|
+
default=0,
|
|
100
|
+
sa_column=sqlalchemy.Column(
|
|
101
|
+
sqlalchemy.Integer,
|
|
102
|
+
nullable=False,
|
|
103
|
+
default=0,
|
|
104
|
+
server_default=sqlalchemy.text("0"),
|
|
105
|
+
comment="Number of recovery attempts",
|
|
106
|
+
),
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
class SagaLogModel(Base, table=True):
|
|
111
|
+
__tablename__ = SAGA_LOG_TABLE_NAME # type: ignore[assignment]
|
|
112
|
+
|
|
113
|
+
id: int | None = Field(
|
|
114
|
+
default=None,
|
|
115
|
+
sa_column=sqlalchemy.Column(
|
|
116
|
+
sqlalchemy.BigInteger(),
|
|
117
|
+
sqlalchemy.Identity(),
|
|
118
|
+
primary_key=True,
|
|
119
|
+
nullable=False,
|
|
120
|
+
autoincrement=True,
|
|
121
|
+
comment="Log ID",
|
|
122
|
+
),
|
|
123
|
+
)
|
|
124
|
+
saga_id: uuid.UUID = Field(
|
|
125
|
+
sa_column=sqlalchemy.Column(
|
|
126
|
+
sqlalchemy.Uuid,
|
|
127
|
+
sqlalchemy.ForeignKey(f"{SAGA_EXECUTION_TABLE_NAME}.id"),
|
|
128
|
+
nullable=False,
|
|
129
|
+
comment="Saga ID",
|
|
130
|
+
),
|
|
131
|
+
)
|
|
132
|
+
step_name: str = Field(
|
|
133
|
+
sa_column=sqlalchemy.Column(
|
|
134
|
+
sqlalchemy.String(255),
|
|
135
|
+
nullable=False,
|
|
136
|
+
comment="Name of the step",
|
|
137
|
+
),
|
|
138
|
+
)
|
|
139
|
+
action: str = Field(
|
|
140
|
+
sa_column=sqlalchemy.Column(
|
|
141
|
+
sqlalchemy.String(50),
|
|
142
|
+
nullable=False,
|
|
143
|
+
comment="Action performed (act/compensate)",
|
|
144
|
+
),
|
|
145
|
+
)
|
|
146
|
+
status: SagaStepStatus = Field(
|
|
147
|
+
sa_column=sqlalchemy.Column(
|
|
148
|
+
sqlalchemy.Enum(SagaStepStatus),
|
|
149
|
+
nullable=False,
|
|
150
|
+
comment="Status of the step",
|
|
151
|
+
),
|
|
152
|
+
)
|
|
153
|
+
details: str | None = Field(
|
|
154
|
+
default=None,
|
|
155
|
+
sa_column=sqlalchemy.Column(
|
|
156
|
+
sqlalchemy.Text,
|
|
157
|
+
nullable=True,
|
|
158
|
+
comment="Additional details or error message",
|
|
159
|
+
),
|
|
160
|
+
)
|
|
161
|
+
created_at: datetime.datetime | None = Field(
|
|
162
|
+
default=None,
|
|
163
|
+
sa_column=sqlalchemy.Column(
|
|
164
|
+
sqlalchemy.DateTime(timezone=True),
|
|
165
|
+
nullable=False,
|
|
166
|
+
server_default=func.now(),
|
|
167
|
+
comment="Timestamp of the log entry",
|
|
168
|
+
),
|
|
169
|
+
)
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
class SqlModelSagaStorage(SqlAlchemySagaStorage):
|
|
173
|
+
"""Saga storage that persists state in SQLModel tables.
|
|
174
|
+
|
|
175
|
+
``session_factory`` must be an ``async_sessionmaker`` built with
|
|
176
|
+
``class_=sqlmodel.ext.asyncio.session.AsyncSession``. Every operation of
|
|
177
|
+
``SqlAlchemySagaStorage`` is available, including checkpoint runs via
|
|
178
|
+
``create_run``, optimistic locking, and recovery queries.
|
|
179
|
+
"""
|
|
180
|
+
|
|
181
|
+
def __init__(self, session_factory: async_sessionmaker[AsyncSession]) -> None:
|
|
182
|
+
@contextlib.asynccontextmanager
|
|
183
|
+
async def _open() -> typing.AsyncGenerator[SqlAlchemyExecuteSession, None]:
|
|
184
|
+
async with session_factory() as session:
|
|
185
|
+
yield SqlAlchemyExecuteSession(session)
|
|
186
|
+
|
|
187
|
+
super().__init__(
|
|
188
|
+
_open, # type: ignore[arg-type]
|
|
189
|
+
execution_model=SagaExecutionModel,
|
|
190
|
+
log_model=SagaLogModel,
|
|
191
|
+
)
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
__all__ = (
|
|
195
|
+
"Base",
|
|
196
|
+
"DEFAULT_SAGA_EXECUTION_TABLE_NAME",
|
|
197
|
+
"DEFAULT_SAGA_LOG_TABLE_NAME",
|
|
198
|
+
"SAGA_EXECUTION_TABLE_NAME",
|
|
199
|
+
"SAGA_LOG_TABLE_NAME",
|
|
200
|
+
"SagaExecutionModel",
|
|
201
|
+
"SagaLogModel",
|
|
202
|
+
"SqlModelSagaStorage",
|
|
203
|
+
)
|
cqrs/sqlmodel_base.py
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
"""Declarative base shared by the optional SQLModel outbox and saga models."""
|
|
2
|
+
|
|
3
|
+
import typing
|
|
4
|
+
|
|
5
|
+
try:
|
|
6
|
+
from sqlalchemy.ext.asyncio import AsyncSession as SqlAlchemyAsyncSession
|
|
7
|
+
from sqlalchemy.orm import registry
|
|
8
|
+
from sqlmodel import SQLModel
|
|
9
|
+
from sqlmodel.ext.asyncio.session import AsyncSession
|
|
10
|
+
except ImportError:
|
|
11
|
+
raise ImportError(
|
|
12
|
+
"You are trying to use the SQLModel integration, "
|
|
13
|
+
"but 'sqlmodel' is not installed. "
|
|
14
|
+
"Please install it using: pip install python-cqrs[sqlmodel]",
|
|
15
|
+
) from None
|
|
16
|
+
|
|
17
|
+
cqrs_registry = registry()
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class CqrsSQLModel(SQLModel, registry=cqrs_registry):
|
|
21
|
+
"""Base for library SQLModel tables.
|
|
22
|
+
|
|
23
|
+
Tables live on this class's metadata, not on the application's
|
|
24
|
+
``SQLModel.metadata``. Call ``Base.metadata.create_all`` the same way as
|
|
25
|
+
with the SQLAlchemy models, or rebind the outbox model onto your own base.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class SqlAlchemyExecuteSession:
|
|
30
|
+
"""SQLModel session whose ``execute`` is the SQLAlchemy implementation.
|
|
31
|
+
|
|
32
|
+
``sqlmodel.AsyncSession.execute`` is deprecated in favor of ``exec`` and
|
|
33
|
+
warns on every call. The outbox and saga storage use Core ``execute``
|
|
34
|
+
because they need ``Result.rowcount`` and ``scalars()`` unchanged from the
|
|
35
|
+
SQLAlchemy repositories.
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
def __init__(self, session: AsyncSession) -> None:
|
|
39
|
+
self._session = session
|
|
40
|
+
|
|
41
|
+
def __getattr__(self, name: str) -> typing.Any:
|
|
42
|
+
return getattr(self._session, name)
|
|
43
|
+
|
|
44
|
+
async def execute(self, statement: typing.Any, params: typing.Any = None, **kwargs: typing.Any) -> typing.Any:
|
|
45
|
+
return await SqlAlchemyAsyncSession.execute(self._session, statement, params, **kwargs)
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: python-cqrs
|
|
3
|
-
Version: 5.
|
|
3
|
+
Version: 5.1.0
|
|
4
4
|
Summary: Event-Driven Architecture Framework for Distributed Systems
|
|
5
5
|
Author-email: Vadim Kozyrevskiy <vadikko2@mail.ru>, Dmitry Kutlubaev <kutlubaev00@mail.ru>
|
|
6
6
|
Maintainer-email: Vadim Kozyrevskiy <vadikko2@mail.ru>
|
|
@@ -29,6 +29,9 @@ Provides-Extra: pydantic
|
|
|
29
29
|
Requires-Dist: pydantic==2.*; extra == "pydantic"
|
|
30
30
|
Provides-Extra: sqlalchemy
|
|
31
31
|
Requires-Dist: sqlalchemy[asyncio]==2.0.*; extra == "sqlalchemy"
|
|
32
|
+
Provides-Extra: sqlmodel
|
|
33
|
+
Requires-Dist: sqlmodel>=0.0.22; extra == "sqlmodel"
|
|
34
|
+
Requires-Dist: sqlalchemy[asyncio]==2.0.*; extra == "sqlmodel"
|
|
32
35
|
Provides-Extra: dev
|
|
33
36
|
Requires-Dist: pycln==2.5.0; extra == "dev"
|
|
34
37
|
Requires-Dist: pre-commit==3.8.0; extra == "dev"
|
|
@@ -54,6 +57,8 @@ Requires-Dist: dishka>=1.0; extra == "dev"
|
|
|
54
57
|
Requires-Dist: protobuf>=4.25.8; extra == "dev"
|
|
55
58
|
Requires-Dist: pydantic==2.*; extra == "dev"
|
|
56
59
|
Requires-Dist: sqlalchemy[asyncio]==2.0.*; extra == "dev"
|
|
60
|
+
Requires-Dist: sqlmodel>=0.0.22; extra == "dev"
|
|
61
|
+
Requires-Dist: aiosqlite>=0.20.0; extra == "dev"
|
|
57
62
|
Provides-Extra: examples
|
|
58
63
|
Requires-Dist: fastapi==0.109.*; extra == "examples"
|
|
59
64
|
Requires-Dist: faststream[kafka]==0.5.28; extra == "examples"
|
|
@@ -98,9 +103,6 @@ Dynamic: license-file
|
|
|
98
103
|
<a href="https://codecov.io/gh/vadikko2/python-cqrs">
|
|
99
104
|
<img src="https://img.shields.io/codecov/c/github/vadikko2/python-cqrs?logo=codecov&logoColor=white" alt="Coverage">
|
|
100
105
|
</a>
|
|
101
|
-
<a href="https://codspeed.io/vadikko2/python-cqrs?utm_source=badge">
|
|
102
|
-
<img src="https://img.shields.io/endpoint?url=https://codspeed.io/badge.json" alt="CodSpeed">
|
|
103
|
-
</a>
|
|
104
106
|
<a href="https://mkdocs.python-cqrs.dev/">
|
|
105
107
|
<img src="https://img.shields.io/badge/docs-mkdocs-blue?logo=readthedocs" alt="Documentation">
|
|
106
108
|
</a>
|
|
@@ -113,17 +115,11 @@ Dynamic: license-file
|
|
|
113
115
|
</p>
|
|
114
116
|
</div>
|
|
115
117
|
|
|
116
|
-
> [!WARNING]
|
|
117
|
-
> **Breaking Changes in v5.0.0**
|
|
118
|
-
>
|
|
119
|
-
> Starting with version 5.0.0, Pydantic support will become optional. The default implementations of `Request`, `Response`, `DomainEvent`, and `NotificationEvent` will be migrated to dataclasses-based implementations.
|
|
120
|
-
|
|
121
118
|
## Table of Contents
|
|
122
119
|
|
|
123
120
|
- [Overview](#overview)
|
|
124
121
|
- [When NOT to use python-cqrs](#when-not-to-use-python-cqrs)
|
|
125
122
|
- [Installation](#installation)
|
|
126
|
-
- [Version Support](#version-support)
|
|
127
123
|
- [Quick Start](#quick-start)
|
|
128
124
|
- [Request and Response Types](#request-and-response-types)
|
|
129
125
|
- [Request Handlers](#request-handlers)
|
|
@@ -166,7 +162,7 @@ project ([documentation](https://akhundmurad.github.io/diator/)) with several en
|
|
|
166
162
|
|
|
167
163
|
- **Typing:** Pydantic [v2.*](https://docs.pydantic.dev/2.8/) and `IRequest`/`IResponse` interfaces — use Pydantic-based, dataclass-based, or custom Request/Response implementations.
|
|
168
164
|
- **Broker:** Kafka via [aiokafka](https://github.com/aio-libs/aiokafka).
|
|
169
|
-
- **Storage:** SQLAlchemy
|
|
165
|
+
- **Storage:** SQLAlchemy and SQLModel outbox and saga storage on MySQL/MariaDB and PostgreSQL.
|
|
170
166
|
- **Integration:** Ready for integration with FastAPI and FastStream.
|
|
171
167
|
- **Documentation:** Built-in Mermaid diagram generation (Sequence and Class diagrams).
|
|
172
168
|
- **Protobuf:** Interface-level support for converting Notification events to Protobuf and back.
|
|
@@ -189,11 +185,12 @@ Do not add a Saga for a single `UPDATE` in one service, and do not use a Saga as
|
|
|
189
185
|
pip install python-cqrs
|
|
190
186
|
```
|
|
191
187
|
|
|
192
|
-
Optional extras (5.0+ — pydantic and SQLAlchemy are no longer core dependencies):
|
|
188
|
+
Optional extras (5.0+ — pydantic and SQLAlchemy are no longer core dependencies; SQLModel storage is 5.1+):
|
|
193
189
|
|
|
194
190
|
```bash
|
|
195
191
|
pip install "python-cqrs[pydantic]" # PydanticRequest / Pydantic* events
|
|
196
192
|
pip install "python-cqrs[sqlalchemy]" # Outbox / Saga SQLAlchemy storage
|
|
193
|
+
pip install "python-cqrs[sqlmodel]" # Outbox / Saga SQLModel storage
|
|
197
194
|
pip install "python-cqrs[kafka]" # Kafka broker (aiokafka)
|
|
198
195
|
pip install "python-cqrs[examples]" # FastAPI, FastStream, uvicorn, etc.
|
|
199
196
|
pip install "python-cqrs[aiobreaker]" # Circuit breaker for saga fallbacks
|
|
@@ -203,37 +200,6 @@ Default `Request` / `Response` / `Event` / `DomainEvent` / `NotificationEvent` a
|
|
|
203
200
|
are dataclass-based (`DC*`). Use `Pydantic*` types with the pydantic extra when you
|
|
204
201
|
need validation.
|
|
205
202
|
|
|
206
|
-
### Import paths (5.0+)
|
|
207
|
-
|
|
208
|
-
| Area | Import |
|
|
209
|
-
|------|--------|
|
|
210
|
-
| Bootstrap | `from cqrs.bootstrap import requests, events, saga` |
|
|
211
|
-
| Mediators | `from cqrs.mediators import RequestMediator, SagaMediator, …` |
|
|
212
|
-
| Handlers | `from cqrs.handlers import RequestHandler, EventHandler, …` |
|
|
213
|
-
| Models | `from cqrs.models import Request, Event, …` / `cqrs.models.pydantic` |
|
|
214
|
-
| Maps | `from cqrs.mapping import RequestMap, EventMap, OutboxedEventMap` |
|
|
215
|
-
| Mermaid | `from cqrs.mermaid import CoRMermaid, SagaMermaid` |
|
|
216
|
-
| Outbox producer | `from cqrs.message_brokers.producer import EventProducer` |
|
|
217
|
-
|
|
218
|
-
`SagaMediator.execute(...)` replaces the former `SagaMediator.stream(...)`
|
|
219
|
-
(StreamingRequestMediator.stream is unchanged).
|
|
220
|
-
|
|
221
|
-
## Version Support
|
|
222
|
-
|
|
223
|
-
| Line | Git branch | What changes |
|
|
224
|
-
|------|------------|--------------|
|
|
225
|
-
| **4.x** | `4.x` | Bug fixes and security only (~12 months after 5.0.0) |
|
|
226
|
-
| **5.x** | `master` | Features and breaking changes |
|
|
227
|
-
|
|
228
|
-
Stay on 4.x until you migrate:
|
|
229
|
-
|
|
230
|
-
```bash
|
|
231
|
-
pip install "python-cqrs>=4,<5"
|
|
232
|
-
```
|
|
233
|
-
|
|
234
|
-
Docs: [4.x](https://mkdocs.python-cqrs.dev/4.0/) and [5.x / latest](https://mkdocs.python-cqrs.dev/latest/) (version switcher after mike deploy). See [SECURITY.md](SECURITY.md) for the support window and vulnerability reporting, and [CONTRIBUTING.md](CONTRIBUTING.md#8-maintaining--fixing-4x) for how to contribute 4.x bugfixes.
|
|
235
|
-
|
|
236
|
-
A package-level EOL `DeprecationWarning` toward 5.x will be added on the **first 4.x patch after 5.0.0** — not before.
|
|
237
203
|
|
|
238
204
|
## Quick Start
|
|
239
205
|
|
|
@@ -1,8 +1,9 @@
|
|
|
1
|
-
cqrs/__init__.py,sha256=
|
|
1
|
+
cqrs/__init__.py,sha256=W9uUGYuGci-4dIdmMUIjaUyJ31-ZyOU0AY0Od4gS_Gg,6170
|
|
2
2
|
cqrs/_dataclass_utils.py,sha256=OqKF3Gifarcj7rjFWUe60BcBrpt0NePgiXbbiuigMdk,3555
|
|
3
3
|
cqrs/circuit_breaker.py,sha256=OEq6N5YK8-uOSXEsC5IfJxPxXOw-UIgjccrlLv22Tgg,2385
|
|
4
4
|
cqrs/generic_utils.py,sha256=DsUq6oCLpZVBkxMmW5cgRrbEcC_DlNTeXuwVv2LPHmI,1571
|
|
5
5
|
cqrs/sqlalchemy_types.py,sha256=VHt8yzlddU2ZdLZ-c2gcS53TnmtQcb2wju8Si5xPfZE,7430
|
|
6
|
+
cqrs/sqlmodel_base.py,sha256=7Cujk82AhBPP_Q1p3qbINN2OxtoC7qSMAwMSZbOmAhU,1674
|
|
6
7
|
cqrs/types.py,sha256=gbHLEPKZloViUJc-jZZ-HhakZvlLxbzk3FT_VTSHOgc,309
|
|
7
8
|
cqrs/adapters/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
8
9
|
cqrs/adapters/amqp.py,sha256=sKtH9XgVlKPI2-_bGc9CNxbRUi8vydUz3FuwvdSepho,2997
|
|
@@ -72,7 +73,8 @@ cqrs/models/response.py,sha256=tc4-L0lHOdsV82ftEDeglj65KODrV1Z0uyG1-49BhM4,2032
|
|
|
72
73
|
cqrs/outbox/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
73
74
|
cqrs/outbox/mock.py,sha256=9sp6OESCqWadnkSQ7tz6C_5FPlXLkVLbQWoP7lbfkz8,2212
|
|
74
75
|
cqrs/outbox/repository.py,sha256=-NsefTE-Zv9fcYEigCSQXVhDwJ8nC7B7JgGkU2S9dZM,2499
|
|
75
|
-
cqrs/outbox/sqlalchemy.py,sha256=
|
|
76
|
+
cqrs/outbox/sqlalchemy.py,sha256=toWW-HZBgCoKKb2DHmnLAyCEEq-VoPnVGec3ydEBxxQ,10353
|
|
77
|
+
cqrs/outbox/sqlmodel.py,sha256=w1tOyiR0tV7S8vHx0SQsU0j48yA00zW-ehjBrU8mT7Q,5550
|
|
76
78
|
cqrs/requests/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
77
79
|
cqrs/requests/fallback.py,sha256=6hysbuxvLti0laveuMMLazIMkjj5VYBxFcY5wkXEXtI,4431
|
|
78
80
|
cqrs/saga/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
@@ -89,14 +91,15 @@ cqrs/saga/storage/enums.py,sha256=yOAsPFUBA7wY-_7ft5wtJcS3wXdsAzUHQ-8X0iuMXZA,44
|
|
|
89
91
|
cqrs/saga/storage/memory.py,sha256=x8Nuib259Z4s-JO4K1bDnUHavwwz1wYf6Oo2p2oh5R0,12836
|
|
90
92
|
cqrs/saga/storage/models.py,sha256=VF-oXCajhwcRZLAhLfvhHq4HRVU7L2WQJLzVfLUxxPg,919
|
|
91
93
|
cqrs/saga/storage/protocol.py,sha256=7k_z49EpABqPq9kAd3R85wPe_yx4hCjEpvWgrW6kNAQ,14031
|
|
92
|
-
cqrs/saga/storage/sqlalchemy.py,sha256=
|
|
94
|
+
cqrs/saga/storage/sqlalchemy.py,sha256=uYHorxz-DcaYISLcy_toI1x129MrxYsHOyRNYR2bHzs,26084
|
|
95
|
+
cqrs/saga/storage/sqlmodel.py,sha256=7vdJzAmm2uwaGrlfCjUsf-1ndwoKnBGqPwPP6D3YIjA,6006
|
|
93
96
|
cqrs/serializers/__init__.py,sha256=vzCKkQ-zm4Ua6EKhJ8syTxycjn8QK_Zuvz0M5BvcCqk,378
|
|
94
97
|
cqrs/serializers/default.py,sha256=USVd1dwtgMgMd2FlK8BHtc7B61myzWcosHR4VkYEKB4,2617
|
|
95
98
|
cqrs/serializers/json.py,sha256=HBnay7_Yzb_ap78jaewZbKYtI7eNGArg3qba-gF_PF0,628
|
|
96
99
|
cqrs/serializers/protobuf.py,sha256=phqEgQrhx9jLCFQGz7xr-3QJbgJHQmBLeDCzxD9dAhE,1640
|
|
97
100
|
cqrs/serializers/protocol.py,sha256=lRyzsc_p7c5ZWUnzXPptzd4rSUOOu-yR7KrYWPBVER4,588
|
|
98
|
-
python_cqrs-5.
|
|
99
|
-
python_cqrs-5.
|
|
100
|
-
python_cqrs-5.
|
|
101
|
-
python_cqrs-5.
|
|
102
|
-
python_cqrs-5.
|
|
101
|
+
python_cqrs-5.1.0.dist-info/licenses/LICENSE,sha256=eS7m3vXf_ZJ_ZGceKhQUySTjk32aywfCHFFirO4t3qc,1072
|
|
102
|
+
python_cqrs-5.1.0.dist-info/METADATA,sha256=2rNVYMawK8Er2m08BQRen_41j408yK511R1s_2CFcwM,50165
|
|
103
|
+
python_cqrs-5.1.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
|
|
104
|
+
python_cqrs-5.1.0.dist-info/top_level.txt,sha256=ic74ni8Zxy9lirOAcyt4YEKU34e8IlC8RslIQmbWCq0,5
|
|
105
|
+
python_cqrs-5.1.0.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|