fastapi-modular 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.
Files changed (69) hide show
  1. fastapi_modular-0.1.0.dist-info/METADATA +377 -0
  2. fastapi_modular-0.1.0.dist-info/RECORD +69 -0
  3. fastapi_modular-0.1.0.dist-info/WHEEL +4 -0
  4. fastapi_modular-0.1.0.dist-info/entry_points.txt +3 -0
  5. fastapi_modular-0.1.0.dist-info/licenses/LICENSE +21 -0
  6. pymodular/__init__.py +74 -0
  7. pymodular/cli/__init__.py +0 -0
  8. pymodular/cli/clean.py +39 -0
  9. pymodular/cli/configure_env.py +569 -0
  10. pymodular/cli/cong_cu.py +111 -0
  11. pymodular/cli/info.py +62 -0
  12. pymodular/cli/install.py +83 -0
  13. pymodular/cli/main.py +247 -0
  14. pymodular/cli/new_module.py +492 -0
  15. pymodular/cli/new_project.py +471 -0
  16. pymodular/cli/serve.py +59 -0
  17. pymodular/core/__init__.py +0 -0
  18. pymodular/core/clock.py +15 -0
  19. pymodular/core/compat.py +39 -0
  20. pymodular/core/config.py +495 -0
  21. pymodular/core/container.py +354 -0
  22. pymodular/core/context.py +78 -0
  23. pymodular/core/controller.py +208 -0
  24. pymodular/core/error_handlers.py +272 -0
  25. pymodular/core/exceptions.py +104 -0
  26. pymodular/core/guards.py +117 -0
  27. pymodular/core/lifespan.py +150 -0
  28. pymodular/core/logging.py +88 -0
  29. pymodular/core/metrics.py +190 -0
  30. pymodular/core/schemas.py +105 -0
  31. pymodular/core/websocket/__init__.py +31 -0
  32. pymodular/core/websocket/adapter.py +192 -0
  33. pymodular/core/websocket/gateway.py +735 -0
  34. pymodular/core/websocket/namespace.py +148 -0
  35. pymodular/core/websocket/protocol.py +157 -0
  36. pymodular/core/websocket/server.py +175 -0
  37. pymodular/core/websocket/socket.py +241 -0
  38. pymodular/discovery.py +180 -0
  39. pymodular/factory.py +126 -0
  40. pymodular/infrastructure/__init__.py +1 -0
  41. pymodular/infrastructure/database/__init__.py +8 -0
  42. pymodular/infrastructure/database/base.py +228 -0
  43. pymodular/infrastructure/database/circuit.py +207 -0
  44. pymodular/infrastructure/database/factory.py +88 -0
  45. pymodular/infrastructure/database/memory.py +112 -0
  46. pymodular/infrastructure/database/mongo.py +186 -0
  47. pymodular/infrastructure/database/repository.py +188 -0
  48. pymodular/infrastructure/database/sql.py +520 -0
  49. pymodular/infrastructure/kafka/__init__.py +26 -0
  50. pymodular/infrastructure/kafka/broker.py +231 -0
  51. pymodular/infrastructure/kafka/consumers.py +371 -0
  52. pymodular/infrastructure/kafka/metrics.py +17 -0
  53. pymodular/infrastructure/mqtt/__init__.py +35 -0
  54. pymodular/infrastructure/mqtt/client.py +292 -0
  55. pymodular/infrastructure/mqtt/consumers.py +219 -0
  56. pymodular/infrastructure/mqtt/metrics.py +17 -0
  57. pymodular/infrastructure/mqtt/patterns.py +116 -0
  58. pymodular/infrastructure/rabbitmq/__init__.py +33 -0
  59. pymodular/infrastructure/rabbitmq/broker.py +616 -0
  60. pymodular/infrastructure/rabbitmq/consumers.py +450 -0
  61. pymodular/infrastructure/rabbitmq/metrics.py +34 -0
  62. pymodular/infrastructure/rabbitmq/patterns.py +64 -0
  63. pymodular/infrastructure/redis/__init__.py +31 -0
  64. pymodular/infrastructure/redis/client.py +362 -0
  65. pymodular/infrastructure/redis/metrics.py +20 -0
  66. pymodular/infrastructure/redis/pubsub.py +262 -0
  67. pymodular/middleware/__init__.py +0 -0
  68. pymodular/middleware/request_context.py +164 -0
  69. pymodular/py.typed +0 -0
