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/ddl_auto.py
ADDED
|
@@ -0,0 +1,1217 @@
|
|
|
1
|
+
"""
|
|
2
|
+
ORM DDL 自动建表/更新 (JPA hibernate.ddl-auto 风格)
|
|
3
|
+
|
|
4
|
+
支持从 Python 实体类(dataclass / 带类型注解的类)自动生成 DDL 语句,
|
|
5
|
+
并根据配置执行建表/更新/验证。
|
|
6
|
+
|
|
7
|
+
支持的 ddl-auto 模式:
|
|
8
|
+
- none: 不做任何操作(默认)
|
|
9
|
+
- validate: 验证表结构是否与实体匹配,不匹配时报错
|
|
10
|
+
- update: 增量更新表结构(添加新列、新索引)
|
|
11
|
+
- create: 每次启动都删除并重新创建表
|
|
12
|
+
- create-drop: 启动时创建,关闭时删除(测试用)
|
|
13
|
+
|
|
14
|
+
支持 MySQL/PostgreSQL/SQLite
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
import logging
|
|
18
|
+
import inspect
|
|
19
|
+
import time
|
|
20
|
+
import threading
|
|
21
|
+
from typing import Dict, List, Optional, Any, Type, Tuple, get_type_hints
|
|
22
|
+
from dataclasses import is_dataclass, fields as dataclass_fields
|
|
23
|
+
from enum import Enum
|
|
24
|
+
|
|
25
|
+
from spring.core.typing_utils import unwrap_optional_type
|
|
26
|
+
|
|
27
|
+
logger = logging.getLogger("Spring.ORM.DDL")
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class DdlAutoMode(Enum):
|
|
31
|
+
NONE = "none"
|
|
32
|
+
VALIDATE = "validate"
|
|
33
|
+
UPDATE = "update"
|
|
34
|
+
CREATE = "create"
|
|
35
|
+
CREATE_DROP = "create-drop"
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
# 类型映射: Python type -> (MySQL type, PostgreSQL type, SQLite type)
|
|
39
|
+
_TYPE_MAP = {
|
|
40
|
+
int: ("BIGINT", "BIGINT", "INTEGER"),
|
|
41
|
+
str: ("VARCHAR(255)", "VARCHAR(255)", "TEXT"),
|
|
42
|
+
float: ("DOUBLE", "DOUBLE PRECISION", "REAL"),
|
|
43
|
+
bool: ("TINYINT(1)", "BOOLEAN", "INTEGER"),
|
|
44
|
+
bytes: ("BLOB", "BYTEA", "BLOB"),
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _get_sql_type(py_type: Any, dialect: str, column_info: dict = None) -> str:
|
|
49
|
+
"""将Python类型映射到SQL类型"""
|
|
50
|
+
column_info = column_info or {}
|
|
51
|
+
# 自定义长度
|
|
52
|
+
length = column_info.get('length')
|
|
53
|
+
if py_type is str and length:
|
|
54
|
+
if dialect == 'mysql':
|
|
55
|
+
return f"VARCHAR({length})"
|
|
56
|
+
elif dialect == 'postgresql':
|
|
57
|
+
return f"VARCHAR({length})"
|
|
58
|
+
else:
|
|
59
|
+
return "TEXT"
|
|
60
|
+
# 显式指定 columnDefinition
|
|
61
|
+
col_def = column_info.get('column_definition')
|
|
62
|
+
if col_def:
|
|
63
|
+
return col_def
|
|
64
|
+
mapped = _TYPE_MAP.get(py_type)
|
|
65
|
+
if mapped:
|
|
66
|
+
if dialect == 'mysql':
|
|
67
|
+
return mapped[0]
|
|
68
|
+
elif dialect == 'postgresql':
|
|
69
|
+
return mapped[1]
|
|
70
|
+
else:
|
|
71
|
+
return mapped[2]
|
|
72
|
+
# datetime / date
|
|
73
|
+
type_name = getattr(py_type, '__name__', str(py_type))
|
|
74
|
+
if 'datetime' in type_name.lower() or 'date' in type_name.lower():
|
|
75
|
+
if dialect == 'mysql':
|
|
76
|
+
return "DATETIME"
|
|
77
|
+
elif dialect == 'postgresql':
|
|
78
|
+
return "TIMESTAMP"
|
|
79
|
+
else:
|
|
80
|
+
return "TEXT"
|
|
81
|
+
if 'decimal' in type_name.lower():
|
|
82
|
+
precision = column_info.get('precision', 10)
|
|
83
|
+
scale = column_info.get('scale', 2)
|
|
84
|
+
if dialect in ('mysql', 'postgresql'):
|
|
85
|
+
return f"DECIMAL({precision},{scale})"
|
|
86
|
+
return "REAL"
|
|
87
|
+
# 默认:TEXT
|
|
88
|
+
return "TEXT" if dialect == 'sqlite' else ("VARCHAR(255)" if dialect in ('mysql', 'postgresql') else "TEXT")
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
class Column:
|
|
92
|
+
"""列定义注解/描述符"""
|
|
93
|
+
def __init__(self, name: str = "", nullable: bool = True, unique: bool = False,
|
|
94
|
+
length: int = 0, primary_key: bool = False, auto_increment: bool = False,
|
|
95
|
+
default: Any = None, column_definition: str = "", comment: str = "",
|
|
96
|
+
precision: int = 0, scale: int = 0):
|
|
97
|
+
self.name = name
|
|
98
|
+
self.nullable = nullable
|
|
99
|
+
self.unique = unique
|
|
100
|
+
self.length = length
|
|
101
|
+
self.primary_key = primary_key
|
|
102
|
+
self.auto_increment = auto_increment
|
|
103
|
+
self.default = default
|
|
104
|
+
self.column_definition = column_definition
|
|
105
|
+
self.comment = comment
|
|
106
|
+
self.precision = precision
|
|
107
|
+
self.scale = scale
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
class Id(Column):
|
|
111
|
+
"""主键列"""
|
|
112
|
+
def __init__(self, name: str = "", auto_increment: bool = True, **kwargs):
|
|
113
|
+
kwargs.pop('primary_key', None)
|
|
114
|
+
super().__init__(name=name, primary_key=True, auto_increment=auto_increment,
|
|
115
|
+
nullable=False, **kwargs)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
class Version(Column):
|
|
119
|
+
"""``@Version`` 乐观锁字段(对齐 JPA ``javax.persistence.Version``)。
|
|
120
|
+
|
|
121
|
+
标记该字段为乐观锁版本号:DDL 生成 ``INTEGER NOT NULL DEFAULT 0``;
|
|
122
|
+
更新时配合 ``OptimisticLockExecutor`` 在 WHERE 子句追加 ``version = ?`` 并自增。
|
|
123
|
+
|
|
124
|
+
用法(两种形式,与 ``Column``/``Id`` 一致)::
|
|
125
|
+
|
|
126
|
+
@entity("sys_user")
|
|
127
|
+
class User:
|
|
128
|
+
id = Id()
|
|
129
|
+
version = Version() # 类属性描述符形式
|
|
130
|
+
def __init__(self, id=None, version=0): ...
|
|
131
|
+
|
|
132
|
+
# 或函数装饰器形式
|
|
133
|
+
@version_column()
|
|
134
|
+
def version(self): ...
|
|
135
|
+
|
|
136
|
+
与 JPA 的差异(已标注):
|
|
137
|
+
- JPA/Hibernate 由 ORM 自动在 UPDATE 时追加 version 检查并自增;
|
|
138
|
+
本框架内嵌 PyMyBatis 不自动注入 version 子句,需通过 ``OptimisticLockExecutor``
|
|
139
|
+
显式执行乐观锁更新(见本文件末尾),或在 XML/注解 SQL 中手写 version 条件。
|
|
140
|
+
"""
|
|
141
|
+
def __init__(self, name: str = "", **kwargs):
|
|
142
|
+
kwargs.pop('primary_key', None)
|
|
143
|
+
kwargs.pop('auto_increment', None)
|
|
144
|
+
# 版本字段非空、默认 0
|
|
145
|
+
kwargs.setdefault('nullable', False)
|
|
146
|
+
kwargs.setdefault('default', 0)
|
|
147
|
+
super().__init__(name=name, primary_key=False, auto_increment=False, **kwargs)
|
|
148
|
+
# 版本标记,供 _build_column_meta 识别
|
|
149
|
+
self.version = True
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
class Transient:
|
|
153
|
+
"""``@Transient`` 瞬态字段标记(对齐 JPA ``javax.persistence.Transient``)。
|
|
154
|
+
|
|
155
|
+
标记该字段**不持久化**:DDL 自动建表与实体解析均跳过该字段。
|
|
156
|
+
|
|
157
|
+
用法(两种形式,与 ``ExcelIgnore`` 一致)::
|
|
158
|
+
|
|
159
|
+
@entity("sys_user")
|
|
160
|
+
class User:
|
|
161
|
+
id = Id()
|
|
162
|
+
display_name = Transient() # 类属性描述符形式:不落库
|
|
163
|
+
def __init__(self, id=None, display_name=None): ...
|
|
164
|
+
|
|
165
|
+
# 或函数装饰器形式
|
|
166
|
+
@transient_field()
|
|
167
|
+
def display_name(self): ...
|
|
168
|
+
|
|
169
|
+
实现为独立标记类(非 ``Column`` 子类),因为瞬态字段根本不是列。
|
|
170
|
+
"""
|
|
171
|
+
def __init__(self, default: Any = None):
|
|
172
|
+
self.default = default
|
|
173
|
+
self.attr_name: str = ""
|
|
174
|
+
|
|
175
|
+
def __set_name__(self, owner: type, name: str) -> None:
|
|
176
|
+
"""类属性描述符形式时,Python 自动回填字段名(镜像 ``ExcelIgnore``)。"""
|
|
177
|
+
self.attr_name = name
|
|
178
|
+
|
|
179
|
+
def __call__(self, target):
|
|
180
|
+
"""函数装饰器形式:``@Transient()``,把 ``__transient__`` 标记挂到目标。"""
|
|
181
|
+
setattr(target, '__transient__', True)
|
|
182
|
+
self.attr_name = getattr(target, '__name__', '')
|
|
183
|
+
return target
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
class Table:
|
|
187
|
+
"""表注解"""
|
|
188
|
+
def __init__(self, name: str = "", indexes: List['Index'] = None, comment: str = ""):
|
|
189
|
+
self.name = name
|
|
190
|
+
self.indexes = indexes or []
|
|
191
|
+
self.comment = comment
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
class Index:
|
|
195
|
+
"""索引定义"""
|
|
196
|
+
def __init__(self, name: str, columns: List[str], unique: bool = False):
|
|
197
|
+
self.name = name
|
|
198
|
+
self.columns = columns
|
|
199
|
+
self.unique = unique
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def column(**kwargs):
|
|
203
|
+
"""字段列装饰器/描述符,用于标注实体字段"""
|
|
204
|
+
col = Column(**kwargs)
|
|
205
|
+
def decorator(f):
|
|
206
|
+
setattr(f, '__column__', col)
|
|
207
|
+
return f
|
|
208
|
+
if len(kwargs) == 1 and 'name' in kwargs and callable(kwargs.get('name')):
|
|
209
|
+
# used as @column without parens
|
|
210
|
+
f = kwargs['name']
|
|
211
|
+
setattr(f, '__column__', Column())
|
|
212
|
+
return f
|
|
213
|
+
return decorator
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def id_column(auto_increment: bool = True, **kwargs):
|
|
217
|
+
"""主键列装饰器"""
|
|
218
|
+
col = Id(auto_increment=auto_increment, **kwargs)
|
|
219
|
+
def decorator(f):
|
|
220
|
+
setattr(f, '__column__', col)
|
|
221
|
+
return f
|
|
222
|
+
return decorator
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def version_column(**kwargs):
|
|
226
|
+
"""``@Version`` 函数装饰器形式(镜像 ``column()`` / ``id_column()``)。
|
|
227
|
+
|
|
228
|
+
用法::
|
|
229
|
+
|
|
230
|
+
@entity("sys_user")
|
|
231
|
+
class User:
|
|
232
|
+
id = Id()
|
|
233
|
+
@version_column()
|
|
234
|
+
def version(self): ...
|
|
235
|
+
"""
|
|
236
|
+
col = Version(**kwargs)
|
|
237
|
+
def decorator(f):
|
|
238
|
+
setattr(f, '__column__', col)
|
|
239
|
+
return f
|
|
240
|
+
return decorator
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def transient_field():
|
|
244
|
+
"""``@Transient`` 函数装饰器形式(镜像 ``ExcelIgnore.__call__``)。
|
|
245
|
+
|
|
246
|
+
用法::
|
|
247
|
+
|
|
248
|
+
@entity("sys_user")
|
|
249
|
+
class User:
|
|
250
|
+
id = Id()
|
|
251
|
+
@transient_field()
|
|
252
|
+
def display_name(self): ...
|
|
253
|
+
"""
|
|
254
|
+
def decorator(f):
|
|
255
|
+
setattr(f, '__transient__', True)
|
|
256
|
+
return f
|
|
257
|
+
return decorator
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def _camel_to_snake(name: str) -> str:
|
|
261
|
+
"""驼峰转下划线"""
|
|
262
|
+
import re
|
|
263
|
+
s1 = re.sub('(.)([A-Z][a-z]+)', r'\1_\2', name)
|
|
264
|
+
return re.sub('([a-z0-9])([A-Z])', r'\1_\2', s1).lower()
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def _is_transient_field(cls: Type, attr_name: str) -> bool:
|
|
268
|
+
"""检查字段是否标记为 ``@Transient``(对齐 Excel ``_has_explicit_properties`` 反射范式)。
|
|
269
|
+
|
|
270
|
+
判定规则(取 MRO 中最近声明):
|
|
271
|
+
1. 类属性为 ``Transient`` 实例(描述符形式)。
|
|
272
|
+
2. 类属性带 ``__transient__`` 标记(``@transient_field()`` 函数装饰器形式)。
|
|
273
|
+
"""
|
|
274
|
+
for cls_base in cls.__mro__:
|
|
275
|
+
if attr_name in cls_base.__dict__:
|
|
276
|
+
cval = cls_base.__dict__[attr_name]
|
|
277
|
+
if isinstance(cval, Transient):
|
|
278
|
+
return True
|
|
279
|
+
if getattr(cval, '__transient__', False) is True:
|
|
280
|
+
return True
|
|
281
|
+
return False # 最近声明非瞬态,子类覆盖
|
|
282
|
+
return False
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
class EntityTable:
|
|
286
|
+
"""从实体类解析出的表元数据"""
|
|
287
|
+
__slots__ = ('table_name', 'columns', 'indexes', 'comment', 'entity_class')
|
|
288
|
+
|
|
289
|
+
def __init__(self, table_name: str, columns: List[dict], indexes: List[Index],
|
|
290
|
+
comment: str, entity_class: type):
|
|
291
|
+
self.table_name = table_name
|
|
292
|
+
self.columns = columns
|
|
293
|
+
self.indexes = indexes
|
|
294
|
+
self.comment = comment
|
|
295
|
+
self.entity_class = entity_class
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
class DdlAutoManager:
|
|
299
|
+
"""
|
|
300
|
+
DDL 自动管理器
|
|
301
|
+
|
|
302
|
+
Usage:
|
|
303
|
+
ddl = DdlAutoManager(connection_pool, dialect="mysql", mode="update")
|
|
304
|
+
ddl.register_entity(User)
|
|
305
|
+
ddl.register_entity(Order)
|
|
306
|
+
ddl.execute() # 根据mode执行
|
|
307
|
+
"""
|
|
308
|
+
|
|
309
|
+
def __init__(self, connection_pool, dialect: str = "mysql",
|
|
310
|
+
mode: str = "none", entity_packages: List[str] = None):
|
|
311
|
+
self.pool = connection_pool
|
|
312
|
+
self.dialect = dialect.lower()
|
|
313
|
+
try:
|
|
314
|
+
self.mode = DdlAutoMode(mode.lower())
|
|
315
|
+
except ValueError:
|
|
316
|
+
logger.warning(f"Unknown ddl-auto mode: {mode}, using 'none'")
|
|
317
|
+
self.mode = DdlAutoMode.NONE
|
|
318
|
+
self._entities: List[Type] = []
|
|
319
|
+
self._parsed: List[EntityTable] = []
|
|
320
|
+
self._executed_sql: List[str] = []
|
|
321
|
+
self._lock = threading.Lock()
|
|
322
|
+
if entity_packages:
|
|
323
|
+
self._scan_packages(entity_packages)
|
|
324
|
+
|
|
325
|
+
def register_entity(self, entity_class: Type):
|
|
326
|
+
"""注册实体类"""
|
|
327
|
+
if entity_class not in self._entities:
|
|
328
|
+
self._entities.append(entity_class)
|
|
329
|
+
|
|
330
|
+
def register_entities(self, entity_classes: List[Type]):
|
|
331
|
+
"""批量注册实体类"""
|
|
332
|
+
for cls in entity_classes:
|
|
333
|
+
self.register_entity(cls)
|
|
334
|
+
|
|
335
|
+
def _scan_packages(self, packages: List[str]):
|
|
336
|
+
"""扫描包下所有实体类(简单实现:要求实体类使用@entity/@Table装饰器或继承基类)"""
|
|
337
|
+
import importlib
|
|
338
|
+
import pkgutil
|
|
339
|
+
for pkg_name in packages:
|
|
340
|
+
try:
|
|
341
|
+
pkg = importlib.import_module(pkg_name)
|
|
342
|
+
for importer, modname, ispkg in pkgutil.walk_packages(pkg.__path__, pkg_name + '.'):
|
|
343
|
+
try:
|
|
344
|
+
mod = importlib.import_module(modname)
|
|
345
|
+
for name, obj in inspect.getmembers(mod, inspect.isclass):
|
|
346
|
+
# 支持 @entity 装饰器标记
|
|
347
|
+
is_entity = getattr(obj, '__entity__', False)
|
|
348
|
+
has_table = hasattr(obj, '__table__')
|
|
349
|
+
has_tablename = hasattr(obj, '__tablename__') and not name.startswith('_')
|
|
350
|
+
if (is_entity or has_table or has_tablename) and obj.__module__ == modname:
|
|
351
|
+
self.register_entity(obj)
|
|
352
|
+
except Exception as e:
|
|
353
|
+
logger.debug(f"Failed to scan module {modname}: {e}")
|
|
354
|
+
except Exception as e:
|
|
355
|
+
logger.warning(f"Failed to scan package {pkg_name}: {e}")
|
|
356
|
+
|
|
357
|
+
def _parse_entity(self, cls: Type) -> EntityTable:
|
|
358
|
+
"""解析实体类,提取表元数据"""
|
|
359
|
+
# 表名
|
|
360
|
+
table_meta = getattr(cls, '__table__', None)
|
|
361
|
+
table_name = ""
|
|
362
|
+
table_comment = ""
|
|
363
|
+
indexes = []
|
|
364
|
+
if isinstance(table_meta, Table):
|
|
365
|
+
table_name = table_meta.name
|
|
366
|
+
table_comment = table_meta.comment
|
|
367
|
+
indexes = list(table_meta.indexes)
|
|
368
|
+
if not table_name:
|
|
369
|
+
table_name = getattr(cls, '__tablename__', "")
|
|
370
|
+
if not table_name:
|
|
371
|
+
table_name = _camel_to_snake(cls.__name__)
|
|
372
|
+
|
|
373
|
+
columns = []
|
|
374
|
+
|
|
375
|
+
# 处理 dataclass
|
|
376
|
+
if is_dataclass(cls):
|
|
377
|
+
for df in dataclass_fields(cls):
|
|
378
|
+
# @Transient 字段不持久化,跳过(与普通类分支一致)
|
|
379
|
+
if _is_transient_field(cls, df.name):
|
|
380
|
+
continue
|
|
381
|
+
# dataclass 字段默认值若直接是 Transient 实例,也跳过
|
|
382
|
+
if isinstance(df.default, Transient):
|
|
383
|
+
continue
|
|
384
|
+
col_meta = self._get_field_meta(df)
|
|
385
|
+
columns.append(col_meta)
|
|
386
|
+
else:
|
|
387
|
+
# 处理普通类:从 __init__ 方法中提取 self.xxx 字段
|
|
388
|
+
init_fields = self._extract_init_fields(cls)
|
|
389
|
+
# 同时检查类级别的类型注解(__init__ 的参数注解)
|
|
390
|
+
init_hints = {}
|
|
391
|
+
try:
|
|
392
|
+
init_hints = get_type_hints(cls.__init__)
|
|
393
|
+
except Exception:
|
|
394
|
+
pass
|
|
395
|
+
cls_annotations = getattr(cls, '__annotations__', {})
|
|
396
|
+
for attr_name, default_val in init_fields.items():
|
|
397
|
+
# @Transient 字段不持久化,跳过
|
|
398
|
+
if _is_transient_field(cls, attr_name):
|
|
399
|
+
continue
|
|
400
|
+
# 优先取 __init__ 注解,其次取类注解
|
|
401
|
+
py_type = init_hints.get(attr_name) or cls_annotations.get(attr_name)
|
|
402
|
+
# 解包 Optional[X]:Python 3.10 的 get_type_hints 会把带 None 默认值的
|
|
403
|
+
# 参数注解自动包装为 Optional[X],3.11+ 不再包装。此处统一解包为承载类型,
|
|
404
|
+
# 否则 _get_sql_type 在 3.10 上识别失败回退到 TEXT,导致数值列被当字符串存储。
|
|
405
|
+
py_type = unwrap_optional_type(py_type)
|
|
406
|
+
if py_type is None:
|
|
407
|
+
py_type = type(default_val) if default_val is not None and default_val != "" else str
|
|
408
|
+
# 检查Column注解
|
|
409
|
+
col_info = None
|
|
410
|
+
for cls_base in cls.__mro__:
|
|
411
|
+
if attr_name in cls_base.__dict__:
|
|
412
|
+
cval = cls_base.__dict__[attr_name]
|
|
413
|
+
if isinstance(cval, Column):
|
|
414
|
+
col_info = cval
|
|
415
|
+
elif hasattr(cval, '__column__'):
|
|
416
|
+
col_info = getattr(cval, '__column__')
|
|
417
|
+
break
|
|
418
|
+
col_meta = self._build_column_meta(attr_name, py_type, col_info)
|
|
419
|
+
columns.append(col_meta)
|
|
420
|
+
|
|
421
|
+
# 检查是否已有主键标记,没有的话检查id字段并标记为PK
|
|
422
|
+
has_pk = any(c['primary_key'] for c in columns)
|
|
423
|
+
id_col = None
|
|
424
|
+
for c in columns:
|
|
425
|
+
if c['py_name'] == 'id' or c['name'] == 'id':
|
|
426
|
+
id_col = c
|
|
427
|
+
break
|
|
428
|
+
|
|
429
|
+
if not has_pk and id_col is not None:
|
|
430
|
+
# 将现有id字段标记为主键和自增
|
|
431
|
+
id_col['primary_key'] = True
|
|
432
|
+
id_col['auto_increment'] = True
|
|
433
|
+
id_col['nullable'] = False
|
|
434
|
+
if self.dialect == 'sqlite':
|
|
435
|
+
id_col['sql_type'] = 'INTEGER'
|
|
436
|
+
elif self.dialect == 'mysql':
|
|
437
|
+
id_col['sql_type'] = _get_sql_type(int, self.dialect)
|
|
438
|
+
elif self.dialect == 'postgresql':
|
|
439
|
+
id_col['sql_type'] = 'BIGSERIAL' if 'BIGINT' in id_col['sql_type'] else 'SERIAL'
|
|
440
|
+
elif not has_pk:
|
|
441
|
+
# 没有id字段,自动添加id主键
|
|
442
|
+
pk_col = {
|
|
443
|
+
'name': 'id',
|
|
444
|
+
'py_name': 'id',
|
|
445
|
+
'py_type': int,
|
|
446
|
+
'sql_type': _get_sql_type(int, self.dialect),
|
|
447
|
+
'nullable': False,
|
|
448
|
+
'unique': False,
|
|
449
|
+
'primary_key': True,
|
|
450
|
+
'auto_increment': True,
|
|
451
|
+
'default': None,
|
|
452
|
+
'comment': 'Primary key',
|
|
453
|
+
}
|
|
454
|
+
if self.dialect == 'sqlite':
|
|
455
|
+
pk_col['sql_type'] = 'INTEGER'
|
|
456
|
+
columns.insert(0, pk_col)
|
|
457
|
+
|
|
458
|
+
return EntityTable(table_name, columns, indexes, table_comment, cls)
|
|
459
|
+
|
|
460
|
+
def _extract_init_fields(self, cls: Type) -> Dict[str, Any]:
|
|
461
|
+
"""从__init__方法中提取 self.xxx[: type] = default 字段"""
|
|
462
|
+
import textwrap
|
|
463
|
+
fields = {}
|
|
464
|
+
try:
|
|
465
|
+
source = inspect.getsource(cls.__init__)
|
|
466
|
+
source = textwrap.dedent(source)
|
|
467
|
+
import re
|
|
468
|
+
# 支持类型注解:self.xxx: type = value 或 self.xxx = value
|
|
469
|
+
for match in re.finditer(r'self\.(\w+)(?::\s*[\w\[\], .]+)?\s*=\s*([^\n#]+)', source):
|
|
470
|
+
fname = match.group(1)
|
|
471
|
+
val_expr = match.group(2).strip()
|
|
472
|
+
if fname.startswith('_'):
|
|
473
|
+
continue
|
|
474
|
+
val_expr = val_expr.rstrip(',').strip()
|
|
475
|
+
# 推断默认值
|
|
476
|
+
default = None
|
|
477
|
+
if val_expr in ('None',):
|
|
478
|
+
default = None
|
|
479
|
+
elif val_expr in ('""', "''", '""""""', "''''''"):
|
|
480
|
+
default = ""
|
|
481
|
+
elif val_expr in ('[]', 'list()'):
|
|
482
|
+
default = []
|
|
483
|
+
elif val_expr in ('{}', 'dict()'):
|
|
484
|
+
default = {}
|
|
485
|
+
elif val_expr == '0':
|
|
486
|
+
default = 0
|
|
487
|
+
elif val_expr in ('0.0', '0.'):
|
|
488
|
+
default = 0.0
|
|
489
|
+
elif val_expr == 'False':
|
|
490
|
+
default = False
|
|
491
|
+
elif val_expr == 'True':
|
|
492
|
+
default = True
|
|
493
|
+
else:
|
|
494
|
+
# self.param = param 模式 (参数赋值)
|
|
495
|
+
param_match = re.match(r'^(\w+)$', val_expr)
|
|
496
|
+
if param_match and param_match.group(1) == fname:
|
|
497
|
+
default = None
|
|
498
|
+
else:
|
|
499
|
+
try:
|
|
500
|
+
default = eval(val_expr, {"__builtins__": {}}, {})
|
|
501
|
+
except Exception:
|
|
502
|
+
default = None
|
|
503
|
+
fields[fname] = default if default is not None else ""
|
|
504
|
+
except Exception:
|
|
505
|
+
pass
|
|
506
|
+
return fields
|
|
507
|
+
|
|
508
|
+
def _get_field_meta(self, df) -> dict:
|
|
509
|
+
"""从dataclass字段提取列元数据"""
|
|
510
|
+
col_info = getattr(df.default, '__column__', None) if df.default is not inspect.Parameter.empty else None
|
|
511
|
+
if col_info is None and isinstance(df.default, Column):
|
|
512
|
+
col_info = df.default
|
|
513
|
+
return self._build_column_meta(df.name, df.type, col_info)
|
|
514
|
+
|
|
515
|
+
def _build_column_meta(self, attr_name: str, py_type: Any, col_info: Optional[Column]) -> dict:
|
|
516
|
+
"""构建列元数据字典"""
|
|
517
|
+
info = {
|
|
518
|
+
'name': attr_name,
|
|
519
|
+
'py_name': attr_name,
|
|
520
|
+
'py_type': py_type,
|
|
521
|
+
'nullable': True,
|
|
522
|
+
'unique': False,
|
|
523
|
+
'primary_key': False,
|
|
524
|
+
'auto_increment': False,
|
|
525
|
+
'default': None,
|
|
526
|
+
'comment': '',
|
|
527
|
+
'length': 0,
|
|
528
|
+
'precision': 0,
|
|
529
|
+
'scale': 0,
|
|
530
|
+
'column_definition': '',
|
|
531
|
+
'version': False, # @Version 乐观锁标记
|
|
532
|
+
}
|
|
533
|
+
if col_info and isinstance(col_info, Column):
|
|
534
|
+
if col_info.name:
|
|
535
|
+
info['name'] = col_info.name
|
|
536
|
+
else:
|
|
537
|
+
info['name'] = _camel_to_snake(attr_name)
|
|
538
|
+
info['nullable'] = col_info.nullable
|
|
539
|
+
info['unique'] = col_info.unique
|
|
540
|
+
info['primary_key'] = col_info.primary_key
|
|
541
|
+
info['auto_increment'] = col_info.auto_increment
|
|
542
|
+
info['default'] = col_info.default
|
|
543
|
+
info['comment'] = col_info.comment
|
|
544
|
+
info['length'] = col_info.length
|
|
545
|
+
info['precision'] = col_info.precision
|
|
546
|
+
info['scale'] = col_info.scale
|
|
547
|
+
info['column_definition'] = col_info.column_definition
|
|
548
|
+
# @Version 乐观锁字段:标记并强制 INTEGER 类型 + 默认 0
|
|
549
|
+
if isinstance(col_info, Version) or getattr(col_info, 'version', False):
|
|
550
|
+
info['version'] = True
|
|
551
|
+
info['nullable'] = False
|
|
552
|
+
if info['default'] is None:
|
|
553
|
+
info['default'] = 0
|
|
554
|
+
# 版本号统一用整型(与 JPA Version 语义一致)
|
|
555
|
+
if self.dialect == 'sqlite':
|
|
556
|
+
info['sql_type'] = 'INTEGER'
|
|
557
|
+
else:
|
|
558
|
+
info['sql_type'] = 'INTEGER'
|
|
559
|
+
return info
|
|
560
|
+
else:
|
|
561
|
+
info['name'] = _camel_to_snake(attr_name)
|
|
562
|
+
|
|
563
|
+
info['sql_type'] = _get_sql_type(py_type, self.dialect, info)
|
|
564
|
+
return info
|
|
565
|
+
|
|
566
|
+
def _quote(self, identifier: str) -> str:
|
|
567
|
+
"""引用标识符(表名/列名)"""
|
|
568
|
+
if self.dialect == 'mysql':
|
|
569
|
+
return f"`{identifier}`"
|
|
570
|
+
return f'"{identifier}"'
|
|
571
|
+
|
|
572
|
+
def _build_create_table_sql(self, et: EntityTable) -> str:
|
|
573
|
+
"""生成 CREATE TABLE 语句"""
|
|
574
|
+
cols_sql = []
|
|
575
|
+
primary_keys = []
|
|
576
|
+
unique_cols = []
|
|
577
|
+
|
|
578
|
+
for col in et.columns:
|
|
579
|
+
parts = [self._quote(col['name'])]
|
|
580
|
+
sql_type = col['sql_type']
|
|
581
|
+
is_pk = col['primary_key']
|
|
582
|
+
is_auto = col['auto_increment']
|
|
583
|
+
|
|
584
|
+
# SQLite: AUTOINCREMENT must be "INTEGER PRIMARY KEY AUTOINCREMENT" inline
|
|
585
|
+
if self.dialect == 'sqlite' and is_pk and is_auto:
|
|
586
|
+
parts = [self._quote(col['name']), "INTEGER", "PRIMARY KEY", "AUTOINCREMENT"]
|
|
587
|
+
primary_keys.append(col['name'])
|
|
588
|
+
else:
|
|
589
|
+
parts = [self._quote(col['name']), sql_type]
|
|
590
|
+
if is_pk:
|
|
591
|
+
if is_auto:
|
|
592
|
+
if self.dialect == 'mysql':
|
|
593
|
+
parts.append("AUTO_INCREMENT")
|
|
594
|
+
elif self.dialect == 'postgresql':
|
|
595
|
+
if 'BIGINT' in sql_type:
|
|
596
|
+
parts[1] = "BIGSERIAL"
|
|
597
|
+
elif 'INT' in sql_type:
|
|
598
|
+
parts[1] = "SERIAL"
|
|
599
|
+
parts.append("NOT NULL")
|
|
600
|
+
primary_keys.append(col['name'])
|
|
601
|
+
else:
|
|
602
|
+
if not col['nullable']:
|
|
603
|
+
parts.append("NOT NULL")
|
|
604
|
+
if col['unique']:
|
|
605
|
+
unique_cols.append(col['name'])
|
|
606
|
+
if col['default'] is not None:
|
|
607
|
+
if isinstance(col['default'], str):
|
|
608
|
+
parts.append(f"DEFAULT '{col['default']}'")
|
|
609
|
+
elif isinstance(col['default'], bool):
|
|
610
|
+
parts.append(f"DEFAULT {1 if col['default'] else 0}")
|
|
611
|
+
else:
|
|
612
|
+
parts.append(f"DEFAULT {col['default']}")
|
|
613
|
+
cols_sql.append(" ".join(parts))
|
|
614
|
+
|
|
615
|
+
# 对于非SQLite或复合主键,显式声明PRIMARY KEY
|
|
616
|
+
if primary_keys and not (self.dialect == 'sqlite' and len(primary_keys) == 1
|
|
617
|
+
and any(c['auto_increment'] for c in et.columns if c['name'] == primary_keys[0])):
|
|
618
|
+
cols_sql.append(f"PRIMARY KEY ({', '.join(self._quote(c) for c in primary_keys)})")
|
|
619
|
+
for uc in unique_cols:
|
|
620
|
+
cols_sql.append(f"UNIQUE ({self._quote(uc)})")
|
|
621
|
+
|
|
622
|
+
# 索引(MySQL不能在CREATE TABLE中声明INDEX,单独CREATE INDEX)
|
|
623
|
+
table_sql = f"CREATE TABLE {self._quote(et.table_name)} (\n "
|
|
624
|
+
table_sql += ",\n ".join(cols_sql)
|
|
625
|
+
table_sql += "\n)"
|
|
626
|
+
|
|
627
|
+
if self.dialect == 'mysql':
|
|
628
|
+
table_sql += " ENGINE=InnoDB DEFAULT CHARSET=utf8mb4"
|
|
629
|
+
if et.comment:
|
|
630
|
+
table_sql += f" COMMENT='{et.comment}'"
|
|
631
|
+
elif self.dialect == 'postgresql' and et.comment:
|
|
632
|
+
table_sql += f"; COMMENT ON TABLE {self._quote(et.table_name)} IS '{et.comment}'"
|
|
633
|
+
|
|
634
|
+
return table_sql
|
|
635
|
+
|
|
636
|
+
def _get_existing_columns(self, table_name: str) -> Dict[str, dict]:
|
|
637
|
+
"""查询现有表的列信息"""
|
|
638
|
+
conn = None
|
|
639
|
+
columns = {}
|
|
640
|
+
try:
|
|
641
|
+
pooled = self.pool.get_connection()
|
|
642
|
+
conn = pooled.connection
|
|
643
|
+
cursor = conn.cursor()
|
|
644
|
+
if self.dialect == 'mysql':
|
|
645
|
+
cursor.execute(f"SHOW COLUMNS FROM {self._quote(table_name)}")
|
|
646
|
+
for row in cursor.fetchall():
|
|
647
|
+
col_name = row[0] if not isinstance(row, dict) else row['Field']
|
|
648
|
+
col_type = row[1] if not isinstance(row, dict) else row['Type']
|
|
649
|
+
is_null = (row[2] == 'YES') if not isinstance(row, dict) else (row['Null'] == 'YES')
|
|
650
|
+
is_pk = False
|
|
651
|
+
key = row[3] if not isinstance(row, dict) else row.get('Key', '')
|
|
652
|
+
if key == 'PRI':
|
|
653
|
+
is_pk = True
|
|
654
|
+
columns[col_name] = {
|
|
655
|
+
'name': col_name, 'type': col_type, 'nullable': is_null, 'primary_key': is_pk
|
|
656
|
+
}
|
|
657
|
+
elif self.dialect == 'postgresql':
|
|
658
|
+
cursor.execute("""
|
|
659
|
+
SELECT column_name, data_type, is_nullable,
|
|
660
|
+
(SELECT COUNT(*) FROM information_schema.table_constraints tc
|
|
661
|
+
JOIN information_schema.key_column_usage kcu
|
|
662
|
+
ON tc.constraint_name = kcu.constraint_name
|
|
663
|
+
WHERE tc.table_name = %s AND tc.constraint_type = 'PRIMARY KEY'
|
|
664
|
+
AND kcu.column_name = columns.column_name) > 0 as is_pk
|
|
665
|
+
FROM information_schema.columns
|
|
666
|
+
WHERE table_name = %s
|
|
667
|
+
""", (table_name, table_name))
|
|
668
|
+
for row in cursor.fetchall():
|
|
669
|
+
col_name, col_type, is_null, is_pk = row[0], row[1], row[2] == 'YES', row[3]
|
|
670
|
+
columns[col_name] = {'name': col_name, 'type': col_type, 'nullable': is_null, 'primary_key': is_pk}
|
|
671
|
+
elif self.dialect == 'sqlite':
|
|
672
|
+
cursor.execute(f"PRAGMA table_info({self._quote(table_name)})")
|
|
673
|
+
for row in cursor.fetchall():
|
|
674
|
+
col_name = row[1]
|
|
675
|
+
col_type = row[2]
|
|
676
|
+
# SQLite PRAGMA table_info: row[3] = notnull (1=NOT NULL, 0=nullable)
|
|
677
|
+
# row[5] = pk (1=primary key)
|
|
678
|
+
is_pk = bool(row[5])
|
|
679
|
+
notnull = bool(row[3]) or is_pk # 主键隐式为NOT NULL
|
|
680
|
+
is_null = not notnull
|
|
681
|
+
columns[col_name] = {'name': col_name, 'type': col_type, 'nullable': is_null, 'primary_key': is_pk}
|
|
682
|
+
cursor.close()
|
|
683
|
+
self.pool.return_connection(pooled)
|
|
684
|
+
except Exception as e:
|
|
685
|
+
logger.debug(f"Failed to get columns for {table_name}: {e}")
|
|
686
|
+
return columns
|
|
687
|
+
|
|
688
|
+
def _table_exists(self, table_name: str) -> bool:
|
|
689
|
+
"""检查表是否存在"""
|
|
690
|
+
conn = None
|
|
691
|
+
try:
|
|
692
|
+
pooled = self.pool.get_connection()
|
|
693
|
+
conn = pooled.connection
|
|
694
|
+
cursor = conn.cursor()
|
|
695
|
+
if self.dialect == 'mysql':
|
|
696
|
+
cursor.execute("SHOW TABLES LIKE %s", (table_name,))
|
|
697
|
+
elif self.dialect == 'postgresql':
|
|
698
|
+
cursor.execute("SELECT EXISTS(SELECT 1 FROM information_schema.tables WHERE table_name = %s)", (table_name,))
|
|
699
|
+
else:
|
|
700
|
+
cursor.execute("SELECT name FROM sqlite_master WHERE type='table' AND name=?", (table_name,))
|
|
701
|
+
exists = cursor.fetchone() is not None
|
|
702
|
+
cursor.close()
|
|
703
|
+
self.pool.return_connection(pooled)
|
|
704
|
+
return exists
|
|
705
|
+
except Exception:
|
|
706
|
+
return False
|
|
707
|
+
|
|
708
|
+
def _get_existing_indexes(self, table_name: str) -> Dict[str, List[str]]:
|
|
709
|
+
"""查询现有表的索引"""
|
|
710
|
+
indexes = {}
|
|
711
|
+
conn = None
|
|
712
|
+
try:
|
|
713
|
+
pooled = self.pool.get_connection()
|
|
714
|
+
conn = pooled.connection
|
|
715
|
+
cursor = conn.cursor()
|
|
716
|
+
if self.dialect == 'mysql':
|
|
717
|
+
cursor.execute(f"SHOW INDEX FROM {self._quote(table_name)}")
|
|
718
|
+
for row in cursor.fetchall():
|
|
719
|
+
idx_name = row[2] if not isinstance(row, dict) else row['Key_name']
|
|
720
|
+
col_name = row[4] if not isinstance(row, dict) else row['Column_name']
|
|
721
|
+
if idx_name == 'PRIMARY':
|
|
722
|
+
continue
|
|
723
|
+
if idx_name not in indexes:
|
|
724
|
+
indexes[idx_name] = []
|
|
725
|
+
indexes[idx_name].append(col_name)
|
|
726
|
+
elif self.dialect == 'sqlite':
|
|
727
|
+
cursor.execute(f"PRAGMA index_list({self._quote(table_name)})")
|
|
728
|
+
for row in cursor.fetchall():
|
|
729
|
+
idx_name = row[1]
|
|
730
|
+
if idx_name.startswith('sqlite_'):
|
|
731
|
+
continue
|
|
732
|
+
cursor2 = conn.cursor()
|
|
733
|
+
cursor2.execute(f"PRAGMA index_info({self._quote(idx_name)})")
|
|
734
|
+
cols = [r[2] for r in cursor2.fetchall()]
|
|
735
|
+
cursor2.close()
|
|
736
|
+
indexes[idx_name] = cols
|
|
737
|
+
cursor.close()
|
|
738
|
+
self.pool.return_connection(pooled)
|
|
739
|
+
except Exception as e:
|
|
740
|
+
logger.debug(f"Failed to get indexes for {table_name}: {e}")
|
|
741
|
+
return indexes
|
|
742
|
+
|
|
743
|
+
def _execute_sql(self, sql: str):
|
|
744
|
+
"""执行单条SQL"""
|
|
745
|
+
conn = None
|
|
746
|
+
try:
|
|
747
|
+
pooled = self.pool.get_connection()
|
|
748
|
+
conn = pooled.connection
|
|
749
|
+
cursor = conn.cursor()
|
|
750
|
+
logger.debug(f"[DDL] Executing: {sql[:200]}")
|
|
751
|
+
cursor.execute(sql)
|
|
752
|
+
conn.commit()
|
|
753
|
+
cursor.close()
|
|
754
|
+
self.pool.return_connection(pooled)
|
|
755
|
+
self._executed_sql.append(sql)
|
|
756
|
+
except Exception as e:
|
|
757
|
+
if conn:
|
|
758
|
+
try:
|
|
759
|
+
conn.rollback()
|
|
760
|
+
except Exception:
|
|
761
|
+
pass
|
|
762
|
+
logger.error(f"[DDL] SQL execution failed: {e}\nSQL: {sql[:300]}")
|
|
763
|
+
raise
|
|
764
|
+
|
|
765
|
+
def execute(self) -> List[str]:
|
|
766
|
+
"""根据 ddl-auto 模式执行 DDL 操作"""
|
|
767
|
+
if self.mode == DdlAutoMode.NONE:
|
|
768
|
+
logger.info("[DDL] ddl-auto=none, skipping DDL execution")
|
|
769
|
+
return []
|
|
770
|
+
|
|
771
|
+
self._parsed = [self._parse_entity(cls) for cls in self._entities]
|
|
772
|
+
if not self._parsed:
|
|
773
|
+
logger.info("[DDL] No entities registered, nothing to do")
|
|
774
|
+
return []
|
|
775
|
+
|
|
776
|
+
logger.info(f"[DDL] ddl-auto={self.mode.value}, processing {len(self._parsed)} table(s)")
|
|
777
|
+
self._executed_sql = []
|
|
778
|
+
|
|
779
|
+
try:
|
|
780
|
+
if self.mode == DdlAutoMode.CREATE:
|
|
781
|
+
self._execute_create()
|
|
782
|
+
elif self.mode == DdlAutoMode.CREATE_DROP:
|
|
783
|
+
self._execute_create()
|
|
784
|
+
# Note: drop will happen on shutdown if registered
|
|
785
|
+
elif self.mode == DdlAutoMode.UPDATE:
|
|
786
|
+
self._execute_update()
|
|
787
|
+
elif self.mode == DdlAutoMode.VALIDATE:
|
|
788
|
+
self._execute_validate()
|
|
789
|
+
except Exception as e:
|
|
790
|
+
logger.error(f"[DDL] Execution failed: {e}")
|
|
791
|
+
raise
|
|
792
|
+
|
|
793
|
+
logger.info(f"[DDL] Completed. Executed {len(self._executed_sql)} statement(s)")
|
|
794
|
+
return list(self._executed_sql)
|
|
795
|
+
|
|
796
|
+
def _execute_create(self):
|
|
797
|
+
"""create 模式: DROP + CREATE"""
|
|
798
|
+
for et in self._parsed:
|
|
799
|
+
if self._table_exists(et.table_name):
|
|
800
|
+
drop_sql = f"DROP TABLE {self._quote(et.table_name)}"
|
|
801
|
+
if self.dialect == 'mysql':
|
|
802
|
+
drop_sql = f"DROP TABLE IF EXISTS {self._quote(et.table_name)}"
|
|
803
|
+
elif self.dialect == 'postgresql':
|
|
804
|
+
drop_sql = f"DROP TABLE IF EXISTS {self._quote(et.table_name)} CASCADE"
|
|
805
|
+
else:
|
|
806
|
+
drop_sql = f"DROP TABLE IF EXISTS {self._quote(et.table_name)}"
|
|
807
|
+
self._execute_sql(drop_sql)
|
|
808
|
+
logger.info(f"[DDL] Dropped table {et.table_name}")
|
|
809
|
+
create_sql = self._build_create_table_sql(et)
|
|
810
|
+
self._execute_sql(create_sql)
|
|
811
|
+
logger.info(f"[DDL] Created table {et.table_name} ({len(et.columns)} columns)")
|
|
812
|
+
|
|
813
|
+
def _execute_update(self):
|
|
814
|
+
"""update 模式: 创建不存在的表,为已存在的表添加新列、创建缺失索引"""
|
|
815
|
+
for et in self._parsed:
|
|
816
|
+
if not self._table_exists(et.table_name):
|
|
817
|
+
create_sql = self._build_create_table_sql(et)
|
|
818
|
+
self._execute_sql(create_sql)
|
|
819
|
+
logger.info(f"[DDL] Created new table {et.table_name}")
|
|
820
|
+
# 创建表后创建索引
|
|
821
|
+
for idx in et.indexes:
|
|
822
|
+
self._create_index(et.table_name, idx)
|
|
823
|
+
continue
|
|
824
|
+
# 增量更新:添加新列
|
|
825
|
+
existing = self._get_existing_columns(et.table_name)
|
|
826
|
+
for col in et.columns:
|
|
827
|
+
if col['name'] not in existing:
|
|
828
|
+
parts = [f"ALTER TABLE {self._quote(et.table_name)} ADD COLUMN",
|
|
829
|
+
self._quote(col['name']), col['sql_type']]
|
|
830
|
+
if not col['nullable']:
|
|
831
|
+
parts.append("NOT NULL")
|
|
832
|
+
if col['default'] is not None:
|
|
833
|
+
if isinstance(col['default'], str):
|
|
834
|
+
parts.append(f"DEFAULT '{col['default']}'")
|
|
835
|
+
else:
|
|
836
|
+
parts.append(f"DEFAULT {col['default']}")
|
|
837
|
+
alter_sql = " ".join(parts)
|
|
838
|
+
self._execute_sql(alter_sql)
|
|
839
|
+
logger.info(f"[DDL] Added column {col['name']} to {et.table_name}")
|
|
840
|
+
# 创建缺失索引
|
|
841
|
+
existing_indexes = self._get_existing_indexes(et.table_name)
|
|
842
|
+
for idx in et.indexes:
|
|
843
|
+
if idx.name not in existing_indexes:
|
|
844
|
+
self._create_index(et.table_name, idx)
|
|
845
|
+
|
|
846
|
+
def _create_index(self, table_name: str, idx: Index):
|
|
847
|
+
"""创建索引"""
|
|
848
|
+
unique = "UNIQUE" if idx.unique else ""
|
|
849
|
+
cols = ", ".join(self._quote(c) for c in idx.columns)
|
|
850
|
+
sql = f"CREATE {unique} INDEX {self._quote(idx.name)} ON {self._quote(table_name)} ({cols})"
|
|
851
|
+
self._execute_sql(sql)
|
|
852
|
+
logger.info(f"[DDL] Created index {idx.name} on {table_name}")
|
|
853
|
+
|
|
854
|
+
def _execute_validate(self):
|
|
855
|
+
"""validate 模式: 验证表结构匹配"""
|
|
856
|
+
errors = []
|
|
857
|
+
for et in self._parsed:
|
|
858
|
+
if not self._table_exists(et.table_name):
|
|
859
|
+
errors.append(f"Table '{et.table_name}' does not exist")
|
|
860
|
+
continue
|
|
861
|
+
existing = self._get_existing_columns(et.table_name)
|
|
862
|
+
for col in et.columns:
|
|
863
|
+
if col['name'] not in existing:
|
|
864
|
+
errors.append(f"Column '{et.table_name}.{col['name']}' is missing")
|
|
865
|
+
else:
|
|
866
|
+
ex = existing[col['name']]
|
|
867
|
+
if col['primary_key'] != ex['primary_key']:
|
|
868
|
+
errors.append(f"Column '{et.table_name}.{col['name']}' primary key mismatch")
|
|
869
|
+
if not col['nullable'] and ex['nullable']:
|
|
870
|
+
errors.append(f"Column '{et.table_name}.{col['name']}' should be NOT NULL")
|
|
871
|
+
if errors:
|
|
872
|
+
error_msg = "Schema validation failed:\n " + "\n ".join(errors)
|
|
873
|
+
logger.error(f"[DDL] {error_msg}")
|
|
874
|
+
raise Exception(error_msg)
|
|
875
|
+
logger.info("[DDL] Schema validation passed")
|
|
876
|
+
|
|
877
|
+
def drop_all(self):
|
|
878
|
+
"""create-drop 模式关闭时调用:删除所有注册实体对应的表"""
|
|
879
|
+
for et in reversed(self._parsed):
|
|
880
|
+
if self._table_exists(et.table_name):
|
|
881
|
+
drop_sql = f"DROP TABLE IF EXISTS {self._quote(et.table_name)}"
|
|
882
|
+
if self.dialect == 'postgresql':
|
|
883
|
+
drop_sql += " CASCADE"
|
|
884
|
+
self._execute_sql(drop_sql)
|
|
885
|
+
logger.info(f"[DDL] Dropped table {et.table_name}")
|
|
886
|
+
|
|
887
|
+
def get_generated_sql(self) -> List[str]:
|
|
888
|
+
"""获取生成但不一定执行的SQL(用于预览)"""
|
|
889
|
+
# 如果还没解析,先解析实体
|
|
890
|
+
if not self._parsed:
|
|
891
|
+
self._parsed = [self._parse_entity(cls) for cls in self._entities]
|
|
892
|
+
result = []
|
|
893
|
+
for et in self._parsed:
|
|
894
|
+
result.append(self._build_create_table_sql(et))
|
|
895
|
+
return result
|
|
896
|
+
|
|
897
|
+
def get_executed_sql(self) -> List[str]:
|
|
898
|
+
"""获取已执行的SQL"""
|
|
899
|
+
return list(self._executed_sql)
|
|
900
|
+
|
|
901
|
+
|
|
902
|
+
# ==================== 装饰器API(便捷使用) ====================
|
|
903
|
+
|
|
904
|
+
def entity(table_name: str = "", indexes: List[Index] = None, comment: str = ""):
|
|
905
|
+
"""
|
|
906
|
+
@Entity 装饰器,标注一个类为JPA风格的实体类
|
|
907
|
+
|
|
908
|
+
Usage:
|
|
909
|
+
@entity("sys_user")
|
|
910
|
+
class User:
|
|
911
|
+
def __init__(self, id: int = None, username: str = "", email: str = ""):
|
|
912
|
+
self.id = id
|
|
913
|
+
self.username = username
|
|
914
|
+
self.email = email
|
|
915
|
+
"""
|
|
916
|
+
t = Table(name=table_name, indexes=indexes, comment=comment)
|
|
917
|
+
def decorator(cls):
|
|
918
|
+
setattr(cls, '__entity__', True)
|
|
919
|
+
setattr(cls, '__table__', t)
|
|
920
|
+
if not table_name:
|
|
921
|
+
t.name = _camel_to_snake(cls.__name__)
|
|
922
|
+
return cls
|
|
923
|
+
return decorator
|
|
924
|
+
|
|
925
|
+
|
|
926
|
+
def table(name: str = "", indexes: List[Index] = None, comment: str = ""):
|
|
927
|
+
"""@Table 装饰器,标注实体类对应的表(与@entity功能相同,别名)"""
|
|
928
|
+
return entity(name, indexes, comment)
|
|
929
|
+
|
|
930
|
+
|
|
931
|
+
# ==================== 全局集成 ====================
|
|
932
|
+
|
|
933
|
+
# 全局DDL自动管理器实例
|
|
934
|
+
_global_ddl_manager: Optional[DdlAutoManager] = None
|
|
935
|
+
|
|
936
|
+
|
|
937
|
+
def init_ddl_auto(connection_pool, config: dict = None) -> Optional[DdlAutoManager]:
|
|
938
|
+
"""
|
|
939
|
+
从配置初始化DDL自动建表管理器并执行
|
|
940
|
+
|
|
941
|
+
Args:
|
|
942
|
+
connection_pool: 数据库连接池实例
|
|
943
|
+
config: 配置字典,应包含 ddl-auto 配置段
|
|
944
|
+
|
|
945
|
+
Returns:
|
|
946
|
+
DdlAutoManager实例或None
|
|
947
|
+
"""
|
|
948
|
+
global _global_ddl_manager
|
|
949
|
+
config = config or {}
|
|
950
|
+
ddl_config = config.get('ddl-auto', config.get('jpa', {}).get('hibernate', {}))
|
|
951
|
+
mode = str(ddl_config.get('mode', ddl_config.get('ddl-auto', 'none'))).lower()
|
|
952
|
+
|
|
953
|
+
if mode == 'none' or not mode:
|
|
954
|
+
logger.info("[DDL] ddl-auto=none, skipping auto DDL")
|
|
955
|
+
return None
|
|
956
|
+
|
|
957
|
+
# 判断方言
|
|
958
|
+
driver = str(config.get('driver', 'sqlite')).lower()
|
|
959
|
+
if 'mysql' in driver:
|
|
960
|
+
dialect = 'mysql'
|
|
961
|
+
elif 'postgresql' in driver or 'pg' in driver:
|
|
962
|
+
dialect = 'postgresql'
|
|
963
|
+
else:
|
|
964
|
+
dialect = 'sqlite'
|
|
965
|
+
|
|
966
|
+
# 创建管理器
|
|
967
|
+
manager = DdlAutoManager(connection_pool, dialect=dialect, mode=mode)
|
|
968
|
+
|
|
969
|
+
# 扫描实体包
|
|
970
|
+
entity_packages = ddl_config.get('entity_packages', ddl_config.get('packages-to-scan', []))
|
|
971
|
+
if isinstance(entity_packages, str):
|
|
972
|
+
entity_packages = [p.strip() for p in entity_packages.split(',') if p.strip()]
|
|
973
|
+
|
|
974
|
+
if entity_packages:
|
|
975
|
+
manager._scan_packages(entity_packages)
|
|
976
|
+
logger.info(f"[DDL] Scanned entity packages: {entity_packages}, found {len(manager._entities)} entities")
|
|
977
|
+
|
|
978
|
+
# 执行DDL
|
|
979
|
+
if manager._entities:
|
|
980
|
+
# 临时关闭DDL阻断
|
|
981
|
+
try:
|
|
982
|
+
if hasattr(connection_pool, 'config'):
|
|
983
|
+
connection_pool.config['security_block_ddl'] = False
|
|
984
|
+
except Exception:
|
|
985
|
+
pass
|
|
986
|
+
manager.execute()
|
|
987
|
+
|
|
988
|
+
_global_ddl_manager = manager
|
|
989
|
+
return manager
|
|
990
|
+
|
|
991
|
+
|
|
992
|
+
def get_ddl_manager() -> Optional[DdlAutoManager]:
|
|
993
|
+
"""获取全局DDL管理器"""
|
|
994
|
+
return _global_ddl_manager
|
|
995
|
+
|
|
996
|
+
|
|
997
|
+
# ==================== JPA @Version 乐观锁执行器 ====================
|
|
998
|
+
|
|
999
|
+
class OptimisticLockError(Exception):
|
|
1000
|
+
"""乐观锁冲突异常:UPDATE 影响行数为 0,说明版本号已变更或记录不存在。"""
|
|
1001
|
+
|
|
1002
|
+
|
|
1003
|
+
def _find_version_column(entity_class: Type) -> Optional[dict]:
|
|
1004
|
+
"""解析实体类,返回 ``@Version`` 列元数据 dict(无则 None)。
|
|
1005
|
+
|
|
1006
|
+
复用 ``DdlAutoManager._parse_entity`` 解析逻辑,避免重复实现字段扫描。
|
|
1007
|
+
"""
|
|
1008
|
+
# 用一个临时 manager 仅做解析(不执行 DDL)
|
|
1009
|
+
tmp = DdlAutoManager.__new__(DdlAutoManager)
|
|
1010
|
+
tmp.dialect = 'sqlite'
|
|
1011
|
+
tmp.mode = DdlAutoMode.NONE
|
|
1012
|
+
try:
|
|
1013
|
+
et = tmp._parse_entity(entity_class)
|
|
1014
|
+
except Exception:
|
|
1015
|
+
return None
|
|
1016
|
+
for col in et.columns:
|
|
1017
|
+
if col.get('version'):
|
|
1018
|
+
return col
|
|
1019
|
+
return None
|
|
1020
|
+
|
|
1021
|
+
|
|
1022
|
+
class OptimisticLockExecutor:
|
|
1023
|
+
"""``@Version`` 乐观锁更新执行器(对齐 JPA/Hibernate 乐观锁语义)。
|
|
1024
|
+
|
|
1025
|
+
本框架内嵌 PyMyBatis **不自动**在 UPDATE 时注入 version 检查子句(与 JPA/Hibernate
|
|
1026
|
+
的差异,已在 ``Version`` 注解文档标注)。本执行器提供等价的显式乐观锁更新:
|
|
1027
|
+
生成 ``UPDATE table SET ... , version = version + 1 WHERE <pk> = ? AND version = ?``,
|
|
1028
|
+
根据影响行数判断是否冲突。
|
|
1029
|
+
|
|
1030
|
+
用法::
|
|
1031
|
+
|
|
1032
|
+
from spring.orm import OptimisticLockExecutor, Version, Id, entity
|
|
1033
|
+
|
|
1034
|
+
@entity("sys_user")
|
|
1035
|
+
class User:
|
|
1036
|
+
id = Id()
|
|
1037
|
+
version = Version()
|
|
1038
|
+
def __init__(self, id=None, name=None, version=0):
|
|
1039
|
+
self.id = id; self.name = name; self.version = version
|
|
1040
|
+
|
|
1041
|
+
executor = OptimisticLockExecutor(connection_pool, dialect="mysql")
|
|
1042
|
+
# 冲突时抛 OptimisticLockError
|
|
1043
|
+
executor.update(entity_class=User, entity=user_obj,
|
|
1044
|
+
set_fields={"name": "new_name"})
|
|
1045
|
+
# 或探测式(不抛错,返回是否成功)
|
|
1046
|
+
ok = executor.try_update(entity_class=User, entity=user_obj,
|
|
1047
|
+
set_fields={"name": "new_name"})
|
|
1048
|
+
|
|
1049
|
+
Args:
|
|
1050
|
+
connection_pool: 数据库连接池(需支持 ``connection()`` 上下文管理器)。
|
|
1051
|
+
dialect: SQL 方言(mysql/postgresql/sqlite),影响标识符引用。
|
|
1052
|
+
"""
|
|
1053
|
+
|
|
1054
|
+
def __init__(self, connection_pool: Any, dialect: str = "mysql"):
|
|
1055
|
+
self.pool = connection_pool
|
|
1056
|
+
self.dialect = dialect.lower()
|
|
1057
|
+
|
|
1058
|
+
def _quote(self, identifier: str) -> str:
|
|
1059
|
+
if self.dialect == 'mysql':
|
|
1060
|
+
return f"`{identifier}`"
|
|
1061
|
+
return f'"{identifier}"'
|
|
1062
|
+
|
|
1063
|
+
def _find_pk(self, entity_class: Type) -> Optional[dict]:
|
|
1064
|
+
"""返回主键列元数据。"""
|
|
1065
|
+
for col in self._parse_columns(entity_class):
|
|
1066
|
+
if col.get('primary_key'):
|
|
1067
|
+
return col
|
|
1068
|
+
return None
|
|
1069
|
+
|
|
1070
|
+
def _parse_columns(self, entity_class: Type) -> List[dict]:
|
|
1071
|
+
"""解析实体类的全部列元数据(复用 ``DdlAutoManager._parse_entity``)。
|
|
1072
|
+
|
|
1073
|
+
列元数据含 ``py_name``(Python 属性名)与 ``name``(SQL 列名),供
|
|
1074
|
+
``set_fields`` 的属性名 -> 列名翻译使用,避免 ``Column(name=...)`` 自定义列名时
|
|
1075
|
+
生成错误 SQL(与 JPA 实体元数据语义一致)。
|
|
1076
|
+
"""
|
|
1077
|
+
tmp = DdlAutoManager.__new__(DdlAutoManager)
|
|
1078
|
+
tmp.dialect = self.dialect
|
|
1079
|
+
tmp.mode = DdlAutoMode.NONE
|
|
1080
|
+
try:
|
|
1081
|
+
et = tmp._parse_entity(entity_class)
|
|
1082
|
+
except Exception:
|
|
1083
|
+
return []
|
|
1084
|
+
return list(et.columns)
|
|
1085
|
+
|
|
1086
|
+
def _column_py_to_sql_map(self, entity_class: Type) -> Dict[str, str]:
|
|
1087
|
+
"""构造 ``{py_name: sql_column_name}`` 映射,用于 ``set_fields`` 翻译。"""
|
|
1088
|
+
mapping: Dict[str, str] = {}
|
|
1089
|
+
for col in self._parse_columns(entity_class):
|
|
1090
|
+
py = col.get('py_name') or col.get('name')
|
|
1091
|
+
mapping[py] = col.get('name') or py
|
|
1092
|
+
return mapping
|
|
1093
|
+
|
|
1094
|
+
def update(
|
|
1095
|
+
self,
|
|
1096
|
+
entity_class: Type,
|
|
1097
|
+
entity: Any,
|
|
1098
|
+
set_fields: Dict[str, Any],
|
|
1099
|
+
) -> int:
|
|
1100
|
+
"""乐观锁更新:冲突时抛 ``OptimisticLockError``。
|
|
1101
|
+
|
|
1102
|
+
Args:
|
|
1103
|
+
entity_class: 实体类(带 ``@Version`` 与主键)。
|
|
1104
|
+
entity: 实体实例(提供主键值与当前 version)。
|
|
1105
|
+
set_fields: 要更新的字段 -> 值映射(不含 version,version 自动 +1)。
|
|
1106
|
+
Returns:
|
|
1107
|
+
新版本号(旧 version + 1)。
|
|
1108
|
+
"""
|
|
1109
|
+
version_col = _find_version_column(entity_class)
|
|
1110
|
+
if version_col is None:
|
|
1111
|
+
raise ValueError(
|
|
1112
|
+
f"{entity_class.__name__} 未声明 @Version 字段,无法乐观锁更新"
|
|
1113
|
+
)
|
|
1114
|
+
# 注意:必须在 try_update 之前捕获 old_version——try_update 成功后会回写
|
|
1115
|
+
# entity.version = old_version + 1,事后再读会得到已自增的值。
|
|
1116
|
+
old_version = getattr(entity, version_col['py_name'], 0) or 0
|
|
1117
|
+
ok = self.try_update(entity_class, entity, set_fields)
|
|
1118
|
+
if not ok:
|
|
1119
|
+
raise OptimisticLockError(
|
|
1120
|
+
f"乐观锁更新失败:{entity_class.__name__} 版本已变更或记录不存在"
|
|
1121
|
+
)
|
|
1122
|
+
return old_version + 1
|
|
1123
|
+
|
|
1124
|
+
def try_update(
|
|
1125
|
+
self,
|
|
1126
|
+
entity_class: Type,
|
|
1127
|
+
entity: Any,
|
|
1128
|
+
set_fields: Dict[str, Any],
|
|
1129
|
+
) -> bool:
|
|
1130
|
+
"""乐观锁更新(探测式):成功返回 True,冲突/记录不存在返回 False。"""
|
|
1131
|
+
version_col = _find_version_column(entity_class)
|
|
1132
|
+
if version_col is None:
|
|
1133
|
+
raise ValueError(f"{entity_class.__name__} 未声明 @Version 字段,无法乐观锁更新")
|
|
1134
|
+
pk_col = self._find_pk(entity_class)
|
|
1135
|
+
if pk_col is None:
|
|
1136
|
+
raise ValueError(f"{entity_class.__name__} 未找到主键字段")
|
|
1137
|
+
|
|
1138
|
+
table_name = self._resolve_table_name(entity_class)
|
|
1139
|
+
old_version = getattr(entity, version_col['py_name'], 0) or 0
|
|
1140
|
+
pk_value = getattr(entity, pk_col['py_name'], None)
|
|
1141
|
+
if pk_value is None:
|
|
1142
|
+
raise ValueError("实体主键值为空,无法乐观锁更新")
|
|
1143
|
+
|
|
1144
|
+
# 构造 UPDATE ... SET ..., version = version + 1 WHERE pk = ? AND version = ?
|
|
1145
|
+
# set_fields 的键为 Python 属性名,需按实体元数据翻译为真实 SQL 列名
|
|
1146
|
+
col_map = self._column_py_to_sql_map(entity_class)
|
|
1147
|
+
set_parts = [f"{self._quote(self._col_sql_name(f, col_map))} = ?" for f in set_fields]
|
|
1148
|
+
set_parts.append(f"{self._quote(version_col['name'])} = {self._quote(version_col['name'])} + 1")
|
|
1149
|
+
sql = (
|
|
1150
|
+
f"UPDATE {self._quote(table_name)} SET {', '.join(set_parts)} "
|
|
1151
|
+
f"WHERE {self._quote(pk_col['name'])} = ? AND {self._quote(version_col['name'])} = ?"
|
|
1152
|
+
)
|
|
1153
|
+
params = list(set_fields.values()) + [pk_value, old_version]
|
|
1154
|
+
|
|
1155
|
+
affected = self._execute_dml(sql, params)
|
|
1156
|
+
# 同步回写实体上的新版本号,便于后续操作
|
|
1157
|
+
if affected > 0:
|
|
1158
|
+
try:
|
|
1159
|
+
setattr(entity, version_col['py_name'], old_version + 1)
|
|
1160
|
+
except Exception:
|
|
1161
|
+
pass
|
|
1162
|
+
return affected > 0
|
|
1163
|
+
|
|
1164
|
+
def _col_sql_name(self, field_name: str, col_map: Dict[str, str]) -> str:
|
|
1165
|
+
"""Python 属性名 -> SQL 列名。
|
|
1166
|
+
|
|
1167
|
+
优先查实体元数据映射(``Column(name=...)`` 自定义列名);未命中时回退 snake_case,
|
|
1168
|
+
兼容未声明 ``Column`` 的简单字段。
|
|
1169
|
+
"""
|
|
1170
|
+
if field_name in col_map:
|
|
1171
|
+
return col_map[field_name]
|
|
1172
|
+
return _camel_to_snake(field_name)
|
|
1173
|
+
|
|
1174
|
+
def _resolve_table_name(self, entity_class: Type) -> str:
|
|
1175
|
+
table_meta = getattr(entity_class, '__table__', None)
|
|
1176
|
+
if isinstance(table_meta, Table) and table_meta.name:
|
|
1177
|
+
return table_meta.name
|
|
1178
|
+
tn = getattr(entity_class, '__tablename__', "")
|
|
1179
|
+
return tn or _camel_to_snake(entity_class.__name__)
|
|
1180
|
+
|
|
1181
|
+
def _execute_dml(self, sql: str, params: list) -> int:
|
|
1182
|
+
"""执行 DML,返回影响行数。兼容 DBUtils 连接池与原生 connection。"""
|
|
1183
|
+
conn = None
|
|
1184
|
+
cursor = None
|
|
1185
|
+
try:
|
|
1186
|
+
if hasattr(self.pool, 'connection'):
|
|
1187
|
+
conn = self.pool.connection()
|
|
1188
|
+
else:
|
|
1189
|
+
conn = self.pool
|
|
1190
|
+
cursor = conn.cursor()
|
|
1191
|
+
affected = cursor.execute(sql, params)
|
|
1192
|
+
conn.commit()
|
|
1193
|
+
# 不同驱动返回语义不一:
|
|
1194
|
+
# - DBUtils/MySQLdb: execute 返回 rowcount (int)
|
|
1195
|
+
# - sqlite3: execute 返回 cursor 自身(非 int)
|
|
1196
|
+
# - psycopg2: execute 返回 None
|
|
1197
|
+
if not isinstance(affected, int):
|
|
1198
|
+
affected = getattr(cursor, 'rowcount', 0)
|
|
1199
|
+
return int(affected or 0)
|
|
1200
|
+
except Exception:
|
|
1201
|
+
try:
|
|
1202
|
+
if conn is not None:
|
|
1203
|
+
conn.rollback()
|
|
1204
|
+
except Exception:
|
|
1205
|
+
pass
|
|
1206
|
+
raise
|
|
1207
|
+
finally:
|
|
1208
|
+
if cursor is not None:
|
|
1209
|
+
try:
|
|
1210
|
+
cursor.close()
|
|
1211
|
+
except Exception:
|
|
1212
|
+
pass
|
|
1213
|
+
if conn is not None and hasattr(self.pool, 'connection'):
|
|
1214
|
+
try:
|
|
1215
|
+
conn.close()
|
|
1216
|
+
except Exception:
|
|
1217
|
+
pass
|