springbootAI 1.8.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.
- spring/__init__.py +66 -0
- spring/ai/__init__.py +78 -0
- spring/ai/advisors.py +139 -0
- spring/ai/annotations.py +74 -0
- spring/ai/autoconfig.py +481 -0
- spring/ai/core.py +391 -0
- spring/ai/etl.py +188 -0
- spring/ai/memory.py +109 -0
- spring/ai/observability.py +129 -0
- spring/ai/providers.py +789 -0
- spring/ai/resilience.py +258 -0
- spring/ai/tools.py +106 -0
- spring/ai/vectorstore.py +303 -0
- spring/annotations/__init__.py +188 -0
- spring/annotations/cache.py +126 -0
- spring/annotations/cloud.py +207 -0
- spring/annotations/conditional.py +272 -0
- spring/annotations/core.py +864 -0
- spring/annotations/messaging.py +107 -0
- spring/aop/__init__.py +4 -0
- spring/aop/cloud_aop.py +404 -0
- spring/aop/comprehensive_aop.py +1015 -0
- spring/aop/method_interceptor.py +19 -0
- spring/aop/proxy_factory.py +55 -0
- spring/cloud/__init__.py +76 -0
- spring/cloud/discovery.py +364 -0
- spring/cloud/feign.py +469 -0
- spring/cloud/gateway.py +452 -0
- spring/cloud/load_balancer.py +149 -0
- spring/cloud/seata.py +557 -0
- spring/cloud/sentinel.py +525 -0
- spring/cloud/tracer.py +337 -0
- spring/config/__init__.py +21 -0
- spring/config/binding.py +206 -0
- spring/config/config_loader.py +405 -0
- spring/context/__init__.py +13 -0
- spring/context/application_context.py +589 -0
- spring/context/bean_definition.py +70 -0
- spring/context/bean_factory.py +1052 -0
- spring/context/registry.py +58 -0
- spring/context/scanner.py +106 -0
- spring/core/__init__.py +3 -0
- spring/core/graceful_shutdown.py +196 -0
- spring/core/typing_utils.py +50 -0
- spring/csv/__init__.py +52 -0
- spring/csv/annotations.py +402 -0
- spring/csv/converters.py +69 -0
- spring/csv/easy_csv.py +95 -0
- spring/csv/exceptions.py +27 -0
- spring/csv/reader.py +195 -0
- spring/csv/writer.py +155 -0
- spring/data/__init__.py +54 -0
- spring/data/page.py +181 -0
- spring/data/repository.py +274 -0
- spring/data/specification.py +228 -0
- spring/datasource/__init__.py +66 -0
- spring/datasource/annotations.py +133 -0
- spring/datasource/context.py +69 -0
- spring/datasource/dynamic.py +148 -0
- spring/event/__init__.py +7 -0
- spring/event/publisher.py +69 -0
- spring/excel/__init__.py +51 -0
- spring/excel/annotations.py +405 -0
- spring/excel/converters.py +231 -0
- spring/excel/easy_excel.py +94 -0
- spring/excel/exceptions.py +31 -0
- spring/excel/reader.py +254 -0
- spring/excel/style.py +95 -0
- spring/excel/writer.py +197 -0
- spring/i18n/__init__.py +97 -0
- spring/i18n/accessor.py +94 -0
- spring/i18n/auto_config.py +177 -0
- spring/i18n/holder.py +106 -0
- spring/i18n/locale.py +152 -0
- spring/i18n/locale_resolver.py +367 -0
- spring/i18n/message_source.py +250 -0
- spring/i18n/middleware.py +79 -0
- spring/i18n/properties.py +168 -0
- spring/i18n/sources.py +255 -0
- spring/logging/__init__.py +1 -0
- spring/logging/loguru_logger.py +228 -0
- spring/main.py +378 -0
- spring/messaging/__init__.py +1 -0
- spring/messaging/rabbitmq.py +302 -0
- spring/monitoring/__init__.py +1 -0
- spring/monitoring/prometheus.py +199 -0
- spring/orm/__init__.py +258 -0
- spring/orm/database.py +222 -0
- spring/orm/ddl_auto.py +1217 -0
- spring/orm/migration.py +419 -0
- spring/orm/mybatis_integration.py +400 -0
- spring/orm/pymybatis/__init__.py +86 -0
- spring/orm/pymybatis/annotations/__init__.py +30 -0
- spring/orm/pymybatis/annotations/annotations.py +332 -0
- spring/orm/pymybatis/cache/__init__.py +47 -0
- spring/orm/pymybatis/cache/cache.py +371 -0
- spring/orm/pymybatis/cache/redis_cache.py +434 -0
- spring/orm/pymybatis/circuit_breaker/__init__.py +21 -0
- spring/orm/pymybatis/circuit_breaker/circuit_breaker.py +424 -0
- spring/orm/pymybatis/configuration.py +525 -0
- spring/orm/pymybatis/core/__init__.py +10 -0
- spring/orm/pymybatis/core/sql_session.py +1382 -0
- spring/orm/pymybatis/core/sql_session_factory.py +76 -0
- spring/orm/pymybatis/dialect/__init__.py +9 -0
- spring/orm/pymybatis/dialect/dialect.py +445 -0
- spring/orm/pymybatis/dynamic_sql/__init__.py +9 -0
- spring/orm/pymybatis/dynamic_sql/dynamic_sql.py +900 -0
- spring/orm/pymybatis/interceptor/__init__.py +31 -0
- spring/orm/pymybatis/interceptor/interceptor.py +427 -0
- spring/orm/pymybatis/mapper/__init__.py +9 -0
- spring/orm/pymybatis/mapper/mapper.py +540 -0
- spring/orm/pymybatis/metrics/__init__.py +41 -0
- spring/orm/pymybatis/metrics/metrics.py +595 -0
- spring/orm/pymybatis/pool/__init__.py +9 -0
- spring/orm/pymybatis/pool/connection_pool.py +711 -0
- spring/orm/pymybatis/security/__init__.py +19 -0
- spring/orm/pymybatis/security/access_control.py +415 -0
- spring/orm/pymybatis/security/password_encoder.py +293 -0
- spring/orm/pymybatis/security/sensitive_data_masker.py +326 -0
- spring/orm/pymybatis/security/sql_injection_detector.py +675 -0
- spring/orm/pymybatis/transaction/__init__.py +9 -0
- spring/orm/pymybatis/transaction/transaction.py +288 -0
- spring/orm/pymybatis/type_handler/__init__.py +37 -0
- spring/orm/pymybatis/type_handler/type_handler.py +473 -0
- spring/orm/pymybatis/version.py +9 -0
- spring/orm/pymybatis/xml_parser/__init__.py +9 -0
- spring/orm/pymybatis/xml_parser/xml_parser.py +761 -0
- spring/retry/__init__.py +12 -0
- spring/retry/retry_annotations.py +71 -0
- spring/retry/retry_decorator.py +155 -0
- spring/scheduling/__init__.py +3 -0
- spring/scheduling/scheduler.py +389 -0
- spring/security/__init__.py +39 -0
- spring/security/jwt_utils.py +281 -0
- spring/security/replay_protection.py +206 -0
- spring/security/secret_manager.py +226 -0
- spring/security/security_aop.py +248 -0
- spring/security/security_context.py +172 -0
- spring/test/__init__.py +45 -0
- spring/test/slicing.py +341 -0
- spring/tracing/__init__.py +11 -0
- spring/tracing/skywalking.py +229 -0
- spring/tx/__init__.py +52 -0
- spring/tx/events.py +172 -0
- spring/tx/synchronization.py +143 -0
- spring/utils/__init__.py +5 -0
- spring/utils/banner.py +32 -0
- spring/utils/logger.py +73 -0
- spring/utils/redis_client.py +526 -0
- spring/validation/__init__.py +55 -0
- spring/validation/aop.py +141 -0
- spring/validation/constraints.py +357 -0
- spring/validation/exceptions.py +55 -0
- spring/validation/validator.py +139 -0
- spring/web/__init__.py +12 -0
- spring/web/actuator.py +319 -0
- spring/web/exception_handler.py +61 -0
- spring/web/health.py +399 -0
- spring/web/interceptor.py +91 -0
- spring/web/result.py +44 -0
- spring/web/swagger.py +601 -0
- spring/web/web_context.py +755 -0
- spring/websocket/__init__.py +86 -0
- spring/websocket/annotations.py +169 -0
- spring/websocket/broker.py +238 -0
- spring/websocket/exceptions.py +26 -0
- spring/websocket/handler.py +243 -0
- spring/websocket/router.py +526 -0
- spring/websocket/session.py +216 -0
- springbootai-1.8.0.dist-info/METADATA +2796 -0
- springbootai-1.8.0.dist-info/RECORD +175 -0
- springbootai-1.8.0.dist-info/WHEEL +5 -0
- springbootai-1.8.0.dist-info/entry_points.txt +2 -0
- springbootai-1.8.0.dist-info/licenses/LICENSE +7 -0
- springbootai-1.8.0.dist-info/top_level.txt +1 -0
spring/orm/migration.py
ADDED
|
@@ -0,0 +1,419 @@
|
|
|
1
|
+
"""
|
|
2
|
+
数据库迁移管理器 (Database Migration Manager)
|
|
3
|
+
|
|
4
|
+
类 Flyway 风格的轻量数据库迁移工具:
|
|
5
|
+
- 基于 SQL 文件版本号管理
|
|
6
|
+
- 自动追踪已执行迁移(schema_version 表)
|
|
7
|
+
- 支持 checksum 校验防止篡改
|
|
8
|
+
- 支持 MySQL/PostgreSQL/SQLite
|
|
9
|
+
- 迁移文件命名: V{version}__{description}.sql
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
import os
|
|
13
|
+
import re
|
|
14
|
+
import hashlib
|
|
15
|
+
import logging
|
|
16
|
+
import time
|
|
17
|
+
from typing import List, Dict, Optional, Tuple
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
|
|
20
|
+
logger = logging.getLogger("Spring.ORM.Migration")
|
|
21
|
+
|
|
22
|
+
# 迁移文件命名模式: V1__init.sql, V2__add_users_table.sql
|
|
23
|
+
_MIGRATION_PATTERN = re.compile(r'^V(\d+(?:\.\d+)?)__(.+)\.sql$', re.IGNORECASE)
|
|
24
|
+
# 版本号分隔符
|
|
25
|
+
_VERSION_SPLIT = re.compile(r'[._]')
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class MigrationError(Exception):
|
|
29
|
+
"""迁移执行错误"""
|
|
30
|
+
pass
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class MigrationState:
|
|
34
|
+
"""迁移状态"""
|
|
35
|
+
PENDING = "PENDING"
|
|
36
|
+
SUCCESS = "SUCCESS"
|
|
37
|
+
FAILED = "FAILED"
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class MigrationRecord:
|
|
41
|
+
"""单条迁移记录"""
|
|
42
|
+
__slots__ = ('version', 'description', 'script', 'checksum',
|
|
43
|
+
'installed_on', 'execution_time', 'success')
|
|
44
|
+
|
|
45
|
+
def __init__(self, version: str, description: str, script: str,
|
|
46
|
+
checksum: str, execution_time: float = 0.0,
|
|
47
|
+
success: bool = True):
|
|
48
|
+
self.version = version
|
|
49
|
+
self.description = description
|
|
50
|
+
self.script = script
|
|
51
|
+
self.checksum = checksum
|
|
52
|
+
self.installed_on = time.time()
|
|
53
|
+
self.execution_time = execution_time
|
|
54
|
+
self.success = success
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class MigrationManager:
|
|
58
|
+
"""
|
|
59
|
+
数据库迁移管理器
|
|
60
|
+
|
|
61
|
+
Usage:
|
|
62
|
+
manager = MigrationManager(connection_pool, migrations_dir="sql/migrations")
|
|
63
|
+
manager.migrate() # 执行所有待执行迁移
|
|
64
|
+
"""
|
|
65
|
+
|
|
66
|
+
def __init__(self, connection_pool, migrations_dir: str,
|
|
67
|
+
dialect: str = "mysql", table_name: str = "schema_version"):
|
|
68
|
+
self.pool = connection_pool
|
|
69
|
+
self.migrations_dir = Path(migrations_dir)
|
|
70
|
+
self.dialect = dialect.lower()
|
|
71
|
+
self.table_name = table_name
|
|
72
|
+
self._ensure_version_table()
|
|
73
|
+
|
|
74
|
+
def _ensure_version_table(self) -> None:
|
|
75
|
+
"""确保 schema_version 表存在"""
|
|
76
|
+
ddl_map = {
|
|
77
|
+
'mysql': f"""
|
|
78
|
+
CREATE TABLE IF NOT EXISTS `{self.table_name}` (
|
|
79
|
+
`version` VARCHAR(50) NOT NULL PRIMARY KEY,
|
|
80
|
+
`description` VARCHAR(200) NOT NULL,
|
|
81
|
+
`script` VARCHAR(500) NOT NULL,
|
|
82
|
+
`checksum` VARCHAR(64) NOT NULL,
|
|
83
|
+
`installed_on` BIGINT NOT NULL,
|
|
84
|
+
`execution_time` INT NOT NULL,
|
|
85
|
+
`success` TINYINT(1) NOT NULL DEFAULT 1
|
|
86
|
+
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
|
|
87
|
+
""",
|
|
88
|
+
'postgresql': f"""
|
|
89
|
+
CREATE TABLE IF NOT EXISTS "{self.table_name}" (
|
|
90
|
+
"version" VARCHAR(50) PRIMARY KEY,
|
|
91
|
+
"description" VARCHAR(200) NOT NULL,
|
|
92
|
+
"script" VARCHAR(500) NOT NULL,
|
|
93
|
+
"checksum" VARCHAR(64) NOT NULL,
|
|
94
|
+
"installed_on" BIGINT NOT NULL,
|
|
95
|
+
"execution_time" INTEGER NOT NULL,
|
|
96
|
+
"success" BOOLEAN NOT NULL DEFAULT TRUE
|
|
97
|
+
)
|
|
98
|
+
""",
|
|
99
|
+
'sqlite': f"""
|
|
100
|
+
CREATE TABLE IF NOT EXISTS "{self.table_name}" (
|
|
101
|
+
"version" TEXT PRIMARY KEY,
|
|
102
|
+
"description" TEXT NOT NULL,
|
|
103
|
+
"script" TEXT NOT NULL,
|
|
104
|
+
"checksum" TEXT NOT NULL,
|
|
105
|
+
"installed_on" INTEGER NOT NULL,
|
|
106
|
+
"execution_time" INTEGER NOT NULL,
|
|
107
|
+
"success" INTEGER NOT NULL DEFAULT 1
|
|
108
|
+
)
|
|
109
|
+
""",
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
ddl = ddl_map.get(self.dialect)
|
|
113
|
+
if not ddl:
|
|
114
|
+
logger.warning(f"Migration: dialect '{self.dialect}' not officially supported, trying generic")
|
|
115
|
+
ddl = ddl_map['mysql']
|
|
116
|
+
|
|
117
|
+
conn = None
|
|
118
|
+
try:
|
|
119
|
+
pooled = self.pool.get_connection()
|
|
120
|
+
conn = pooled.connection
|
|
121
|
+
cursor = conn.cursor()
|
|
122
|
+
for stmt in ddl.split(';'):
|
|
123
|
+
stmt = stmt.strip()
|
|
124
|
+
if stmt:
|
|
125
|
+
cursor.execute(stmt)
|
|
126
|
+
conn.commit()
|
|
127
|
+
cursor.close()
|
|
128
|
+
self.pool.return_connection(pooled)
|
|
129
|
+
except Exception as e:
|
|
130
|
+
if conn:
|
|
131
|
+
try:
|
|
132
|
+
conn.rollback()
|
|
133
|
+
except Exception:
|
|
134
|
+
pass
|
|
135
|
+
raise MigrationError(f"Failed to create version table: {e}") from e
|
|
136
|
+
|
|
137
|
+
def _get_applied_versions(self) -> Dict[str, MigrationRecord]:
|
|
138
|
+
"""获取已执行的迁移版本"""
|
|
139
|
+
conn = None
|
|
140
|
+
try:
|
|
141
|
+
pooled = self.pool.get_connection()
|
|
142
|
+
conn = pooled.connection
|
|
143
|
+
cursor = conn.cursor()
|
|
144
|
+
if self.dialect == 'mysql':
|
|
145
|
+
cursor.execute(f"SELECT version, description, script, checksum, installed_on, execution_time, success FROM `{self.table_name}` WHERE success = 1")
|
|
146
|
+
else:
|
|
147
|
+
cursor.execute(f'SELECT version, description, script, checksum, installed_on, execution_time, success FROM "{self.table_name}" WHERE success = 1')
|
|
148
|
+
|
|
149
|
+
applied = {}
|
|
150
|
+
for row in cursor.fetchall():
|
|
151
|
+
if isinstance(row, dict):
|
|
152
|
+
rec = MigrationRecord(
|
|
153
|
+
version=str(row['version']),
|
|
154
|
+
description=row['description'],
|
|
155
|
+
script=row['script'],
|
|
156
|
+
checksum=row['checksum'],
|
|
157
|
+
execution_time=row.get('execution_time', 0),
|
|
158
|
+
success=bool(row.get('success', True))
|
|
159
|
+
)
|
|
160
|
+
else:
|
|
161
|
+
rec = MigrationRecord(
|
|
162
|
+
version=str(row[0]),
|
|
163
|
+
description=row[1],
|
|
164
|
+
script=row[2],
|
|
165
|
+
checksum=row[3],
|
|
166
|
+
execution_time=row[4] if len(row) > 4 else 0,
|
|
167
|
+
success=bool(row[6]) if len(row) > 6 else True
|
|
168
|
+
)
|
|
169
|
+
applied[rec.version] = rec
|
|
170
|
+
cursor.close()
|
|
171
|
+
self.pool.return_connection(pooled)
|
|
172
|
+
return applied
|
|
173
|
+
except Exception as e:
|
|
174
|
+
if conn:
|
|
175
|
+
try:
|
|
176
|
+
conn.rollback()
|
|
177
|
+
except Exception:
|
|
178
|
+
pass
|
|
179
|
+
raise MigrationError(f"Failed to read applied versions: {e}") from e
|
|
180
|
+
|
|
181
|
+
def _discover_migrations(self) -> List[Tuple[str, str, str, str]]:
|
|
182
|
+
"""发现迁移文件,返回 [(version, description, filename, content_checksum)]"""
|
|
183
|
+
if not self.migrations_dir.exists():
|
|
184
|
+
logger.warning(f"Migration directory not found: {self.migrations_dir}")
|
|
185
|
+
return []
|
|
186
|
+
|
|
187
|
+
migrations = []
|
|
188
|
+
for f in sorted(self.migrations_dir.iterdir()):
|
|
189
|
+
if not f.is_file():
|
|
190
|
+
continue
|
|
191
|
+
match = _MIGRATION_PATTERN.match(f.name)
|
|
192
|
+
if not match:
|
|
193
|
+
continue
|
|
194
|
+
version = match.group(1)
|
|
195
|
+
description = match.group(2).replace('_', ' ')
|
|
196
|
+
content = f.read_text(encoding='utf-8')
|
|
197
|
+
checksum = hashlib.sha256(content.encode('utf-8')).hexdigest()[:63]
|
|
198
|
+
migrations.append((version, description, f.name, content, checksum))
|
|
199
|
+
|
|
200
|
+
# 按版本号排序
|
|
201
|
+
def _version_key(item):
|
|
202
|
+
parts = []
|
|
203
|
+
for p in _VERSION_SPLIT.split(item[0]):
|
|
204
|
+
try:
|
|
205
|
+
parts.append((0, int(p)))
|
|
206
|
+
except ValueError:
|
|
207
|
+
parts.append((1, p))
|
|
208
|
+
return parts
|
|
209
|
+
|
|
210
|
+
migrations.sort(key=_version_key)
|
|
211
|
+
return [(m[0], m[1], m[2], m[4]) for m in migrations]
|
|
212
|
+
|
|
213
|
+
def _version_tuple(self, version: str) -> tuple:
|
|
214
|
+
"""版本号转可比较tuple"""
|
|
215
|
+
parts = []
|
|
216
|
+
for p in _VERSION_SPLIT.split(version):
|
|
217
|
+
try:
|
|
218
|
+
parts.append(int(p))
|
|
219
|
+
except ValueError:
|
|
220
|
+
parts.append(p)
|
|
221
|
+
return tuple(parts)
|
|
222
|
+
|
|
223
|
+
def _execute_migration(self, version: str, description: str,
|
|
224
|
+
script_name: str, sql_content: str,
|
|
225
|
+
checksum: str) -> MigrationRecord:
|
|
226
|
+
"""执行单条迁移"""
|
|
227
|
+
conn = None
|
|
228
|
+
start = time.monotonic()
|
|
229
|
+
try:
|
|
230
|
+
pooled = self.pool.get_connection()
|
|
231
|
+
conn = pooled.connection
|
|
232
|
+
cursor = conn.cursor()
|
|
233
|
+
|
|
234
|
+
# 按分号分割语句(简单分割,不处理存储过程中的分号)
|
|
235
|
+
statements = []
|
|
236
|
+
current = []
|
|
237
|
+
for line in sql_content.split('\n'):
|
|
238
|
+
stripped = line.strip()
|
|
239
|
+
# 跳过注释
|
|
240
|
+
if stripped.startswith('--') or stripped.startswith('#'):
|
|
241
|
+
continue
|
|
242
|
+
current.append(line)
|
|
243
|
+
if stripped.endswith(';'):
|
|
244
|
+
stmt = '\n'.join(current).strip().rstrip(';')
|
|
245
|
+
if stmt:
|
|
246
|
+
statements.append(stmt)
|
|
247
|
+
current = []
|
|
248
|
+
# 最后一条可能没有分号
|
|
249
|
+
remaining = '\n'.join(current).strip()
|
|
250
|
+
if remaining:
|
|
251
|
+
statements.append(remaining)
|
|
252
|
+
|
|
253
|
+
for stmt in statements:
|
|
254
|
+
stmt = stmt.strip()
|
|
255
|
+
if stmt:
|
|
256
|
+
logger.debug(f"Executing migration SQL: {stmt[:100]}...")
|
|
257
|
+
cursor.execute(stmt)
|
|
258
|
+
|
|
259
|
+
elapsed = time.monotonic() - start
|
|
260
|
+
|
|
261
|
+
# 记录迁移
|
|
262
|
+
if self.dialect == 'mysql':
|
|
263
|
+
cursor.execute(
|
|
264
|
+
f"INSERT INTO `{self.table_name}` (version, description, script, checksum, installed_on, execution_time, success) VALUES (%s, %s, %s, %s, %s, %s, %s)",
|
|
265
|
+
(version, description, script_name, checksum, int(time.time()), int(elapsed * 1000), 1)
|
|
266
|
+
)
|
|
267
|
+
else:
|
|
268
|
+
ph = '?' if self.dialect == 'sqlite' else '%s'
|
|
269
|
+
table_ref = f'"{self.table_name}"'
|
|
270
|
+
cursor.execute(
|
|
271
|
+
f'INSERT INTO {table_ref} (version, description, script, checksum, installed_on, execution_time, success) VALUES ({ph}, {ph}, {ph}, {ph}, {ph}, {ph}, {ph})',
|
|
272
|
+
(version, description, script_name, checksum, int(time.time()), int(elapsed * 1000), 1)
|
|
273
|
+
)
|
|
274
|
+
|
|
275
|
+
conn.commit()
|
|
276
|
+
cursor.close()
|
|
277
|
+
self.pool.return_connection(pooled)
|
|
278
|
+
|
|
279
|
+
record = MigrationRecord(version, description, script_name, checksum, elapsed, True)
|
|
280
|
+
logger.info(f"Migration V{version} ({description}) applied successfully in {elapsed:.2f}s")
|
|
281
|
+
return record
|
|
282
|
+
|
|
283
|
+
except Exception as e:
|
|
284
|
+
elapsed = time.monotonic() - start
|
|
285
|
+
if conn:
|
|
286
|
+
try:
|
|
287
|
+
conn.rollback()
|
|
288
|
+
except Exception:
|
|
289
|
+
pass
|
|
290
|
+
logger.error(f"Migration V{version} failed after {elapsed:.2f}s: {e}")
|
|
291
|
+
raise MigrationError(f"Migration V{version} ({description}) failed: {e}") from e
|
|
292
|
+
|
|
293
|
+
def migrate(self, baseline: bool = False) -> List[MigrationRecord]:
|
|
294
|
+
"""
|
|
295
|
+
执行所有待执行的迁移
|
|
296
|
+
|
|
297
|
+
Args:
|
|
298
|
+
baseline: 如果为True,将现有数据库标记为已完成初始迁移(用于在已有数据库上启用迁移)
|
|
299
|
+
|
|
300
|
+
Returns:
|
|
301
|
+
本次执行的迁移记录列表
|
|
302
|
+
"""
|
|
303
|
+
applied = self._get_applied_versions()
|
|
304
|
+
discovered = self._discover_migrations()
|
|
305
|
+
|
|
306
|
+
if not discovered:
|
|
307
|
+
logger.info("No migration files found")
|
|
308
|
+
return []
|
|
309
|
+
|
|
310
|
+
# 校验已执行迁移的checksum
|
|
311
|
+
for version, rec in applied.items():
|
|
312
|
+
for disc_ver, desc, script, checksum in discovered:
|
|
313
|
+
if disc_ver == version and rec.checksum != checksum:
|
|
314
|
+
raise MigrationError(
|
|
315
|
+
f"Checksum mismatch for migration V{version}! "
|
|
316
|
+
f"Applied checksum={rec.checksum}, current={checksum}. "
|
|
317
|
+
f"Migration files must not be modified after application."
|
|
318
|
+
)
|
|
319
|
+
|
|
320
|
+
executed = []
|
|
321
|
+
for version, description, script, checksum in discovered:
|
|
322
|
+
if version in applied:
|
|
323
|
+
continue
|
|
324
|
+
|
|
325
|
+
if baseline and not executed:
|
|
326
|
+
# baseline模式:将第一条之前的视为已baseline
|
|
327
|
+
logger.info(f"Baseline: marking migrations up to V{version} as applied")
|
|
328
|
+
baseline_conn = None
|
|
329
|
+
try:
|
|
330
|
+
pooled = self.pool.get_connection()
|
|
331
|
+
baseline_conn = pooled.connection
|
|
332
|
+
cursor = baseline_conn.cursor()
|
|
333
|
+
if self.dialect == 'mysql':
|
|
334
|
+
cursor.execute(
|
|
335
|
+
f"INSERT IGNORE INTO `{self.table_name}` (version, description, script, checksum, installed_on, execution_time, success) VALUES (%s, %s, %s, %s, %s, %s, %s)",
|
|
336
|
+
(version, description, script, checksum, int(time.time()), 0, 1)
|
|
337
|
+
)
|
|
338
|
+
else:
|
|
339
|
+
ph = '?' if self.dialect == 'sqlite' else '%s'
|
|
340
|
+
cursor.execute(
|
|
341
|
+
f'INSERT INTO "{self.table_name}" (version, description, script, checksum, installed_on, execution_time, success) SELECT {ph},{ph},{ph},{ph},{ph},{ph},{ph} WHERE NOT EXISTS (SELECT 1 FROM "{self.table_name}" WHERE version = {ph})',
|
|
342
|
+
(version, description, script, checksum, int(time.time()), 0, 1, version)
|
|
343
|
+
)
|
|
344
|
+
baseline_conn.commit()
|
|
345
|
+
cursor.close()
|
|
346
|
+
self.pool.return_connection(pooled)
|
|
347
|
+
except Exception:
|
|
348
|
+
if baseline_conn:
|
|
349
|
+
try:
|
|
350
|
+
baseline_conn.rollback()
|
|
351
|
+
except Exception:
|
|
352
|
+
pass
|
|
353
|
+
baseline = False
|
|
354
|
+
continue
|
|
355
|
+
|
|
356
|
+
record = self._execute_migration(version, description, script, self._read_sql(script), checksum)
|
|
357
|
+
executed.append(record)
|
|
358
|
+
|
|
359
|
+
if not executed:
|
|
360
|
+
logger.info(f"All {len(applied)} migrations are up to date")
|
|
361
|
+
else:
|
|
362
|
+
logger.info(f"Applied {len(executed)} new migration(s)")
|
|
363
|
+
|
|
364
|
+
return executed
|
|
365
|
+
|
|
366
|
+
def _read_sql(self, script_name: str) -> str:
|
|
367
|
+
"""读取SQL文件内容"""
|
|
368
|
+
path = self.migrations_dir / script_name
|
|
369
|
+
return path.read_text(encoding='utf-8')
|
|
370
|
+
|
|
371
|
+
def status(self) -> Dict:
|
|
372
|
+
"""获取迁移状态"""
|
|
373
|
+
applied = self._get_applied_versions()
|
|
374
|
+
discovered = self._discover_migrations()
|
|
375
|
+
|
|
376
|
+
migrations = []
|
|
377
|
+
for version, description, script, checksum in discovered:
|
|
378
|
+
state = MigrationState.SUCCESS if version in applied else MigrationState.PENDING
|
|
379
|
+
if version in applied and applied[version].checksum != checksum:
|
|
380
|
+
state = "CHECKSUM_MISMATCH"
|
|
381
|
+
migrations.append({
|
|
382
|
+
'version': version,
|
|
383
|
+
'description': description,
|
|
384
|
+
'script': script,
|
|
385
|
+
'state': state,
|
|
386
|
+
})
|
|
387
|
+
|
|
388
|
+
pending = [m for m in migrations if m['state'] == MigrationState.PENDING]
|
|
389
|
+
return {
|
|
390
|
+
'total': len(migrations),
|
|
391
|
+
'applied': len(applied),
|
|
392
|
+
'pending': len(pending),
|
|
393
|
+
'migrations': migrations,
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
def repair(self) -> int:
|
|
397
|
+
"""修复失败的迁移记录(标记为可重试)"""
|
|
398
|
+
conn = None
|
|
399
|
+
try:
|
|
400
|
+
pooled = self.pool.get_connection()
|
|
401
|
+
conn = pooled.connection
|
|
402
|
+
cursor = conn.cursor()
|
|
403
|
+
if self.dialect == 'mysql':
|
|
404
|
+
cursor.execute(f"DELETE FROM `{self.table_name}` WHERE success = 0")
|
|
405
|
+
else:
|
|
406
|
+
cursor.execute(f'DELETE FROM "{self.table_name}" WHERE success = 0')
|
|
407
|
+
deleted = cursor.rowcount
|
|
408
|
+
conn.commit()
|
|
409
|
+
cursor.close()
|
|
410
|
+
self.pool.return_connection(pooled)
|
|
411
|
+
logger.info(f"Repaired {deleted} failed migration record(s)")
|
|
412
|
+
return deleted
|
|
413
|
+
except Exception as e:
|
|
414
|
+
if conn:
|
|
415
|
+
try:
|
|
416
|
+
conn.rollback()
|
|
417
|
+
except Exception:
|
|
418
|
+
pass
|
|
419
|
+
raise MigrationError(f"Repair failed: {e}") from e
|