@@ -0,0 +1,520 @@
1
+ """Backend SQL cho SQLite và PostgreSQL, dùng SQLAlchemy Core (không ORM).
2
+
3
+ File này CHỈ được import khi settings chọn driver sqlite/postgres — xem
4
+ `factory.py`. Nhờ vậy máy không cài SQLAlchemy vẫn chạy được template.
5
+
6
+ Bảng được suy ra từ dataclass entity, nên entity không phải kế thừa Base hay
7
+ khai báo Column. Đổi lại, `create_all()` chỉ tạo bảng còn thiếu và không biết
8
+ migrate — production nên dùng Alembic thay cho `auto_create`.
9
+
10
+ Transaction: mỗi request dùng chung một connection do `SqlUnitOfWork` giữ
11
+ (provider request-scoped), commit khi handler chạy xong, rollback nếu có lỗi.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ from collections.abc import AsyncIterator
17
+ from contextlib import asynccontextmanager
18
+ from datetime import datetime
19
+ from enum import Enum
20
+ from typing import Any, TypeVar
21
+
22
+ from sqlalchemy import (
23
+ Boolean,
24
+ Column,
25
+ DateTime,
26
+ Float,
27
+ Index,
28
+ Integer,
29
+ MetaData,
30
+ String,
31
+ Table,
32
+ func,
33
+ select,
34
+ )
35
+ from sqlalchemy import (
36
+ delete as sql_delete,
37
+ )
38
+ from sqlalchemy import (
39
+ insert as sql_insert,
40
+ )
41
+ from sqlalchemy import (
42
+ update as sql_update,
43
+ )
44
+ from sqlalchemy.ext.asyncio import AsyncConnection, AsyncEngine, create_async_engine
45
+
46
+ from pymodular.core.container import Scope, injectable
47
+ from pymodular.core.logging import get_logger
48
+ from pymodular.infrastructure.database.base import (
49
+ DatabaseBackend,
50
+ Filters,
51
+ Match,
52
+ active_filters,
53
+ from_document,
54
+ mapping_for,
55
+ to_document,
56
+ )
57
+
58
+ log = get_logger(__name__)
59
+
60
+ E = TypeVar("E")
61
+
62
+ _COLUMN_TYPES: dict[type, Any] = {
63
+ str: String,
64
+ bool: Boolean,
65
+ int: Integer,
66
+ float: Float,
67
+ datetime: DateTime(timezone=True),
68
+ }
69
+
70
+
71
+ def _type_name(type_: Any, dialect: Any) -> str:
72
+ """Tên kiểu đã chuẩn hoá, bỏ phần độ dài, để so hai bên cho công bằng."""
73
+ try:
74
+ rendered = type_.compile(dialect=dialect)
75
+ except Exception: # noqa: BLE001 - kiểu lạ không compile được thì lấy repr
76
+ rendered = str(type_)
77
+ return rendered.split("(")[0].strip().upper()
78
+
79
+
80
+ def _column_type(declared: type) -> Any:
81
+ if isinstance(declared, type) and issubclass(declared, Enum):
82
+ return String(64) # Enum lưu bằng .value cho dễ đọc và dễ migrate
83
+ return _COLUMN_TYPES.get(declared, String)
84
+
85
+
86
+ @injectable(scope=Scope.REQUEST)
87
+ class SqlUnitOfWork:
88
+ """Một connection + một transaction cho mỗi request.
89
+
90
+ Container tạo nó khi backend cần lần đầu trong request, và gọi
91
+ `on_request_end` lúc đóng request scope — commit nếu handler thành công,
92
+ rollback nếu có exception. Vì việc này chạy TRƯỚC khi response được gửi
93
+ (xem controller.py), client đọc lại ngay sau khi ghi sẽ thấy dữ liệu mới.
94
+ """
95
+
96
+ def __init__(self) -> None:
97
+ self._connection: AsyncConnection | None = None
98
+
99
+ async def connection(self, engine: AsyncEngine) -> AsyncConnection:
100
+ if self._connection is None:
101
+ self._connection = await engine.connect()
102
+ await self._connection.begin()
103
+ return self._connection
104
+
105
+ async def on_request_end(self, error: BaseException | None) -> None:
106
+ if self._connection is None:
107
+ return
108
+ try:
109
+ if error is None:
110
+ await self._connection.commit()
111
+ else:
112
+ await self._connection.rollback()
113
+ finally:
114
+ await self._connection.close()
115
+ self._connection = None
116
+
117
+
118
+ def build_metadata(*entities: type) -> MetaData:
119
+ """Dựng MetaData từ các entity — dùng cho Alembic autogenerate.
120
+
121
+ Không cần engine, không cần kết nối: chỉ đọc dataclass và suy ra bảng.
122
+ """
123
+ metadata = MetaData()
124
+ for entity in entities:
125
+ mapping = mapping_for(entity)
126
+ Table(
127
+ mapping.storage,
128
+ metadata,
129
+ *(
130
+ Column(
131
+ name,
132
+ _column_type(declared),
133
+ primary_key=(name == "id"),
134
+ nullable=(name != "id"),
135
+ )
136
+ for name, declared in mapping.fields.items()
137
+ ),
138
+ *(
139
+ Index(name, *columns, unique=is_unique)
140
+ for name, columns, is_unique in mapping.index_specs()
141
+ ),
142
+ )
143
+ return metadata
144
+
145
+
146
+ class SqlBackend(DatabaseBackend):
147
+ def __init__(
148
+ self,
149
+ dsn: str,
150
+ *,
151
+ echo: bool = False,
152
+ schema_mode: str = "create",
153
+ drop_columns: bool = False,
154
+ pool_pre_ping: bool = True,
155
+ pool_size: int = 5,
156
+ max_overflow: int = 10,
157
+ pool_recycle_seconds: int = 1800,
158
+ connect_timeout_seconds: float = 10.0,
159
+ query_timeout_seconds: float = 15.0,
160
+ ) -> None:
161
+ self.name = "postgres" if dsn.startswith("postgresql") else "sqlite"
162
+ self._dsn = dsn
163
+ self._echo = echo
164
+ self._schema_mode = schema_mode
165
+ self._drop_columns = drop_columns
166
+ self._pool_pre_ping = pool_pre_ping
167
+ self._pool_size = pool_size
168
+ self._max_overflow = max_overflow
169
+ self._pool_recycle = pool_recycle_seconds
170
+ self._connect_timeout = connect_timeout_seconds
171
+ self._query_timeout = query_timeout_seconds
172
+ self._engine: AsyncEngine | None = None
173
+ self._metadata = MetaData()
174
+ self._tables: dict[str, Table] = {}
175
+
176
+ # ------------------------------------------------------------------ vòng đời
177
+ def _engine_kwargs(self) -> dict[str, Any]:
178
+ kwargs: dict[str, Any] = {
179
+ "echo": self._echo,
180
+ "future": True,
181
+ # Kiểm tra connection còn sống trước khi giao cho request. Đây là
182
+ # thứ khiến database restart trở nên vô hình với client.
183
+ "pool_pre_ping": self._pool_pre_ping,
184
+ }
185
+ if self.name == "postgres":
186
+ # SQLite dùng NullPool nên không nhận các tham số pool này.
187
+ kwargs.update(
188
+ pool_size=self._pool_size,
189
+ max_overflow=self._max_overflow,
190
+ pool_recycle=self._pool_recycle,
191
+ connect_args={
192
+ "timeout": self._connect_timeout,
193
+ # Chặn cả câu lệnh đã gửi đi, không chỉ lúc mở kết nối.
194
+ "command_timeout": self._query_timeout,
195
+ },
196
+ )
197
+ return kwargs
198
+
199
+ async def startup(self) -> None:
200
+ self._engine = create_async_engine(self._dsn, **self._engine_kwargs())
201
+ log.info("db.connected", backend=self.name, pre_ping=self._pool_pre_ping)
202
+
203
+ async def shutdown(self) -> None:
204
+ if self._engine is not None:
205
+ await self._engine.dispose()
206
+ self._engine = None
207
+
208
+ async def ping(self) -> bool:
209
+ from sqlalchemy import text
210
+
211
+ async with self._engine.connect() as conn: # type: ignore[union-attr]
212
+ await conn.execute(text("SELECT 1"))
213
+ return True
214
+
215
+ # ------------------------------------------------------------------ ánh xạ
216
+ def _table(self, entity: type) -> Table:
217
+ mapping = mapping_for(entity)
218
+ table = self._tables.get(mapping.storage)
219
+ if table is not None:
220
+ return table
221
+
222
+ columns = [
223
+ Column(
224
+ name,
225
+ _column_type(declared),
226
+ primary_key=(name == "id"),
227
+ nullable=(name != "id"),
228
+ )
229
+ for name, declared in mapping.fields.items()
230
+ ]
231
+ table = Table(mapping.storage, self._metadata, *columns)
232
+ self._tables[mapping.storage] = table
233
+ return table
234
+
235
+ async def create_schema(self, *entities: type) -> None:
236
+ """Đưa schema về khớp entity, theo mức đã cấu hình.
237
+
238
+ - "off" : không làm gì.
239
+ - "create" : chỉ CREATE TABLE cho bảng còn thiếu.
240
+ - "sync" : thêm cột mới bằng ALTER TABLE ADD COLUMN; cột thừa thì xoá
241
+ (nếu drop_columns=True) hoặc chỉ cảnh báo; cột lệch kiểu
242
+ chỉ cảnh báo, không tự đổi.
243
+
244
+ Vì sao không tự đổi kiểu cột: mỗi database một cú pháp, và phép đổi có
245
+ thể mất dữ liệu (VARCHAR -> INTEGER) hoặc khoá bảng rất lâu. Đó là việc
246
+ của một migration có review, không phải của lúc khởi động.
247
+ """
248
+ if self._engine is None:
249
+ return
250
+
251
+ for entity in entities:
252
+ self._table(entity)
253
+
254
+ if self._schema_mode == "off":
255
+ # Không đụng schema, nhưng vẫn phải soi: thiếu unique index thì
256
+ # ràng buộc duy nhất không tồn tại, và hai request đồng thời sẽ
257
+ # cùng ghi được bản trùng.
258
+ await self._audit_indexes(*entities)
259
+ return
260
+
261
+ async with self._engine.begin() as conn:
262
+ await conn.run_sync(self._metadata.create_all)
263
+
264
+ await self._ensure_indexes(*entities)
265
+
266
+ if self._schema_mode != "sync":
267
+ log.info("db.schema_ready", mode=self._schema_mode, tables=sorted(self._tables))
268
+ return
269
+
270
+ added, dropped, mismatched, kept = [], [], [], []
271
+ async with self._engine.begin() as conn:
272
+ for table in self._tables.values():
273
+ a, d, m, k = await self._sync_table(conn, table)
274
+ added += a
275
+ dropped += d
276
+ mismatched += m
277
+ kept += k
278
+
279
+ log.info(
280
+ "db.schema_ready",
281
+ mode="sync",
282
+ tables=sorted(self._tables),
283
+ added=added or None,
284
+ dropped=dropped or None,
285
+ )
286
+ for column in mismatched:
287
+ log.warning("db.column_type_mismatch", column=column,
288
+ hint="đổi kiểu cột phải làm bằng migration, không tự động")
289
+ for column in kept:
290
+ log.warning("db.extra_column_kept", column=column,
291
+ hint="đặt APP_DB__DROP_COLUMNS=true nếu muốn xoá (mất dữ liệu)")
292
+
293
+ async def _audit_indexes(self, *entities: type) -> None:
294
+ """Chỉ kiểm tra, không tạo — dùng khi schema_mode="off"."""
295
+ from sqlalchemy import inspect as sa_inspect
296
+
297
+ assert self._engine is not None
298
+ missing: list[str] = []
299
+
300
+ async with self._engine.connect() as conn:
301
+ for entity in entities:
302
+ mapping = mapping_for(entity)
303
+ if not (mapping.unique or mapping.indexes):
304
+ continue
305
+ try:
306
+ existing = await conn.run_sync(
307
+ lambda c, name=mapping.storage: {
308
+ i["name"] for i in sa_inspect(c).get_indexes(name)
309
+ }
310
+ )
311
+ except Exception: # noqa: BLE001 - bảng chưa tồn tại
312
+ continue
313
+ for name, columns, is_unique in mapping.index_specs():
314
+ if name in existing:
315
+ continue
316
+ label = f"{mapping.storage}({', '.join(columns)})"
317
+ missing.append(f"{label} (UNIQUE)" if is_unique else label)
318
+
319
+ if missing:
320
+ log.warning(
321
+ "db.indexes_missing",
322
+ indexes=missing,
323
+ hint="schema_mode='off' nên index không được tạo tự động — "
324
+ "hãy thêm chúng vào migration, nếu không ràng buộc duy nhất "
325
+ "sẽ không có hiệu lực",
326
+ )
327
+
328
+ async def _ensure_indexes(self, *entities: type) -> None:
329
+ """Tạo index và unique index đã khai báo ở @entity.
330
+
331
+ `CREATE [UNIQUE] INDEX IF NOT EXISTS` chạy được trên cả SQLite lẫn
332
+ PostgreSQL và lặp lại vô hại, nên gọi mỗi lần khởi động cũng không sao.
333
+ """
334
+ from sqlalchemy import text
335
+
336
+ assert self._engine is not None
337
+ for entity in entities:
338
+ mapping = mapping_for(entity)
339
+ for name, columns, is_unique in mapping.index_specs():
340
+ kind = "UNIQUE INDEX" if is_unique else "INDEX"
341
+ statement = (
342
+ f"CREATE {kind} IF NOT EXISTS {name} "
343
+ f"ON {mapping.storage} ({', '.join(columns)})"
344
+ )
345
+ try:
346
+ async with self._engine.begin() as conn:
347
+ await conn.execute(text(statement))
348
+ except Exception as exc: # noqa: BLE001 - index hỏng không được làm chết app
349
+ # Hay gặp nhất: dữ liệu cũ đã có bản trùng nên không tạo
350
+ # được unique index. Không làm app chết, nhưng phải kêu to
351
+ # vì lúc này ràng buộc KHÔNG có hiệu lực.
352
+ log.error(
353
+ "db.index_failed",
354
+ index=name,
355
+ columns=list(columns),
356
+ unique=is_unique,
357
+ error=str(exc).splitlines()[0],
358
+ hint="dọn bản ghi trùng rồi khởi động lại",
359
+ )
360
+
361
+ async def _sync_table(self, conn: AsyncConnection, table: Table):
362
+ from sqlalchemy import inspect as sa_inspect
363
+ from sqlalchemy import text
364
+
365
+ existing = await conn.run_sync(
366
+ lambda sync_conn: {c["name"]: c for c in sa_inspect(sync_conn).get_columns(table.name)}
367
+ )
368
+ dialect = conn.engine.dialect
369
+ added, dropped, mismatched, kept = [], [], [], []
370
+
371
+ # cột có trong entity nhưng chưa có dưới database -> thêm
372
+ for column in table.columns:
373
+ if column.name in existing:
374
+ # Phải compile CẢ HAI bằng cùng dialect. Dùng str() cho kiểu do
375
+ # inspector trả về sẽ mất thông tin (TIMESTAMP WITH TIME ZONE
376
+ # in ra thành "TIMESTAMP") và sinh cảnh báo sai.
377
+ want = _type_name(column.type, dialect)
378
+ have = _type_name(existing[column.name]["type"], dialect)
379
+ if want != have:
380
+ mismatched.append(f"{table.name}.{column.name}: {have} -> {want}")
381
+ continue
382
+
383
+ ddl = f"{column.name} {column.type.compile(dialect=dialect)}"
384
+ await conn.execute(text(f"ALTER TABLE {table.name} ADD COLUMN {ddl}"))
385
+ added.append(f"{table.name}.{column.name}")
386
+
387
+ # cột còn dưới database nhưng entity đã bỏ
388
+ for name in existing:
389
+ if name in table.c:
390
+ continue
391
+ if not self._drop_columns:
392
+ kept.append(f"{table.name}.{name}")
393
+ continue
394
+ try:
395
+ await conn.execute(text(f"ALTER TABLE {table.name} DROP COLUMN {name}"))
396
+ dropped.append(f"{table.name}.{name}")
397
+ except Exception as exc: # noqa: BLE001 - SQLite cũ không DROP COLUMN được
398
+ kept.append(f"{table.name}.{name}")
399
+ log.warning("db.drop_column_failed", column=f"{table.name}.{name}", error=str(exc))
400
+
401
+ return added, dropped, mismatched, kept
402
+
403
+ # ------------------------------------------------------------------ kết nối
404
+ @asynccontextmanager
405
+ async def _conn(self) -> AsyncIterator[AsyncConnection]:
406
+ """Dùng connection của request nếu có; ngoài request thì mở tạm một cái."""
407
+ from pymodular.core.container import container
408
+
409
+ assert self._engine is not None, "backend chưa startup()"
410
+ try:
411
+ uow = container.resolve(SqlUnitOfWork)
412
+ except RuntimeError:
413
+ async with self._engine.begin() as conn: # ngoài request: tự commit
414
+ yield conn
415
+ return
416
+ yield await uow.connection(self._engine)
417
+
418
+ def _where(self, table: Table, filters: Filters) -> list[Any]:
419
+ return [table.c[k] == v for k, v in active_filters(filters).items() if k in table.c]
420
+
421
+ def _row_to_entity(self, entity: type[E], row: Any) -> E:
422
+ return from_document(entity, dict(row._mapping))
423
+
424
+ # ------------------------------------------------------------------ truy vấn
425
+ async def get(self, entity: type[E], id_: str) -> E | None:
426
+ table = self._table(entity)
427
+ async with self._conn() as conn:
428
+ row = (await conn.execute(select(table).where(table.c.id == id_))).first()
429
+ return self._row_to_entity(entity, row) if row is not None else None
430
+
431
+ async def find(
432
+ self,
433
+ entity: type[E],
434
+ *,
435
+ filters: Filters,
436
+ match: Match = None,
437
+ order_by: str | None = None,
438
+ limit: int | None = None,
439
+ offset: int = 0,
440
+ ) -> list[E]:
441
+ table = self._table(entity)
442
+ stmt = select(table).where(*self._where(table, filters))
443
+ if order_by and order_by in table.c:
444
+ stmt = stmt.order_by(table.c[order_by])
445
+
446
+ # `match=` là predicate Python nên KHÔNG đẩy xuống SQL được: phải lấy
447
+ # về rồi lọc. Chỉ phân trang ở SQL khi không có match.
448
+ if match is None:
449
+ if offset:
450
+ stmt = stmt.offset(offset)
451
+ if limit is not None:
452
+ stmt = stmt.limit(limit)
453
+
454
+ async with self._conn() as conn:
455
+ rows = (await conn.execute(stmt)).fetchall()
456
+
457
+ items = [self._row_to_entity(entity, r) for r in rows]
458
+ if match is not None:
459
+ items = [o for o in items if match(o)][offset:]
460
+ if limit is not None:
461
+ items = items[:limit]
462
+ return items
463
+
464
+ async def find_one(
465
+ self, entity: type[E], *, filters: Filters, match: Match = None
466
+ ) -> E | None:
467
+ items = await self.find(entity, filters=filters, match=match, limit=1)
468
+ return items[0] if items else None
469
+
470
+ async def count(
471
+ self, entity: type[E], *, filters: Filters, match: Match = None
472
+ ) -> int:
473
+ if match is not None:
474
+ return len(await self.find(entity, filters=filters, match=match))
475
+ table = self._table(entity)
476
+ stmt = select(func.count()).select_from(table).where(*self._where(table, filters))
477
+ async with self._conn() as conn:
478
+ return int((await conn.execute(stmt)).scalar_one())
479
+
480
+ # ------------------------------------------------------------------ ghi
481
+ async def save(self, entity: type[E], obj: E) -> E:
482
+ import uuid
483
+
484
+ table = self._table(entity)
485
+ is_new = not getattr(obj, "id", None)
486
+ if is_new:
487
+ obj.id = uuid.uuid4().hex # type: ignore[attr-defined]
488
+
489
+ values = to_document(obj)
490
+ async with self._conn() as conn:
491
+ if is_new:
492
+ await conn.execute(sql_insert(table).values(**values))
493
+ else:
494
+ result = await conn.execute(
495
+ sql_update(table).where(table.c.id == obj.id).values(**values) # type: ignore[attr-defined]
496
+ )
497
+ if result.rowcount == 0:
498
+ await conn.execute(sql_insert(table).values(**values))
499
+ return obj
500
+
501
+ async def delete(self, entity: type[E], id_: str) -> bool:
502
+ table = self._table(entity)
503
+ async with self._conn() as conn:
504
+ result = await conn.execute(sql_delete(table).where(table.c.id == id_))
505
+ return result.rowcount > 0
506
+
507
+ async def delete_where(
508
+ self, entity: type[E], *, filters: Filters, match: Match = None
509
+ ) -> int:
510
+ if match is not None:
511
+ victims = await self.find(entity, filters=filters, match=match)
512
+ removed = 0
513
+ for obj in victims:
514
+ removed += int(await self.delete(entity, obj.id)) # type: ignore[attr-defined]
515
+ return removed
516
+
517
+ table = self._table(entity)
518
+ async with self._conn() as conn:
519
+ result = await conn.execute(sql_delete(table).where(*self._where(table, filters)))
520
+ return int(result.rowcount)
@@ -0,0 +1,26 @@
1
+ """Lớp Kafka — TUỲ CHỌN, và độc lập với mọi thứ khác.
2
+
3
+ pip install 'fastapi-modular[kafka]' # cài aiokafka + ghi sẵn APP_KAFKA__* vào .env
4
+
5
+ Dùng khi cần nhật ký sự kiện ĐỌC LẠI ĐƯỢC: tin nằm lại theo thời gian giữ của
6
+ topic, nhiều nhóm consumer đọc cùng một dòng tin ở các vị trí khác nhau. Không
7
+ dùng thì để `APP_KAFKA__ENABLED=false` (mặc định).
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from pymodular.infrastructure.kafka.broker import KafkaBroker
13
+ from pymodular.infrastructure.kafka.consumers import (
14
+ KafkaRunner,
15
+ PermanentMessageError,
16
+ discover_kafka_subscribers,
17
+ kafka_subscriber,
18
+ )
19
+
20
+ __all__ = [
21
+ "KafkaBroker",
22
+ "KafkaRunner",
23
+ "PermanentMessageError",
24
+ "discover_kafka_subscribers",
25
+ "kafka_subscriber",
26
+ ]