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.
- fastapi_modular-0.1.0.dist-info/METADATA +377 -0
- fastapi_modular-0.1.0.dist-info/RECORD +69 -0
- fastapi_modular-0.1.0.dist-info/WHEEL +4 -0
- fastapi_modular-0.1.0.dist-info/entry_points.txt +3 -0
- fastapi_modular-0.1.0.dist-info/licenses/LICENSE +21 -0
- pymodular/__init__.py +74 -0
- pymodular/cli/__init__.py +0 -0
- pymodular/cli/clean.py +39 -0
- pymodular/cli/configure_env.py +569 -0
- pymodular/cli/cong_cu.py +111 -0
- pymodular/cli/info.py +62 -0
- pymodular/cli/install.py +83 -0
- pymodular/cli/main.py +247 -0
- pymodular/cli/new_module.py +492 -0
- pymodular/cli/new_project.py +471 -0
- pymodular/cli/serve.py +59 -0
- pymodular/core/__init__.py +0 -0
- pymodular/core/clock.py +15 -0
- pymodular/core/compat.py +39 -0
- pymodular/core/config.py +495 -0
- pymodular/core/container.py +354 -0
- pymodular/core/context.py +78 -0
- pymodular/core/controller.py +208 -0
- pymodular/core/error_handlers.py +272 -0
- pymodular/core/exceptions.py +104 -0
- pymodular/core/guards.py +117 -0
- pymodular/core/lifespan.py +150 -0
- pymodular/core/logging.py +88 -0
- pymodular/core/metrics.py +190 -0
- pymodular/core/schemas.py +105 -0
- pymodular/core/websocket/__init__.py +31 -0
- pymodular/core/websocket/adapter.py +192 -0
- pymodular/core/websocket/gateway.py +735 -0
- pymodular/core/websocket/namespace.py +148 -0
- pymodular/core/websocket/protocol.py +157 -0
- pymodular/core/websocket/server.py +175 -0
- pymodular/core/websocket/socket.py +241 -0
- pymodular/discovery.py +180 -0
- pymodular/factory.py +126 -0
- pymodular/infrastructure/__init__.py +1 -0
- pymodular/infrastructure/database/__init__.py +8 -0
- pymodular/infrastructure/database/base.py +228 -0
- pymodular/infrastructure/database/circuit.py +207 -0
- pymodular/infrastructure/database/factory.py +88 -0
- pymodular/infrastructure/database/memory.py +112 -0
- pymodular/infrastructure/database/mongo.py +186 -0
- pymodular/infrastructure/database/repository.py +188 -0
- pymodular/infrastructure/database/sql.py +520 -0
- pymodular/infrastructure/kafka/__init__.py +26 -0
- pymodular/infrastructure/kafka/broker.py +231 -0
- pymodular/infrastructure/kafka/consumers.py +371 -0
- pymodular/infrastructure/kafka/metrics.py +17 -0
- pymodular/infrastructure/mqtt/__init__.py +35 -0
- pymodular/infrastructure/mqtt/client.py +292 -0
- pymodular/infrastructure/mqtt/consumers.py +219 -0
- pymodular/infrastructure/mqtt/metrics.py +17 -0
- pymodular/infrastructure/mqtt/patterns.py +116 -0
- pymodular/infrastructure/rabbitmq/__init__.py +33 -0
- pymodular/infrastructure/rabbitmq/broker.py +616 -0
- pymodular/infrastructure/rabbitmq/consumers.py +450 -0
- pymodular/infrastructure/rabbitmq/metrics.py +34 -0
- pymodular/infrastructure/rabbitmq/patterns.py +64 -0
- pymodular/infrastructure/redis/__init__.py +31 -0
- pymodular/infrastructure/redis/client.py +362 -0
- pymodular/infrastructure/redis/metrics.py +20 -0
- pymodular/infrastructure/redis/pubsub.py +262 -0
- pymodular/middleware/__init__.py +0 -0
- pymodular/middleware/request_context.py +164 -0
- 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
|
+
]
|