aury-boot 0.0.2__py3-none-any.whl → 0.0.4__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.
- aury/boot/__init__.py +66 -0
- aury/boot/_version.py +2 -2
- aury/boot/application/__init__.py +120 -0
- aury/boot/application/app/__init__.py +39 -0
- aury/boot/application/app/base.py +511 -0
- aury/boot/application/app/components.py +434 -0
- aury/boot/application/app/middlewares.py +101 -0
- aury/boot/application/config/__init__.py +44 -0
- aury/boot/application/config/settings.py +663 -0
- aury/boot/application/constants/__init__.py +19 -0
- aury/boot/application/constants/components.py +50 -0
- aury/boot/application/constants/scheduler.py +28 -0
- aury/boot/application/constants/service.py +29 -0
- aury/boot/application/errors/__init__.py +55 -0
- aury/boot/application/errors/chain.py +80 -0
- aury/boot/application/errors/codes.py +67 -0
- aury/boot/application/errors/exceptions.py +238 -0
- aury/boot/application/errors/handlers.py +320 -0
- aury/boot/application/errors/response.py +120 -0
- aury/boot/application/interfaces/__init__.py +76 -0
- aury/boot/application/interfaces/egress.py +224 -0
- aury/boot/application/interfaces/ingress.py +98 -0
- aury/boot/application/middleware/__init__.py +22 -0
- aury/boot/application/middleware/logging.py +451 -0
- aury/boot/application/migrations/__init__.py +13 -0
- aury/boot/application/migrations/manager.py +685 -0
- aury/boot/application/migrations/setup.py +237 -0
- aury/boot/application/rpc/__init__.py +63 -0
- aury/boot/application/rpc/base.py +108 -0
- aury/boot/application/rpc/client.py +294 -0
- aury/boot/application/rpc/discovery.py +218 -0
- aury/boot/application/scheduler/__init__.py +13 -0
- aury/boot/application/scheduler/runner.py +123 -0
- aury/boot/application/server/__init__.py +296 -0
- aury/boot/commands/__init__.py +30 -0
- aury/boot/commands/add.py +76 -0
- aury/boot/commands/app.py +105 -0
- aury/boot/commands/config.py +177 -0
- aury/boot/commands/docker.py +367 -0
- aury/boot/commands/docs.py +284 -0
- aury/boot/commands/generate.py +1277 -0
- aury/boot/commands/init.py +892 -0
- aury/boot/commands/migrate/__init__.py +37 -0
- aury/boot/commands/migrate/app.py +54 -0
- aury/boot/commands/migrate/commands.py +303 -0
- aury/boot/commands/scheduler.py +124 -0
- aury/boot/commands/server/__init__.py +21 -0
- aury/boot/commands/server/app.py +541 -0
- aury/boot/commands/templates/generate/api.py.tpl +105 -0
- aury/boot/commands/templates/generate/model.py.tpl +17 -0
- aury/boot/commands/templates/generate/repository.py.tpl +19 -0
- aury/boot/commands/templates/generate/schema.py.tpl +29 -0
- aury/boot/commands/templates/generate/service.py.tpl +48 -0
- aury/boot/commands/templates/project/CLI.md.tpl +92 -0
- aury/boot/commands/templates/project/DEVELOPMENT.md.tpl +1397 -0
- aury/boot/commands/templates/project/README.md.tpl +111 -0
- aury/boot/commands/templates/project/admin_console_init.py.tpl +50 -0
- aury/boot/commands/templates/project/config.py.tpl +30 -0
- aury/boot/commands/templates/project/conftest.py.tpl +26 -0
- aury/boot/commands/templates/project/env.example.tpl +213 -0
- aury/boot/commands/templates/project/gitignore.tpl +128 -0
- aury/boot/commands/templates/project/main.py.tpl +41 -0
- aury/boot/commands/templates/project/modules/api.py.tpl +19 -0
- aury/boot/commands/templates/project/modules/exceptions.py.tpl +84 -0
- aury/boot/commands/templates/project/modules/schedules.py.tpl +18 -0
- aury/boot/commands/templates/project/modules/tasks.py.tpl +20 -0
- aury/boot/commands/worker.py +143 -0
- aury/boot/common/__init__.py +35 -0
- aury/boot/common/exceptions/__init__.py +114 -0
- aury/boot/common/i18n/__init__.py +16 -0
- aury/boot/common/i18n/translator.py +272 -0
- aury/boot/common/logging/__init__.py +716 -0
- aury/boot/contrib/__init__.py +10 -0
- aury/boot/contrib/admin_console/__init__.py +18 -0
- aury/boot/contrib/admin_console/auth.py +137 -0
- aury/boot/contrib/admin_console/discovery.py +69 -0
- aury/boot/contrib/admin_console/install.py +172 -0
- aury/boot/contrib/admin_console/utils.py +44 -0
- aury/boot/domain/__init__.py +79 -0
- aury/boot/domain/exceptions/__init__.py +132 -0
- aury/boot/domain/models/__init__.py +51 -0
- aury/boot/domain/models/base.py +69 -0
- aury/boot/domain/models/mixins.py +135 -0
- aury/boot/domain/models/models.py +96 -0
- aury/boot/domain/pagination/__init__.py +279 -0
- aury/boot/domain/repository/__init__.py +23 -0
- aury/boot/domain/repository/impl.py +423 -0
- aury/boot/domain/repository/interceptors.py +47 -0
- aury/boot/domain/repository/interface.py +106 -0
- aury/boot/domain/repository/query_builder.py +348 -0
- aury/boot/domain/service/__init__.py +11 -0
- aury/boot/domain/service/base.py +73 -0
- aury/boot/domain/transaction/__init__.py +404 -0
- aury/boot/infrastructure/__init__.py +104 -0
- aury/boot/infrastructure/cache/__init__.py +31 -0
- aury/boot/infrastructure/cache/backends.py +348 -0
- aury/boot/infrastructure/cache/base.py +68 -0
- aury/boot/infrastructure/cache/exceptions.py +37 -0
- aury/boot/infrastructure/cache/factory.py +94 -0
- aury/boot/infrastructure/cache/manager.py +274 -0
- aury/boot/infrastructure/database/__init__.py +39 -0
- aury/boot/infrastructure/database/config.py +71 -0
- aury/boot/infrastructure/database/exceptions.py +44 -0
- aury/boot/infrastructure/database/manager.py +317 -0
- aury/boot/infrastructure/database/query_tools/__init__.py +164 -0
- aury/boot/infrastructure/database/strategies/__init__.py +198 -0
- aury/boot/infrastructure/di/__init__.py +15 -0
- aury/boot/infrastructure/di/container.py +393 -0
- aury/boot/infrastructure/events/__init__.py +33 -0
- aury/boot/infrastructure/events/bus.py +362 -0
- aury/boot/infrastructure/events/config.py +52 -0
- aury/boot/infrastructure/events/consumer.py +134 -0
- aury/boot/infrastructure/events/middleware.py +51 -0
- aury/boot/infrastructure/events/models.py +63 -0
- aury/boot/infrastructure/monitoring/__init__.py +529 -0
- aury/boot/infrastructure/scheduler/__init__.py +19 -0
- aury/boot/infrastructure/scheduler/exceptions.py +37 -0
- aury/boot/infrastructure/scheduler/manager.py +478 -0
- aury/boot/infrastructure/storage/__init__.py +38 -0
- aury/boot/infrastructure/storage/base.py +164 -0
- aury/boot/infrastructure/storage/exceptions.py +37 -0
- aury/boot/infrastructure/storage/factory.py +88 -0
- aury/boot/infrastructure/tasks/__init__.py +24 -0
- aury/boot/infrastructure/tasks/config.py +45 -0
- aury/boot/infrastructure/tasks/constants.py +37 -0
- aury/boot/infrastructure/tasks/exceptions.py +37 -0
- aury/boot/infrastructure/tasks/manager.py +490 -0
- aury/boot/testing/__init__.py +24 -0
- aury/boot/testing/base.py +122 -0
- aury/boot/testing/client.py +163 -0
- aury/boot/testing/factory.py +154 -0
- aury/boot/toolkit/__init__.py +21 -0
- aury/boot/toolkit/http/__init__.py +367 -0
- {aury_boot-0.0.2.dist-info → aury_boot-0.0.4.dist-info}/METADATA +3 -2
- aury_boot-0.0.4.dist-info/RECORD +137 -0
- aury_boot-0.0.2.dist-info/RECORD +0 -5
- {aury_boot-0.0.2.dist-info → aury_boot-0.0.4.dist-info}/WHEEL +0 -0
- {aury_boot-0.0.2.dist-info → aury_boot-0.0.4.dist-info}/entry_points.txt +0 -0
|
@@ -0,0 +1,685 @@
|
|
|
1
|
+
"""数据库迁移管理。
|
|
2
|
+
|
|
3
|
+
提供类似 Django 的迁移管理接口,封装 Alembic 命令,并增强功能。
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import asyncio
|
|
9
|
+
from collections.abc import Callable
|
|
10
|
+
import importlib
|
|
11
|
+
import inspect
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
import pkgutil
|
|
14
|
+
from typing import Any
|
|
15
|
+
|
|
16
|
+
from alembic import command
|
|
17
|
+
from alembic.autogenerate import compare_metadata
|
|
18
|
+
from alembic.config import Config
|
|
19
|
+
from alembic.runtime.migration import MigrationContext
|
|
20
|
+
from alembic.script import ScriptDirectory
|
|
21
|
+
from sqlalchemy import MetaData
|
|
22
|
+
from sqlalchemy.ext.asyncio import create_async_engine
|
|
23
|
+
from sqlalchemy.orm import DeclarativeBase
|
|
24
|
+
|
|
25
|
+
from aury.boot.common.logging import logger
|
|
26
|
+
from aury.boot.domain.models import Base
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def load_all_models(model_modules: list[str]) -> None:
|
|
30
|
+
"""加载所有模型模块,确保 Alembic 可以检测到它们。
|
|
31
|
+
|
|
32
|
+
这个函数会导入指定的模块及其所有子模块,确保所有继承自 Base 的模型类
|
|
33
|
+
被正确注册到 SQLAlchemy 的元数据中。必须在 Alembic env.py 中调用此函数。
|
|
34
|
+
|
|
35
|
+
支持的模式:
|
|
36
|
+
- 精确模块名: "app.models"(既可以是文件 app/models.py,也可以是包 app/models/__init__.py)
|
|
37
|
+
- 单层通配符: "app.*.models" 匹配 app.users.models, app.products.models
|
|
38
|
+
- 递归通配符: "app.**" 递归匹配 app 下所有子模块
|
|
39
|
+
- 混合模式: "app.**.models" 递归匹配所有以 models 结尾的模块
|
|
40
|
+
|
|
41
|
+
Args:
|
|
42
|
+
model_modules: 模型模块列表或模式,如 ["app.models", "app.**.models"]
|
|
43
|
+
|
|
44
|
+
示例:
|
|
45
|
+
# 在 alembic/env.py 中
|
|
46
|
+
from aury.boot.application.migrations import load_all_models
|
|
47
|
+
from aury.boot.domain.models import Base
|
|
48
|
+
|
|
49
|
+
# 加载所有模型
|
|
50
|
+
load_all_models(["app.models", "app.**.models"])
|
|
51
|
+
|
|
52
|
+
# 确保使用 Base.metadata
|
|
53
|
+
target_metadata = Base.metadata
|
|
54
|
+
"""
|
|
55
|
+
if not model_modules:
|
|
56
|
+
logger.warning("未配置 model_modules,Alembic 可能无法检测到模型变更")
|
|
57
|
+
return
|
|
58
|
+
|
|
59
|
+
loaded_count = 0
|
|
60
|
+
|
|
61
|
+
for module_pattern in model_modules:
|
|
62
|
+
# 检查是否有通配符
|
|
63
|
+
if '*' in module_pattern:
|
|
64
|
+
# 处理通配符模式
|
|
65
|
+
modules = _expand_module_pattern(module_pattern)
|
|
66
|
+
else:
|
|
67
|
+
# 精确模块名
|
|
68
|
+
modules = [module_pattern]
|
|
69
|
+
|
|
70
|
+
for mod_name in modules:
|
|
71
|
+
try:
|
|
72
|
+
mod = importlib.import_module(mod_name)
|
|
73
|
+
logger.debug(f"已导入模型模块: {mod_name}")
|
|
74
|
+
loaded_count += 1
|
|
75
|
+
|
|
76
|
+
# 如果是包,递归导入其所有子模块
|
|
77
|
+
if hasattr(mod, '__path__'):
|
|
78
|
+
_load_package_submodules(mod)
|
|
79
|
+
|
|
80
|
+
except ImportError as e:
|
|
81
|
+
logger.debug(f"无法导入模型模块 {mod_name}: {e}")
|
|
82
|
+
except Exception as e:
|
|
83
|
+
logger.debug(f"加载模型模块 {mod_name} 时出错: {e}")
|
|
84
|
+
|
|
85
|
+
if loaded_count > 0:
|
|
86
|
+
logger.info(f"✅ 已加载 {loaded_count} 个模型模块")
|
|
87
|
+
else:
|
|
88
|
+
logger.warning("⚠️ 未加载任何模型模块,Alembic 可能无法检测到模型变更")
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _expand_module_pattern(pattern: str) -> list[str]:
|
|
92
|
+
"""根据通配符模式展开模块列表。
|
|
93
|
+
|
|
94
|
+
支持的模式:
|
|
95
|
+
- app.*.models: 匹配 app 下单层的 models 模块(app.users.models, app.products.models)
|
|
96
|
+
- app.**.models: 递归匹配 app 下所有层的 models 模块
|
|
97
|
+
- app.**: 递归匹配 app 下所有子模块
|
|
98
|
+
|
|
99
|
+
Args:
|
|
100
|
+
pattern: 包含通配符的模块模式
|
|
101
|
+
|
|
102
|
+
Returns:
|
|
103
|
+
展开后的模块名列表
|
|
104
|
+
"""
|
|
105
|
+
modules = []
|
|
106
|
+
|
|
107
|
+
if '**' in pattern:
|
|
108
|
+
# 递归通配符:app.**.models 或 app.**
|
|
109
|
+
# 提取基础包名(** 之前的部分)
|
|
110
|
+
parts = pattern.split('**')
|
|
111
|
+
base_pkg = parts[0].rstrip('.')
|
|
112
|
+
suffix = parts[1].lstrip('.') # models 或空字符串
|
|
113
|
+
|
|
114
|
+
try:
|
|
115
|
+
pkg = importlib.import_module(base_pkg)
|
|
116
|
+
if hasattr(pkg, '__path__'):
|
|
117
|
+
# 递归遍历所有子模块
|
|
118
|
+
for _, modname, _ in pkgutil.walk_packages(
|
|
119
|
+
path=pkg.__path__,
|
|
120
|
+
prefix=f"{base_pkg}.",
|
|
121
|
+
onerror=lambda x: None,
|
|
122
|
+
):
|
|
123
|
+
# 如果有后缀,检查模块名是否以后缀结尾
|
|
124
|
+
if suffix:
|
|
125
|
+
if modname.endswith(suffix):
|
|
126
|
+
modules.append(modname)
|
|
127
|
+
else:
|
|
128
|
+
modules.append(modname)
|
|
129
|
+
except ImportError:
|
|
130
|
+
pass
|
|
131
|
+
|
|
132
|
+
else:
|
|
133
|
+
# 单层通配符:app.*.models
|
|
134
|
+
# 提取基础包和后缀
|
|
135
|
+
parts = pattern.split('*')
|
|
136
|
+
base_pkg = parts[0].rstrip('.')
|
|
137
|
+
suffix = parts[1].lstrip('.')
|
|
138
|
+
|
|
139
|
+
try:
|
|
140
|
+
pkg = importlib.import_module(base_pkg)
|
|
141
|
+
if hasattr(pkg, '__path__'):
|
|
142
|
+
# 只遍历一层子模块
|
|
143
|
+
for _, modname, _ in pkgutil.iter_modules(pkg.__path__, prefix=f"{base_pkg}."):
|
|
144
|
+
# 检查是否有后缀部分,如果有则继续尝试导入
|
|
145
|
+
if suffix:
|
|
146
|
+
full_name = f"{modname}.{suffix}"
|
|
147
|
+
try:
|
|
148
|
+
importlib.import_module(full_name)
|
|
149
|
+
modules.append(full_name)
|
|
150
|
+
except ImportError:
|
|
151
|
+
pass
|
|
152
|
+
else:
|
|
153
|
+
modules.append(modname)
|
|
154
|
+
except ImportError:
|
|
155
|
+
pass
|
|
156
|
+
|
|
157
|
+
return modules
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def _load_package_submodules(package_module) -> None:
|
|
161
|
+
"""递归加载包内的所有模块。
|
|
162
|
+
|
|
163
|
+
对于包(目录),这个函数会遍历其所有子模块并导入它们,
|
|
164
|
+
确保其中定义的模型都被 SQLAlchemy 注册到 Base.metadata。
|
|
165
|
+
|
|
166
|
+
Args:
|
|
167
|
+
package_module: 已导入的包模块对象
|
|
168
|
+
"""
|
|
169
|
+
if not hasattr(package_module, '__path__'):
|
|
170
|
+
return
|
|
171
|
+
|
|
172
|
+
package_name = package_module.__name__
|
|
173
|
+
package_path = Path(package_module.__path__[0])
|
|
174
|
+
|
|
175
|
+
# 遍历包目录下的所有 .py 文件(不包括 __pycache__)
|
|
176
|
+
for py_file in package_path.glob('*.py'):
|
|
177
|
+
# 跳过 __init__.py 和特殊文件
|
|
178
|
+
if py_file.name.startswith('_'):
|
|
179
|
+
continue
|
|
180
|
+
|
|
181
|
+
module_name = py_file.stem
|
|
182
|
+
full_module_name = f"{package_name}.{module_name}"
|
|
183
|
+
|
|
184
|
+
try:
|
|
185
|
+
importlib.import_module(full_module_name)
|
|
186
|
+
logger.debug(f"已加载包中的模块: {full_module_name}")
|
|
187
|
+
except ImportError as e:
|
|
188
|
+
logger.debug(f"无法导入模块 {full_module_name}: {e}")
|
|
189
|
+
except Exception as e:
|
|
190
|
+
logger.debug(f"加载模块 {full_module_name} 时出错: {e}")
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
class MigrationManager:
|
|
194
|
+
"""迁移管理器。
|
|
195
|
+
|
|
196
|
+
提供类似 Django 的迁移管理接口,并增强功能:
|
|
197
|
+
- 自动检测模型变更
|
|
198
|
+
- 数据迁移支持
|
|
199
|
+
- 迁移前/后钩子
|
|
200
|
+
- 干运行(dry-run)
|
|
201
|
+
- 迁移检查
|
|
202
|
+
- 更好的错误处理
|
|
203
|
+
- 自动创建配置和目录
|
|
204
|
+
|
|
205
|
+
使用示例:
|
|
206
|
+
manager = MigrationManager(
|
|
207
|
+
config_path="alembic.ini",
|
|
208
|
+
script_location="migrations",
|
|
209
|
+
database_url="sqlite+aiosqlite:///./app.db",
|
|
210
|
+
model_modules=["app.models"],
|
|
211
|
+
auto_create=True,
|
|
212
|
+
)
|
|
213
|
+
|
|
214
|
+
# 生成迁移(自动检测模型变更)
|
|
215
|
+
await manager.make_migrations(message="add user table")
|
|
216
|
+
|
|
217
|
+
# 执行迁移(带钩子)
|
|
218
|
+
await manager.upgrade()
|
|
219
|
+
|
|
220
|
+
# 查看状态
|
|
221
|
+
status = await manager.status()
|
|
222
|
+
"""
|
|
223
|
+
|
|
224
|
+
def __init__(
|
|
225
|
+
self,
|
|
226
|
+
database_url: str,
|
|
227
|
+
config_path: str = "alembic.ini",
|
|
228
|
+
script_location: str = "migrations",
|
|
229
|
+
model_modules: list[str] | None = None,
|
|
230
|
+
auto_create: bool = True,
|
|
231
|
+
) -> None:
|
|
232
|
+
"""初始化迁移管理器。
|
|
233
|
+
|
|
234
|
+
Args:
|
|
235
|
+
database_url: 数据库连接字符串
|
|
236
|
+
config_path: Alembic 配置文件路径
|
|
237
|
+
script_location: Alembic 迁移脚本目录
|
|
238
|
+
model_modules: 模型模块列表(用于自动检测变更)
|
|
239
|
+
auto_create: 是否自动创建配置和目录
|
|
240
|
+
"""
|
|
241
|
+
self._database_url = database_url
|
|
242
|
+
self._config_path = Path(config_path)
|
|
243
|
+
self._script_location = script_location
|
|
244
|
+
self._model_modules = model_modules or []
|
|
245
|
+
|
|
246
|
+
# 自动创建配置和目录
|
|
247
|
+
if auto_create:
|
|
248
|
+
self._ensure_migration_setup()
|
|
249
|
+
|
|
250
|
+
# 加载 Alembic 配置
|
|
251
|
+
if not self._config_path.exists():
|
|
252
|
+
raise FileNotFoundError(
|
|
253
|
+
f"Alembic 配置文件不存在: {config_path}\n"
|
|
254
|
+
f"请设置 auto_create=True 以自动创建配置"
|
|
255
|
+
)
|
|
256
|
+
|
|
257
|
+
self._alembic_cfg = Config(str(self._config_path))
|
|
258
|
+
self._alembic_cfg.set_main_option("script_location", self._script_location)
|
|
259
|
+
self._alembic_cfg.set_main_option("sqlalchemy.url", self._database_url)
|
|
260
|
+
|
|
261
|
+
# 迁移钩子
|
|
262
|
+
self._before_upgrade_hooks: list[Callable[[str], None]] = []
|
|
263
|
+
self._after_upgrade_hooks: list[Callable[[str], None]] = []
|
|
264
|
+
self._before_downgrade_hooks: list[Callable[[str], None]] = []
|
|
265
|
+
self._after_downgrade_hooks: list[Callable[[str], None]] = []
|
|
266
|
+
|
|
267
|
+
logger.debug(f"迁移管理器已初始化: {config_path}")
|
|
268
|
+
|
|
269
|
+
def _ensure_migration_setup(self) -> None:
|
|
270
|
+
"""确保迁移配置和目录存在,不存在则自动创建。
|
|
271
|
+
|
|
272
|
+
使用统一的 setup 模块,保证单一数据源。
|
|
273
|
+
"""
|
|
274
|
+
from .setup import ensure_migration_setup
|
|
275
|
+
|
|
276
|
+
# 获取当前工作目录作为 base_path
|
|
277
|
+
base_path = Path.cwd()
|
|
278
|
+
|
|
279
|
+
ensure_migration_setup(
|
|
280
|
+
base_path=base_path,
|
|
281
|
+
config_path=str(self._config_path),
|
|
282
|
+
script_location=self._script_location,
|
|
283
|
+
model_modules=self._model_modules,
|
|
284
|
+
)
|
|
285
|
+
|
|
286
|
+
def register_before_upgrade(self, hook: Callable[[str], None]) -> None:
|
|
287
|
+
"""注册升级前钩子。
|
|
288
|
+
|
|
289
|
+
Args:
|
|
290
|
+
hook: 钩子函数,接收目标版本作为参数
|
|
291
|
+
"""
|
|
292
|
+
self._before_upgrade_hooks.append(hook)
|
|
293
|
+
|
|
294
|
+
def register_after_upgrade(self, hook: Callable[[str], None]) -> None:
|
|
295
|
+
"""注册升级后钩子。
|
|
296
|
+
|
|
297
|
+
Args:
|
|
298
|
+
hook: 钩子函数,接收目标版本作为参数
|
|
299
|
+
"""
|
|
300
|
+
self._after_upgrade_hooks.append(hook)
|
|
301
|
+
|
|
302
|
+
def register_before_downgrade(self, hook: Callable[[str], None]) -> None:
|
|
303
|
+
"""注册回滚前钩子。
|
|
304
|
+
|
|
305
|
+
Args:
|
|
306
|
+
hook: 钩子函数,接收目标版本作为参数
|
|
307
|
+
"""
|
|
308
|
+
self._before_downgrade_hooks.append(hook)
|
|
309
|
+
|
|
310
|
+
def register_after_downgrade(self, hook: Callable[[str], None]) -> None:
|
|
311
|
+
"""注册回滚后钩子。
|
|
312
|
+
|
|
313
|
+
Args:
|
|
314
|
+
hook: 钩子函数,接收目标版本作为参数
|
|
315
|
+
"""
|
|
316
|
+
self._after_downgrade_hooks.append(hook)
|
|
317
|
+
|
|
318
|
+
def _load_models(self) -> set[type[DeclarativeBase]]:
|
|
319
|
+
"""加载所有模型(用于自动检测变更)。
|
|
320
|
+
|
|
321
|
+
Returns:
|
|
322
|
+
set[type[DeclarativeBase]]: 模型类集合
|
|
323
|
+
"""
|
|
324
|
+
models: set[type[DeclarativeBase]] = set()
|
|
325
|
+
|
|
326
|
+
for module_name in self._model_modules:
|
|
327
|
+
try:
|
|
328
|
+
module = importlib.import_module(module_name)
|
|
329
|
+
for name, obj in inspect.getmembers(module):
|
|
330
|
+
if (
|
|
331
|
+
inspect.isclass(obj)
|
|
332
|
+
and issubclass(obj, Base)
|
|
333
|
+
and obj is not Base
|
|
334
|
+
and not getattr(obj, "__abstract__", False)
|
|
335
|
+
):
|
|
336
|
+
models.add(obj)
|
|
337
|
+
logger.debug(f"加载模型: {module_name}.{name}")
|
|
338
|
+
except ImportError as e:
|
|
339
|
+
logger.warning(f"无法导入模型模块 {module_name}: {e}")
|
|
340
|
+
|
|
341
|
+
return models
|
|
342
|
+
|
|
343
|
+
async def _detect_changes(self) -> list[dict[str, Any]]:
|
|
344
|
+
"""检测模型变更(类似 Django 的 autodetect)。
|
|
345
|
+
|
|
346
|
+
Returns:
|
|
347
|
+
list[dict[str, Any]]: 变更列表
|
|
348
|
+
"""
|
|
349
|
+
if not self._model_modules:
|
|
350
|
+
return []
|
|
351
|
+
|
|
352
|
+
try:
|
|
353
|
+
models = self._load_models()
|
|
354
|
+
if not models:
|
|
355
|
+
return []
|
|
356
|
+
|
|
357
|
+
# 使用异步引擎
|
|
358
|
+
engine = create_async_engine(self._database_url)
|
|
359
|
+
|
|
360
|
+
def _sync_detect(conn):
|
|
361
|
+
context = MigrationContext.configure(conn)
|
|
362
|
+
diff = compare_metadata(context, Base.metadata)
|
|
363
|
+
changes = []
|
|
364
|
+
for change in diff:
|
|
365
|
+
changes.append({
|
|
366
|
+
"type": type(change).__name__,
|
|
367
|
+
"description": str(change),
|
|
368
|
+
})
|
|
369
|
+
return changes
|
|
370
|
+
|
|
371
|
+
async with engine.connect() as conn:
|
|
372
|
+
changes = await conn.run_sync(_sync_detect)
|
|
373
|
+
|
|
374
|
+
await engine.dispose()
|
|
375
|
+
return changes
|
|
376
|
+
except Exception as e:
|
|
377
|
+
logger.warning(f"检测模型变更失败: {e}")
|
|
378
|
+
return []
|
|
379
|
+
|
|
380
|
+
async def check(self) -> dict[str, Any]:
|
|
381
|
+
"""检查迁移(类似 Django 的 check)。
|
|
382
|
+
|
|
383
|
+
检查迁移文件是否有问题,如:
|
|
384
|
+
- 迁移依赖是否正确
|
|
385
|
+
- 是否有冲突
|
|
386
|
+
- 是否有缺失的迁移
|
|
387
|
+
|
|
388
|
+
Returns:
|
|
389
|
+
dict[str, Any]: 检查结果
|
|
390
|
+
"""
|
|
391
|
+
def _check():
|
|
392
|
+
script = ScriptDirectory.from_config(self._alembic_cfg)
|
|
393
|
+
revisions = list(script.walk_revisions())
|
|
394
|
+
|
|
395
|
+
issues = []
|
|
396
|
+
warnings = []
|
|
397
|
+
|
|
398
|
+
# 检查是否有孤立的迁移
|
|
399
|
+
revision_map = {rev.revision: rev for rev in revisions}
|
|
400
|
+
for rev in revisions:
|
|
401
|
+
if rev.down_revision and rev.down_revision not in revision_map:
|
|
402
|
+
issues.append(f"迁移 {rev.revision} 的父版本 {rev.down_revision} 不存在")
|
|
403
|
+
|
|
404
|
+
# 检查是否有多个 head(冲突)
|
|
405
|
+
heads = script.get_revisions("heads")
|
|
406
|
+
if len(heads) > 1:
|
|
407
|
+
warnings.append(f"发现 {len(heads)} 个 head,可能存在分支,需要合并")
|
|
408
|
+
|
|
409
|
+
return {
|
|
410
|
+
"valid": len(issues) == 0,
|
|
411
|
+
"issues": issues,
|
|
412
|
+
"warnings": warnings,
|
|
413
|
+
"revision_count": len(revisions),
|
|
414
|
+
"head_count": len(heads),
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
return await asyncio.to_thread(_check)
|
|
418
|
+
|
|
419
|
+
async def make_migrations(
|
|
420
|
+
self,
|
|
421
|
+
message: str | None = None,
|
|
422
|
+
autogenerate: bool = True,
|
|
423
|
+
dry_run: bool = False,
|
|
424
|
+
) -> dict[str, Any]:
|
|
425
|
+
"""生成迁移文件(类似 Django 的 makemigrations)。
|
|
426
|
+
|
|
427
|
+
Args:
|
|
428
|
+
message: 迁移消息
|
|
429
|
+
autogenerate: 是否自动生成(基于模型变更)
|
|
430
|
+
dry_run: 是否干运行(只检测变更,不生成文件)
|
|
431
|
+
|
|
432
|
+
Returns:
|
|
433
|
+
dict[str, Any]: 生成结果,包含变更信息
|
|
434
|
+
"""
|
|
435
|
+
if not message:
|
|
436
|
+
message = "auto migration"
|
|
437
|
+
|
|
438
|
+
# 检测变更
|
|
439
|
+
changes = []
|
|
440
|
+
if autogenerate and self._model_modules:
|
|
441
|
+
changes = await self._detect_changes()
|
|
442
|
+
if changes:
|
|
443
|
+
logger.info(f"检测到 {len(changes)} 个模型变更")
|
|
444
|
+
for change in changes:
|
|
445
|
+
logger.debug(f" - {change['type']}: {change['description']}")
|
|
446
|
+
|
|
447
|
+
if dry_run:
|
|
448
|
+
return {
|
|
449
|
+
"dry_run": True,
|
|
450
|
+
"changes": changes,
|
|
451
|
+
"message": message,
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
def _make():
|
|
455
|
+
command.revision(
|
|
456
|
+
self._alembic_cfg,
|
|
457
|
+
message=message,
|
|
458
|
+
autogenerate=autogenerate,
|
|
459
|
+
)
|
|
460
|
+
|
|
461
|
+
await asyncio.to_thread(_make)
|
|
462
|
+
logger.info(f"迁移文件已生成: {message}")
|
|
463
|
+
|
|
464
|
+
return {
|
|
465
|
+
"dry_run": False,
|
|
466
|
+
"changes": changes,
|
|
467
|
+
"message": message,
|
|
468
|
+
"path": f"{self._script_location}/versions/{message.replace(' ', '_')}.py",
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
async def upgrade(
|
|
472
|
+
self,
|
|
473
|
+
revision: str = "head",
|
|
474
|
+
dry_run: bool = False,
|
|
475
|
+
) -> None:
|
|
476
|
+
"""执行迁移(类似 Django 的 migrate)。
|
|
477
|
+
|
|
478
|
+
Args:
|
|
479
|
+
revision: 目标版本(默认 "head" 表示最新版本)
|
|
480
|
+
dry_run: 是否干运行(只显示会执行的迁移,不实际执行)
|
|
481
|
+
"""
|
|
482
|
+
if dry_run:
|
|
483
|
+
# 干运行:只显示会执行的迁移
|
|
484
|
+
status_info = await self.status()
|
|
485
|
+
pending = status_info.get("pending", [])
|
|
486
|
+
if pending:
|
|
487
|
+
logger.info(f"干运行:将执行 {len(pending)} 个迁移")
|
|
488
|
+
for rev in pending:
|
|
489
|
+
logger.info(f" - {rev}")
|
|
490
|
+
else:
|
|
491
|
+
logger.info("干运行:没有待执行的迁移")
|
|
492
|
+
return
|
|
493
|
+
|
|
494
|
+
# 执行钩子
|
|
495
|
+
for hook in self._before_upgrade_hooks:
|
|
496
|
+
try:
|
|
497
|
+
hook(revision)
|
|
498
|
+
except Exception as e:
|
|
499
|
+
logger.error(f"升级前钩子执行失败: {e}")
|
|
500
|
+
|
|
501
|
+
def _upgrade():
|
|
502
|
+
command.upgrade(self._alembic_cfg, revision)
|
|
503
|
+
|
|
504
|
+
await asyncio.to_thread(_upgrade)
|
|
505
|
+
logger.info(f"迁移已执行到版本: {revision}")
|
|
506
|
+
|
|
507
|
+
# 执行钩子
|
|
508
|
+
for hook in self._after_upgrade_hooks:
|
|
509
|
+
try:
|
|
510
|
+
hook(revision)
|
|
511
|
+
except Exception as e:
|
|
512
|
+
logger.error(f"升级后钩子执行失败: {e}")
|
|
513
|
+
|
|
514
|
+
async def downgrade(
|
|
515
|
+
self,
|
|
516
|
+
revision: str,
|
|
517
|
+
dry_run: bool = False,
|
|
518
|
+
) -> None:
|
|
519
|
+
"""回滚迁移。
|
|
520
|
+
|
|
521
|
+
Args:
|
|
522
|
+
revision: 目标版本(如 "previous", "-1", 或具体版本号)
|
|
523
|
+
dry_run: 是否干运行(只显示会回滚的迁移,不实际执行)
|
|
524
|
+
"""
|
|
525
|
+
if dry_run:
|
|
526
|
+
# 干运行:显示会回滚的迁移
|
|
527
|
+
status_info = await self.status()
|
|
528
|
+
current = status_info.get("current")
|
|
529
|
+
if current:
|
|
530
|
+
logger.info(f"干运行:将从 {current} 回滚到 {revision}")
|
|
531
|
+
return
|
|
532
|
+
|
|
533
|
+
# 执行钩子
|
|
534
|
+
for hook in self._before_downgrade_hooks:
|
|
535
|
+
try:
|
|
536
|
+
hook(revision)
|
|
537
|
+
except Exception as e:
|
|
538
|
+
logger.error(f"回滚前钩子执行失败: {e}")
|
|
539
|
+
|
|
540
|
+
def _downgrade():
|
|
541
|
+
command.downgrade(self._alembic_cfg, revision)
|
|
542
|
+
|
|
543
|
+
await asyncio.to_thread(_downgrade)
|
|
544
|
+
logger.info(f"迁移已回滚到版本: {revision}")
|
|
545
|
+
|
|
546
|
+
# 执行钩子
|
|
547
|
+
for hook in self._after_downgrade_hooks:
|
|
548
|
+
try:
|
|
549
|
+
hook(revision)
|
|
550
|
+
except Exception as e:
|
|
551
|
+
logger.error(f"回滚后钩子执行失败: {e}")
|
|
552
|
+
|
|
553
|
+
async def status(self) -> dict[str, Any]:
|
|
554
|
+
"""查看迁移状态(类似 Django 的 showmigrations)。
|
|
555
|
+
|
|
556
|
+
Returns:
|
|
557
|
+
dict[str, Any]: 迁移状态信息
|
|
558
|
+
"""
|
|
559
|
+
script = ScriptDirectory.from_config(self._alembic_cfg)
|
|
560
|
+
|
|
561
|
+
# 使用异步引擎,通过 run_sync 执行同步操作
|
|
562
|
+
engine = create_async_engine(self._database_url)
|
|
563
|
+
current_rev = None
|
|
564
|
+
|
|
565
|
+
try:
|
|
566
|
+
async with engine.connect() as conn:
|
|
567
|
+
def _get_current_rev(connection):
|
|
568
|
+
context = MigrationContext.configure(connection)
|
|
569
|
+
return context.get_current_revision()
|
|
570
|
+
|
|
571
|
+
current_rev = await conn.run_sync(_get_current_rev)
|
|
572
|
+
finally:
|
|
573
|
+
await engine.dispose()
|
|
574
|
+
|
|
575
|
+
head_rev = script.get_current_head()
|
|
576
|
+
revisions = list(script.walk_revisions())
|
|
577
|
+
|
|
578
|
+
applied = []
|
|
579
|
+
pending = []
|
|
580
|
+
|
|
581
|
+
# 简化逻辑:
|
|
582
|
+
# - 如果 current_rev 是 None,所有迁移都是 pending
|
|
583
|
+
# - 如果 current_rev == head_rev,没有 pending
|
|
584
|
+
# - 否则,从 head 到 current 之间的是 pending
|
|
585
|
+
|
|
586
|
+
if current_rev is None:
|
|
587
|
+
# 数据库是新的,所有迁移都需要执行
|
|
588
|
+
pending = [rev.revision for rev in revisions]
|
|
589
|
+
elif current_rev == head_rev:
|
|
590
|
+
# 已是最新版本
|
|
591
|
+
applied = [rev.revision for rev in revisions]
|
|
592
|
+
else:
|
|
593
|
+
# 部分已执行,部分待执行
|
|
594
|
+
# 从 head 向下遍历,直到 current_rev
|
|
595
|
+
found_current = False
|
|
596
|
+
for rev in revisions: # walk_revisions 从 head 开始
|
|
597
|
+
if rev.revision == current_rev:
|
|
598
|
+
found_current = True
|
|
599
|
+
applied.append(rev.revision)
|
|
600
|
+
elif found_current:
|
|
601
|
+
applied.append(rev.revision)
|
|
602
|
+
else:
|
|
603
|
+
pending.append(rev.revision)
|
|
604
|
+
|
|
605
|
+
return {
|
|
606
|
+
"current": current_rev,
|
|
607
|
+
"head": head_rev,
|
|
608
|
+
"pending": pending,
|
|
609
|
+
"applied": applied,
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
async def show(self) -> list[dict[str, str]]:
|
|
613
|
+
"""显示所有迁移(类似 Django 的 showmigrations)。
|
|
614
|
+
|
|
615
|
+
Returns:
|
|
616
|
+
list[dict[str, str]]: 迁移列表
|
|
617
|
+
"""
|
|
618
|
+
def _show():
|
|
619
|
+
script = ScriptDirectory.from_config(self._alembic_cfg)
|
|
620
|
+
revisions = list(script.walk_revisions())
|
|
621
|
+
|
|
622
|
+
result = []
|
|
623
|
+
for rev in revisions:
|
|
624
|
+
result.append({
|
|
625
|
+
"revision": rev.revision,
|
|
626
|
+
"down_revision": rev.down_revision,
|
|
627
|
+
"message": rev.doc or "",
|
|
628
|
+
"path": str(rev.path) if hasattr(rev, "path") else "",
|
|
629
|
+
})
|
|
630
|
+
|
|
631
|
+
return result
|
|
632
|
+
|
|
633
|
+
return await asyncio.to_thread(_show)
|
|
634
|
+
|
|
635
|
+
async def history(self, verbose: bool = False) -> list[str]:
|
|
636
|
+
"""显示迁移历史。
|
|
637
|
+
|
|
638
|
+
Args:
|
|
639
|
+
verbose: 是否显示详细信息
|
|
640
|
+
|
|
641
|
+
Returns:
|
|
642
|
+
list[str]: 迁移历史列表
|
|
643
|
+
"""
|
|
644
|
+
def _history():
|
|
645
|
+
command.history(self._alembic_cfg, verbose=verbose)
|
|
646
|
+
|
|
647
|
+
await asyncio.to_thread(_history)
|
|
648
|
+
return []
|
|
649
|
+
|
|
650
|
+
async def merge(
|
|
651
|
+
self,
|
|
652
|
+
revisions: list[str],
|
|
653
|
+
message: str | None = None,
|
|
654
|
+
) -> str:
|
|
655
|
+
"""合并迁移(类似 Django 的迁移合并)。
|
|
656
|
+
|
|
657
|
+
当有多个分支时,创建合并迁移。
|
|
658
|
+
|
|
659
|
+
Args:
|
|
660
|
+
revisions: 要合并的版本列表
|
|
661
|
+
message: 合并消息
|
|
662
|
+
|
|
663
|
+
Returns:
|
|
664
|
+
str: 合并后的迁移文件路径
|
|
665
|
+
"""
|
|
666
|
+
if not message:
|
|
667
|
+
message = f"merge {', '.join(revisions)}"
|
|
668
|
+
|
|
669
|
+
def _merge():
|
|
670
|
+
command.merge(
|
|
671
|
+
self._alembic_cfg,
|
|
672
|
+
revisions=revisions,
|
|
673
|
+
message=message,
|
|
674
|
+
)
|
|
675
|
+
|
|
676
|
+
await asyncio.to_thread(_merge)
|
|
677
|
+
logger.info(f"迁移已合并: {message}")
|
|
678
|
+
return f"{self._script_location}/versions/{message.replace(' ', '_')}.py"
|
|
679
|
+
|
|
680
|
+
|
|
681
|
+
__all__ = [
|
|
682
|
+
"MigrationManager",
|
|
683
|
+
"load_all_models",
|
|
684
|
+
]
|
|
685
|
+
|