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,228 @@
1
+ """Hợp đồng chung cho mọi backend database, và phần ánh xạ entity <-> bản ghi.
2
+
3
+ Ba backend (memory / SQL / MongoDB) đều cài đúng bộ method dưới đây, nên tầng
4
+ `Repository` ở core không cần biết đang chạy trên cái nào.
5
+
6
+ Ánh xạ dựa hoàn toàn vào dataclass entity: tên trường thành tên cột hoặc khoá
7
+ document, kiểu Python thành kiểu cột. Entity vì vậy vẫn là dataclass thuần,
8
+ không dính ORM.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import dataclasses
14
+ import hashlib
15
+ from collections.abc import Callable, Sequence
16
+ from datetime import datetime
17
+ from enum import Enum
18
+ from functools import cache
19
+ from typing import Any, Protocol, TypeVar, get_args, get_type_hints
20
+
21
+ from pymodular.core.compat import UTC, TimeoutErrors
22
+
23
+ E = TypeVar("E")
24
+
25
+ Filters = dict[str, Any]
26
+ Match = Callable[[Any], bool] | None
27
+
28
+
29
+ MAX_INDEX_NAME = 63 # PostgreSQL cắt tên định danh ở 63 ký tự
30
+
31
+
32
+ def index_name(prefix: str, storage: str, columns: Sequence[str]) -> str:
33
+ """Tên index ổn định, không vượt giới hạn độ dài của PostgreSQL."""
34
+ name = f"{prefix}_{storage}_{'_'.join(columns)}"
35
+ if len(name) <= MAX_INDEX_NAME:
36
+ return name
37
+ digest = hashlib.sha1("_".join(columns).encode()).hexdigest()[:8]
38
+ return f"{prefix}_{storage[:40]}_{digest}"
39
+
40
+
41
+ @dataclasses.dataclass(frozen=True, slots=True)
42
+ class EntityMapping:
43
+ entity: type
44
+ storage: str # tên bảng (SQL) hoặc collection (Mongo)
45
+ fields: dict[str, type] # tên trường -> kiểu đã giải
46
+ unique: tuple[tuple[str, ...], ...] # mỗi phần tử là một cột hoặc một cụm cột
47
+ indexes: tuple[tuple[str, ...], ...]
48
+
49
+ def index_specs(self) -> list[tuple[str, tuple[str, ...], bool]]:
50
+ """[(tên index, các cột, có unique không)] cho mọi index đã khai báo."""
51
+ return [
52
+ *((index_name("uq", self.storage, cols), cols, True) for cols in self.unique),
53
+ *((index_name("ix", self.storage, cols), cols, False) for cols in self.indexes),
54
+ ]
55
+
56
+
57
+ @cache
58
+ def mapping_for(entity: type) -> EntityMapping:
59
+ if not dataclasses.is_dataclass(entity):
60
+ raise TypeError(f"{entity.__name__} phải là dataclass mới ánh xạ được")
61
+
62
+ hints = get_type_hints(entity)
63
+ fields = {f.name: hints.get(f.name, str) for f in dataclasses.fields(entity)}
64
+ return EntityMapping(
65
+ entity=entity,
66
+ storage=getattr(entity, "__storage_name__", f"{entity.__name__.lower()}s"),
67
+ fields=fields,
68
+ unique=tuple(getattr(entity, "__storage_unique__", ())),
69
+ indexes=tuple(getattr(entity, "__storage_indexes__", ())),
70
+ )
71
+
72
+
73
+ def to_document(obj: Any) -> dict[str, Any]:
74
+ """Entity -> dict thuần (Enum thành giá trị, datetime giữ nguyên)."""
75
+ doc: dict[str, Any] = {}
76
+ for field in dataclasses.fields(obj):
77
+ value = getattr(obj, field.name)
78
+ doc[field.name] = value.value if isinstance(value, Enum) else value
79
+ return doc
80
+
81
+
82
+ def _allows_none(declared: Any) -> bool:
83
+ """Kiểu có chấp nhận None không (Optional[X] hoặc X | None)."""
84
+ return declared is Any or type(None) in get_args(declared)
85
+
86
+
87
+ @cache
88
+ def _defaults_of(entity: type) -> dict[str, Callable[[], Any]]:
89
+ """Hàm sinh giá trị mặc định cho từng trường có khai báo default."""
90
+ out: dict[str, Callable[[], Any]] = {}
91
+ for f in dataclasses.fields(entity):
92
+ if f.default is not dataclasses.MISSING:
93
+ out[f.name] = lambda value=f.default: value
94
+ elif f.default_factory is not dataclasses.MISSING: # type: ignore[misc]
95
+ out[f.name] = f.default_factory # type: ignore[assignment]
96
+ return out
97
+
98
+
99
+ def from_document(entity: type[E], doc: dict[str, Any]) -> E:
100
+ """dict -> entity, ép lại Enum/datetime và bù giá trị mặc định.
101
+
102
+ Bù mặc định là chỗ quan trọng khi schema tiến hoá: thêm một trường vào
103
+ entity thì bản ghi cũ chưa có giá trị (SQL trả NULL, Mongo thiếu hẳn khoá).
104
+ Nếu kiểu khai báo không nhận None mà entity có default, ta dùng default —
105
+ nhờ vậy dữ liệu cũ đọc ra vẫn hợp lệ thay vì mang None trái kiểu.
106
+ """
107
+ mapping = mapping_for(entity)
108
+ defaults = _defaults_of(entity)
109
+ kwargs: dict[str, Any] = {}
110
+
111
+ for name, declared in mapping.fields.items():
112
+ value = doc.get(name)
113
+
114
+ if value is None and not _allows_none(declared) and name in defaults:
115
+ value = defaults[name]()
116
+ elif value is not None:
117
+ if isinstance(declared, type) and issubclass(declared, Enum):
118
+ value = declared(value)
119
+ elif declared is datetime:
120
+ if isinstance(value, str):
121
+ value = datetime.fromisoformat(value)
122
+ if isinstance(value, datetime) and value.tzinfo is None:
123
+ # SQLite và MongoDB trả datetime KHÔNG mang múi giờ (cả hai
124
+ # lưu theo UTC nhưng không kèm tzinfo). Gắn lại UTC ở đây để
125
+ # ba driver cho ra cùng một dạng, và để một response không
126
+ # lẫn lộn "có Z" với "không Z".
127
+ value = value.replace(tzinfo=UTC)
128
+
129
+ kwargs[name] = value
130
+
131
+ return entity(**kwargs) # type: ignore[call-arg]
132
+
133
+
134
+ def matches(obj: Any, filters: Filters, match: Match) -> bool:
135
+ """Lọc trong Python — dùng cho backend memory và cho tham số `match=`."""
136
+ for key, value in filters.items():
137
+ if getattr(obj, key, None) != value:
138
+ return False
139
+ return match is None or match(obj)
140
+
141
+
142
+ def active_filters(filters: Filters) -> Filters:
143
+ """Bỏ mọi điều kiện có giá trị None (quy ước: None = không lọc)."""
144
+ return {k: v for k, v in filters.items() if v is not None}
145
+
146
+
147
+ class DuplicateKeyViolation(Exception):
148
+ """Vi phạm ràng buộc duy nhất, do backend không có sẵn kiểu lỗi riêng.
149
+
150
+ SQL ném IntegrityError, Mongo ném DuplicateKeyError; backend memory dùng
151
+ lớp này để ba đường đi cho ra cùng một kết quả HTTP 409.
152
+ """
153
+
154
+ def __init__(self, storage: str, columns: Sequence[str], values: Sequence[Any]) -> None:
155
+ self.storage = storage
156
+ self.columns = tuple(columns)
157
+ self.values = tuple(values)
158
+ pairs = ", ".join(f"{c}={v!r}" for c, v in zip(self.columns, self.values, strict=True))
159
+ super().__init__(f"{storage}: đã tồn tại bản ghi với {pairs}")
160
+
161
+
162
+ # Tên lớp lỗi "tạm thời" của các driver. Dùng tên thay vì import để file này
163
+ # không phụ thuộc vào thư viện của driver nào.
164
+ _TRANSIENT_NAMES = frozenset({
165
+ "ServerSelectionTimeoutError", # pymongo: chưa chọn được server
166
+ "AutoReconnect", # pymongo: mất kết nối, sẽ tự nối lại
167
+ "NetworkTimeout", # pymongo
168
+ "ConnectionFailure", # pymongo (lớp cha)
169
+ "CannotConnectNowError", # asyncpg: database đang khởi động
170
+ "TooManyConnectionsError", # asyncpg: hết slot, chờ chút sẽ có
171
+ })
172
+
173
+
174
+ def is_transient_error(exc: BaseException) -> bool:
175
+ """Lỗi này có khả năng tự hết nếu thử lại không?
176
+
177
+ Phân biệt "database chưa kịp lên" (đáng thử lại) với "sai mật khẩu / sai
178
+ tên database" (thử lại bao nhiêu lần cũng vậy, chỉ làm chậm lúc phát hiện
179
+ cấu hình sai). Duyệt cả chuỗi __cause__ vì driver hay bọc lỗi gốc lại.
180
+ """
181
+ seen: set[int] = set()
182
+ current: BaseException | None = exc
183
+
184
+ while current is not None and id(current) not in seen:
185
+ seen.add(id(current))
186
+ if isinstance(current, (ConnectionError, *TimeoutErrors)):
187
+ return True
188
+ if type(current).__name__ in _TRANSIENT_NAMES:
189
+ return True
190
+ # OSError chung (host chưa phân giải được, mạng chưa lên...)
191
+ if isinstance(current, OSError) and not isinstance(current, (IsADirectoryError, NotADirectoryError, PermissionError)):
192
+ return True
193
+ current = current.__cause__ or current.__context__
194
+
195
+ return False
196
+
197
+
198
+ class DatabaseBackend(Protocol):
199
+ """Bộ method mà mọi backend phải có."""
200
+
201
+ name: str
202
+
203
+ async def startup(self) -> None: ...
204
+ async def shutdown(self) -> None: ...
205
+ async def ping(self) -> bool: ...
206
+
207
+ async def get(self, entity: type[E], id_: str) -> E | None: ...
208
+ async def find(
209
+ self,
210
+ entity: type[E],
211
+ *,
212
+ filters: Filters,
213
+ match: Match = None,
214
+ order_by: str | None = None,
215
+ limit: int | None = None,
216
+ offset: int = 0,
217
+ ) -> list[E]: ...
218
+ async def find_one(
219
+ self, entity: type[E], *, filters: Filters, match: Match = None
220
+ ) -> E | None: ...
221
+ async def count(
222
+ self, entity: type[E], *, filters: Filters, match: Match = None
223
+ ) -> int: ...
224
+ async def save(self, entity: type[E], obj: E) -> E: ...
225
+ async def delete(self, entity: type[E], id_: str) -> bool: ...
226
+ async def delete_where(
227
+ self, entity: type[E], *, filters: Filters, match: Match = None
228
+ ) -> int: ...
@@ -0,0 +1,207 @@
1
+ """Circuit breaker + hạn thời gian cho database.
2
+
3
+ Vấn đề khi không có nó: database chết, mỗi request vẫn đi tới tận nơi, chờ hết
4
+ `connect_timeout` rồi mới nhận 503. Với timeout 10 giây và 100 request/giây,
5
+ toàn bộ worker bị giữ chỗ chờ vô ích, và API chết theo database.
6
+
7
+ Cách chữa: đếm số lần hỏng liên tiếp. Quá ngưỡng thì "ngắt mạch" — mọi request
8
+ trả 503 NGAY, không chạm database. Sau `reset_seconds` cho đúng MỘT request đi
9
+ thử; thành công thì đóng mạch lại, hỏng thì mở tiếp.
10
+
11
+ Lớp bọc này còn áp một hạn thời gian cứng cho mọi lời gọi. Cần thiết vì
12
+ timeout của từng driver không phủ hết mọi tình huống: database bị đóng băng
13
+ giữa lúc đang trả lời thì connection vẫn "mở", `connect_timeout` không cứu
14
+ được, và request treo cho tới khi client bỏ cuộc. `asyncio.wait_for` ở đây chặn
15
+ được mọi trường hợp, bất kể driver nào bên dưới.
16
+
17
+ Ba trạng thái:
18
+
19
+ closed --(hỏng liên tiếp >= ngưỡng)--> open
20
+ open --(hết reset_seconds)---------> half_open
21
+ half_open --(1 request thành công)----> closed
22
+ --(1 request hỏng)-----------> open
23
+ """
24
+
25
+ from __future__ import annotations
26
+
27
+ import asyncio
28
+ import time
29
+ from typing import Any, TypeVar
30
+
31
+ from pymodular.core.compat import StrEnum, TimeoutErrors
32
+ from pymodular.core.logging import get_logger
33
+ from pymodular.infrastructure.database.base import (
34
+ DatabaseBackend,
35
+ Filters,
36
+ Match,
37
+ is_transient_error,
38
+ )
39
+
40
+ log = get_logger(__name__)
41
+
42
+ E = TypeVar("E")
43
+
44
+
45
+ class CircuitState(StrEnum):
46
+ CLOSED = "closed"
47
+ OPEN = "open"
48
+ HALF_OPEN = "half_open"
49
+
50
+
51
+ class CircuitOpenError(Exception):
52
+ """Mạch đang ngắt — không thử database nữa cho tới khi hết thời gian nghỉ."""
53
+
54
+ def __init__(self, backend: str, retry_after: float) -> None:
55
+ self.backend = backend
56
+ self.retry_after = retry_after
57
+ super().__init__(
58
+ f"Database '{backend}' đang bị ngắt mạch, thử lại sau {retry_after:.0f}s"
59
+ )
60
+
61
+
62
+ class CircuitBreakerBackend(DatabaseBackend):
63
+ """Bọc quanh một backend thật, đếm hỏng và ngắt mạch khi cần.
64
+
65
+ Chỉ tính lỗi KẾT NỐI (`is_transient_error`). Lỗi nghiệp vụ như trùng khoá
66
+ không được làm mạch ngắt — đó là database đang hoạt động tốt.
67
+ """
68
+
69
+ def __init__(
70
+ self,
71
+ inner: DatabaseBackend,
72
+ *,
73
+ failure_threshold: int = 5,
74
+ reset_seconds: float = 10.0,
75
+ call_timeout_seconds: float = 15.0,
76
+ breaker_enabled: bool = True,
77
+ ) -> None:
78
+ self._inner = inner
79
+ self._threshold = failure_threshold
80
+ self._reset_seconds = reset_seconds
81
+ self._call_timeout = call_timeout_seconds
82
+ # Tắt breaker chỉ tắt phần ngắt mạch; HẠN THỜI GIAN vẫn áp dụng, vì
83
+ # thiếu nó thì một database bị treo sẽ giữ chỗ mọi worker vô thời hạn.
84
+ self._breaker_enabled = breaker_enabled
85
+
86
+ self._state = CircuitState.CLOSED
87
+ self._failures = 0
88
+ self._opened_at = 0.0
89
+
90
+ # ------------------------------------------------------------------ trạng thái
91
+ @property
92
+ def name(self) -> str:
93
+ return self._inner.name
94
+
95
+ @property
96
+ def state(self) -> CircuitState:
97
+ return self._state
98
+
99
+ @property
100
+ def stats(self) -> dict[str, Any]:
101
+ return {
102
+ "enabled": self._breaker_enabled,
103
+ "state": self._state.value,
104
+ "failures": self._failures,
105
+ "threshold": self._threshold,
106
+ "call_timeout": self._call_timeout,
107
+ }
108
+
109
+ def _before_call(self) -> None:
110
+ if not self._breaker_enabled or self._state is CircuitState.CLOSED:
111
+ return
112
+
113
+ waited = time.monotonic() - self._opened_at
114
+ if self._state is CircuitState.OPEN:
115
+ if waited < self._reset_seconds:
116
+ raise CircuitOpenError(self.name, self._reset_seconds - waited)
117
+ self._state = CircuitState.HALF_OPEN
118
+ log.warning("db.circuit_half_open", backend=self.name)
119
+
120
+ def _on_success(self) -> None:
121
+ if self._state is not CircuitState.CLOSED:
122
+ log.info("db.circuit_closed", backend=self.name)
123
+ self._state = CircuitState.CLOSED
124
+ self._failures = 0
125
+
126
+ def _on_failure(self, exc: BaseException) -> None:
127
+ if not self._breaker_enabled or not is_transient_error(exc):
128
+ return # lỗi nghiệp vụ: database vẫn khoẻ, không tính
129
+
130
+ self._failures += 1
131
+ if self._failures >= self._threshold or self._state is CircuitState.HALF_OPEN:
132
+ if self._state is not CircuitState.OPEN:
133
+ log.error(
134
+ "db.circuit_open",
135
+ backend=self.name,
136
+ failures=self._failures,
137
+ reset_in=self._reset_seconds,
138
+ )
139
+ self._state = CircuitState.OPEN
140
+ self._opened_at = time.monotonic()
141
+
142
+ async def _call(self, method: str, *args: Any, **kwargs: Any) -> Any:
143
+ self._before_call()
144
+ try:
145
+ result = await asyncio.wait_for(
146
+ getattr(self._inner, method)(*args, **kwargs), self._call_timeout
147
+ )
148
+ except TimeoutErrors as exc:
149
+ # TimeoutError là lỗi tạm thời, nên nó tính vào số lần hỏng và sẽ
150
+ # làm mạch ngắt nếu lặp lại — đúng ý đồ.
151
+ log.warning("db.call_timeout", backend=self.name, method=method,
152
+ timeout=self._call_timeout)
153
+ self._on_failure(exc)
154
+ # Ném lại bằng TimeoutError DỰNG SẴN, không phải asyncio.TimeoutError:
155
+ # trên 3.10 hai lớp đó khác nhau, nên `except TimeoutError` ở code
156
+ # người dùng sẽ trượt trên 3.10 và trúng trên 3.11. Lỗi phụ thuộc
157
+ # phiên bản là thứ tệ nhất một thư viện có thể để lọt ra ngoài.
158
+ raise TimeoutError(
159
+ f"{self.name}.{method} quá {self._call_timeout}s"
160
+ ) from exc
161
+ except Exception as exc:
162
+ self._on_failure(exc)
163
+ raise
164
+ self._on_success()
165
+ return result
166
+
167
+ # ------------------------------------------------------------------ vòng đời
168
+ async def startup(self) -> None:
169
+ await self._inner.startup()
170
+
171
+ async def shutdown(self) -> None:
172
+ await self._inner.shutdown()
173
+
174
+ async def ping(self) -> bool:
175
+ return await self._call("ping")
176
+
177
+ def __getattr__(self, item: str) -> Any:
178
+ # create_schema và các method riêng của backend cụ thể đi thẳng.
179
+ return getattr(self._inner, item)
180
+
181
+ # ------------------------------------------------------------------ truy vấn
182
+ async def get(self, entity: type[E], id_: str) -> E | None:
183
+ return await self._call("get", entity, id_)
184
+
185
+ async def find(self, entity: type[E], **kwargs: Any) -> list[E]:
186
+ return await self._call("find", entity, **kwargs)
187
+
188
+ async def find_one(
189
+ self, entity: type[E], *, filters: Filters, match: Match = None
190
+ ) -> E | None:
191
+ return await self._call("find_one", entity, filters=filters, match=match)
192
+
193
+ async def count(
194
+ self, entity: type[E], *, filters: Filters, match: Match = None
195
+ ) -> int:
196
+ return await self._call("count", entity, filters=filters, match=match)
197
+
198
+ async def save(self, entity: type[E], obj: E) -> E:
199
+ return await self._call("save", entity, obj)
200
+
201
+ async def delete(self, entity: type[E], id_: str) -> bool:
202
+ return await self._call("delete", entity, id_)
203
+
204
+ async def delete_where(
205
+ self, entity: type[E], *, filters: Filters, match: Match = None
206
+ ) -> int:
207
+ return await self._call("delete_where", entity, filters=filters, match=match)
@@ -0,0 +1,88 @@
1
+ """Chọn backend theo cấu hình, và chỉ import thư viện của driver được chọn.
2
+
3
+ Đây là điểm mấu chốt cho yêu cầu "dùng Postgres thì không cần cài thư viện của
4
+ SQLite/Mongo": mọi `import sqlalchemy` / `import motor` đều nằm TRONG hàm, nên
5
+ chúng chỉ chạy khi driver tương ứng được chọn. Thiếu thư viện sẽ báo lỗi nói rõ
6
+ cần chạy lệnh make nào, thay vì ImportError lúc khởi động.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from pymodular.core.config import DatabaseSettings
12
+ from pymodular.infrastructure.database.base import DatabaseBackend
13
+
14
+ _INSTALL_HINT = {
15
+ "sqlite": "pip install \'fastapi-modular[sqlite]\'",
16
+ "postgres": "pip install \'fastapi-modular[postgres]\'",
17
+ "mongodb": "pip install \'fastapi-modular[mongodb]\'",
18
+ }
19
+
20
+
21
+ def _missing(driver: str, package: str) -> RuntimeError:
22
+ cach_cai = _INSTALL_HINT.get(driver, "pip install 'fastapi-modular[<driver>]'")
23
+ return RuntimeError(
24
+ f"Driver database '{driver}' cần thư viện '{package}' nhưng chưa cài. "
25
+ f"Chạy: {cach_cai}"
26
+ )
27
+
28
+
29
+ def _wrap(settings: DatabaseSettings, backend: DatabaseBackend) -> DatabaseBackend:
30
+ """Bọc backend bằng lớp hạn-thời-gian + circuit breaker.
31
+
32
+ Luôn bọc, kể cả khi tắt circuit breaker: phần hạn thời gian là bắt buộc,
33
+ vì timeout của từng driver không phủ được trường hợp database treo giữa
34
+ chừng, và khi đó request sẽ giữ chỗ worker vô thời hạn.
35
+ """
36
+ from pymodular.infrastructure.database.circuit import CircuitBreakerBackend
37
+
38
+ return CircuitBreakerBackend(
39
+ backend,
40
+ failure_threshold=settings.circuit_failure_threshold,
41
+ reset_seconds=settings.circuit_reset_seconds,
42
+ call_timeout_seconds=settings.query_timeout_seconds,
43
+ breaker_enabled=settings.circuit_breaker,
44
+ )
45
+
46
+
47
+ def create_backend(settings: DatabaseSettings) -> DatabaseBackend:
48
+ driver = settings.driver
49
+
50
+ if driver == "memory":
51
+ from pymodular.infrastructure.database.memory import MemoryBackend
52
+
53
+ # Backend memory không đi qua mạng nên không có gì để ngắt.
54
+ return MemoryBackend()
55
+
56
+ if driver in ("sqlite", "postgres"):
57
+ try:
58
+ from pymodular.infrastructure.database.sql import SqlBackend
59
+ except ModuleNotFoundError as exc:
60
+ raise _missing(driver, exc.name or "sqlalchemy") from exc
61
+
62
+ return _wrap(settings, SqlBackend(
63
+ settings.resolved_dsn,
64
+ echo=settings.echo,
65
+ schema_mode=settings.schema_mode,
66
+ drop_columns=settings.drop_columns,
67
+ pool_pre_ping=settings.pool_pre_ping,
68
+ pool_size=settings.pool_size,
69
+ max_overflow=settings.max_overflow,
70
+ pool_recycle_seconds=settings.pool_recycle_seconds,
71
+ connect_timeout_seconds=settings.connect_timeout_seconds,
72
+ query_timeout_seconds=settings.query_timeout_seconds,
73
+ ))
74
+
75
+ if driver == "mongodb":
76
+ try:
77
+ from pymodular.infrastructure.database.mongo import MongoBackend
78
+ except ModuleNotFoundError as exc:
79
+ raise _missing(driver, exc.name or "motor") from exc
80
+
81
+ return _wrap(settings, MongoBackend(
82
+ settings.resolved_dsn,
83
+ database=settings.name,
84
+ connect_timeout_seconds=settings.connect_timeout_seconds,
85
+ query_timeout_seconds=settings.query_timeout_seconds,
86
+ ))
87
+
88
+ raise RuntimeError(f"Driver database không hỗ trợ: {driver!r}")
@@ -0,0 +1,112 @@
1
+ """Backend lưu trong RAM — mặc định của template, không cần cài gì thêm.
2
+
3
+ Chỉ hợp cho phát triển và test: dữ liệu mất khi restart, và mỗi worker giữ
4
+ một bản riêng nên KHÔNG được chạy nhiều worker với backend này.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import uuid
10
+ from typing import Any, TypeVar
11
+
12
+ from pymodular.infrastructure.database.base import (
13
+ DatabaseBackend,
14
+ DuplicateKeyViolation,
15
+ Filters,
16
+ Match,
17
+ active_filters,
18
+ mapping_for,
19
+ matches,
20
+ )
21
+
22
+ E = TypeVar("E")
23
+
24
+
25
+ class MemoryBackend(DatabaseBackend):
26
+ name = "memory"
27
+
28
+ def __init__(self) -> None:
29
+ self._tables: dict[str, dict[str, Any]] = {}
30
+
31
+ async def startup(self) -> None:
32
+ return None
33
+
34
+ async def shutdown(self) -> None:
35
+ self._tables.clear()
36
+
37
+ async def ping(self) -> bool:
38
+ return True
39
+
40
+ def _table(self, entity: type) -> dict[str, Any]:
41
+ return self._tables.setdefault(mapping_for(entity).storage, {})
42
+
43
+ def _select(self, entity: type, filters: Filters, match: Match) -> list[Any]:
44
+ active = active_filters(filters)
45
+ return [o for o in self._table(entity).values() if matches(o, active, match)]
46
+
47
+ async def get(self, entity: type[E], id_: str) -> E | None:
48
+ return self._table(entity).get(id_)
49
+
50
+ async def find(
51
+ self,
52
+ entity: type[E],
53
+ *,
54
+ filters: Filters,
55
+ match: Match = None,
56
+ order_by: str | None = None,
57
+ limit: int | None = None,
58
+ offset: int = 0,
59
+ ) -> list[E]:
60
+ rows = self._select(entity, filters, match)
61
+ if order_by:
62
+ rows.sort(key=lambda o: getattr(o, order_by, 0))
63
+ rows = rows[offset:]
64
+ return rows[:limit] if limit is not None else rows
65
+
66
+ async def find_one(
67
+ self, entity: type[E], *, filters: Filters, match: Match = None
68
+ ) -> E | None:
69
+ return next(iter(self._select(entity, filters, match)), None)
70
+
71
+ async def count(
72
+ self, entity: type[E], *, filters: Filters, match: Match = None
73
+ ) -> int:
74
+ return len(self._select(entity, filters, match))
75
+
76
+ async def save(self, entity: type[E], obj: E) -> E:
77
+ self._check_unique(entity, obj)
78
+ if not getattr(obj, "id", None):
79
+ obj.id = uuid.uuid4().hex # type: ignore[attr-defined]
80
+ self._table(entity)[obj.id] = obj # type: ignore[attr-defined]
81
+ return obj
82
+
83
+ def _check_unique(self, entity: type, obj: Any) -> None:
84
+ """Bắt chước ràng buộc unique của database thật.
85
+
86
+ Không có phần này thì backend memory sẽ cho ghi trùng, và test chạy
87
+ trên memory sẽ không bắt được lỗi mà production gặp phải.
88
+ """
89
+ mapping = mapping_for(entity)
90
+ if not mapping.unique:
91
+ return
92
+
93
+ own_id = getattr(obj, "id", None)
94
+ for columns in mapping.unique:
95
+ values = tuple(getattr(obj, column, None) for column in columns)
96
+ for other in self._table(entity).values():
97
+ if getattr(other, "id", None) == own_id:
98
+ continue
99
+ if tuple(getattr(other, c, None) for c in columns) == values:
100
+ raise DuplicateKeyViolation(mapping.storage, columns, values)
101
+
102
+ async def delete(self, entity: type[E], id_: str) -> bool:
103
+ return self._table(entity).pop(id_, None) is not None
104
+
105
+ async def delete_where(
106
+ self, entity: type[E], *, filters: Filters, match: Match = None
107
+ ) -> int:
108
+ table = self._table(entity)
109
+ ids = [o.id for o in self._select(entity, filters, match)]
110
+ for id_ in ids:
111
+ del table[id_]
112
+ return len(ids)