sdpy-kit 0.5.1__tar.gz → 0.5.2__tar.gz
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.
- {sdpy_kit-0.5.1 → sdpy_kit-0.5.2}/PKG-INFO +1 -1
- {sdpy_kit-0.5.1 → sdpy_kit-0.5.2}/kit/__init__.py +4 -1
- {sdpy_kit-0.5.1 → sdpy_kit-0.5.2}/kit/db/__init__.py +3 -0
- sdpy_kit-0.5.2/kit/db/audit.py +382 -0
- {sdpy_kit-0.5.1 → sdpy_kit-0.5.2}/kit/db/base_db_kit.py +88 -4
- {sdpy_kit-0.5.1 → sdpy_kit-0.5.2}/kit/db/crud.py +28 -5
- {sdpy_kit-0.5.1 → sdpy_kit-0.5.2}/.gitignore +0 -0
- {sdpy_kit-0.5.1 → sdpy_kit-0.5.2}/README.md +0 -0
- {sdpy_kit-0.5.1 → sdpy_kit-0.5.2}/kit/ai/__init__.py +0 -0
- {sdpy_kit-0.5.1 → sdpy_kit-0.5.2}/kit/ai/base_service.py +0 -0
- {sdpy_kit-0.5.1 → sdpy_kit-0.5.2}/kit/ai/chat_service.py +0 -0
- {sdpy_kit-0.5.1 → sdpy_kit-0.5.2}/kit/ai/embedding_service.py +0 -0
- {sdpy_kit-0.5.1 → sdpy_kit-0.5.2}/kit/ai/exceptions.py +0 -0
- {sdpy_kit-0.5.1 → sdpy_kit-0.5.2}/kit/ai/failover.py +0 -0
- {sdpy_kit-0.5.1 → sdpy_kit-0.5.2}/kit/ai/image_service.py +0 -0
- {sdpy_kit-0.5.1 → sdpy_kit-0.5.2}/kit/ai/model_builder.py +0 -0
- {sdpy_kit-0.5.1 → sdpy_kit-0.5.2}/kit/ai/model_client.py +0 -0
- {sdpy_kit-0.5.1 → sdpy_kit-0.5.2}/kit/ai/pool.py +0 -0
- {sdpy_kit-0.5.1 → sdpy_kit-0.5.2}/kit/ai/profiles.py +0 -0
- {sdpy_kit-0.5.1 → sdpy_kit-0.5.2}/kit/ai/rerank_service.py +0 -0
- {sdpy_kit-0.5.1 → sdpy_kit-0.5.2}/kit/ai/runner.py +0 -0
- {sdpy_kit-0.5.1 → sdpy_kit-0.5.2}/kit/ai/types.py +0 -0
- {sdpy_kit-0.5.1 → sdpy_kit-0.5.2}/kit/common_kit.py +0 -0
- {sdpy_kit-0.5.1 → sdpy_kit-0.5.2}/kit/config/__init__.py +0 -0
- {sdpy_kit-0.5.1 → sdpy_kit-0.5.2}/kit/config/kit_config.py +0 -0
- {sdpy_kit-0.5.1 → sdpy_kit-0.5.2}/kit/config/log_config.py +0 -0
- {sdpy_kit-0.5.1 → sdpy_kit-0.5.2}/kit/crawl/__init__.py +0 -0
- {sdpy_kit-0.5.1 → sdpy_kit-0.5.2}/kit/crawl/article_models.py +0 -0
- {sdpy_kit-0.5.1 → sdpy_kit-0.5.2}/kit/crawl/web_article_kit.py +0 -0
- {sdpy_kit-0.5.1 → sdpy_kit-0.5.2}/kit/crawl/wx_article_kit.py +0 -0
- {sdpy_kit-0.5.1 → sdpy_kit-0.5.2}/kit/db/engine.py +0 -0
- {sdpy_kit-0.5.1 → sdpy_kit-0.5.2}/kit/db/exceptions.py +0 -0
- {sdpy_kit-0.5.1 → sdpy_kit-0.5.2}/kit/db/models/__init__.py +0 -0
- {sdpy_kit-0.5.1 → sdpy_kit-0.5.2}/kit/db/models/base.py +0 -0
- {sdpy_kit-0.5.1 → sdpy_kit-0.5.2}/kit/db/mysql_kit.py +0 -0
- {sdpy_kit-0.5.1 → sdpy_kit-0.5.2}/kit/db/pg_kit.py +0 -0
- {sdpy_kit-0.5.1 → sdpy_kit-0.5.2}/kit/db/query.py +0 -0
- {sdpy_kit-0.5.1 → sdpy_kit-0.5.2}/kit/db/session.py +0 -0
- {sdpy_kit-0.5.1 → sdpy_kit-0.5.2}/kit/db/sqlite_kit.py +0 -0
- {sdpy_kit-0.5.1 → sdpy_kit-0.5.2}/kit/db/transaction.py +0 -0
- {sdpy_kit-0.5.1 → sdpy_kit-0.5.2}/kit/docs/__init__.py +0 -0
- {sdpy_kit-0.5.1 → sdpy_kit-0.5.2}/kit/docs/anydoc_kit.py +0 -0
- {sdpy_kit-0.5.1 → sdpy_kit-0.5.2}/kit/docs/docx_kit.py +0 -0
- {sdpy_kit-0.5.1 → sdpy_kit-0.5.2}/kit/docs/marker_kit.py +0 -0
- {sdpy_kit-0.5.1 → sdpy_kit-0.5.2}/kit/docs/md2doc_kit.py +0 -0
- {sdpy_kit-0.5.1 → sdpy_kit-0.5.2}/kit/docs/mineru_kit.py +0 -0
- {sdpy_kit-0.5.1 → sdpy_kit-0.5.2}/kit/docs/rc_docparser_kit.py +0 -0
- {sdpy_kit-0.5.1 → sdpy_kit-0.5.2}/kit/file_kit.py +0 -0
- {sdpy_kit-0.5.1 → sdpy_kit-0.5.2}/kit/laya_kit.py +0 -0
- {sdpy_kit-0.5.1 → sdpy_kit-0.5.2}/kit/license_kit.py +0 -0
- {sdpy_kit-0.5.1 → sdpy_kit-0.5.2}/kit/redis_kit.py +0 -0
- {sdpy_kit-0.5.1 → sdpy_kit-0.5.2}/pyproject.toml +0 -0
|
@@ -23,12 +23,13 @@ Author: Clark Chang
|
|
|
23
23
|
数据库访问通过 kit.db 子包提供(基于 SQLModel)。
|
|
24
24
|
"""
|
|
25
25
|
|
|
26
|
-
__version__ = "0.5.
|
|
26
|
+
__version__ = "0.5.2"
|
|
27
27
|
|
|
28
28
|
from kit.db import (
|
|
29
29
|
MYSQL,
|
|
30
30
|
POSTGRESQL,
|
|
31
31
|
SQLITE,
|
|
32
|
+
AuditConfig,
|
|
32
33
|
CRUDBase,
|
|
33
34
|
DEFAULT_DISTANCE_TYPE,
|
|
34
35
|
DBError,
|
|
@@ -84,6 +85,8 @@ __all__ = [
|
|
|
84
85
|
"VectorNotSupportedError",
|
|
85
86
|
# CRUD
|
|
86
87
|
"CRUDBase",
|
|
88
|
+
# 审计字段与删除标记的自动填充(默认关闭,enable 后生效)
|
|
89
|
+
"AuditConfig",
|
|
87
90
|
# 事务
|
|
88
91
|
"transactional",
|
|
89
92
|
# 原生 SQL 语句构造(公共)
|
|
@@ -29,6 +29,7 @@ Author: Clark Chang
|
|
|
29
29
|
- SQLModel 混入类(models/base.py)
|
|
30
30
|
"""
|
|
31
31
|
|
|
32
|
+
from kit.db.audit import AuditConfig
|
|
32
33
|
from kit.db.base_db_kit import (
|
|
33
34
|
DEFAULT_DISTANCE_TYPE,
|
|
34
35
|
generate_snowflake_ids,
|
|
@@ -86,6 +87,8 @@ __all__ = [
|
|
|
86
87
|
"VectorNotSupportedError",
|
|
87
88
|
# CRUD
|
|
88
89
|
"CRUDBase",
|
|
90
|
+
# 审计字段与删除标记的自动填充(默认关闭,enable 后生效)
|
|
91
|
+
"AuditConfig",
|
|
89
92
|
# 事务
|
|
90
93
|
"transactional",
|
|
91
94
|
# 原生 SQL 语句构造(公共)
|
|
@@ -0,0 +1,382 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Copyright (c) 2021-2026 Clark Chang. All Rights Reserved.
|
|
3
|
+
|
|
4
|
+
Licensed under the Apache License, Version 2.0 (the "License");
|
|
5
|
+
you may not use this file except in compliance with the License.
|
|
6
|
+
You may obtain a copy of the License at
|
|
7
|
+
|
|
8
|
+
http://www.apache.org/licenses/LICENSE-2.0
|
|
9
|
+
|
|
10
|
+
Unless required by applicable law or agreed to in writing, software
|
|
11
|
+
distributed under the License is distributed on an "AS IS" BASIS,
|
|
12
|
+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
13
|
+
See the License for the specific language governing permissions and
|
|
14
|
+
limitations under the License.
|
|
15
|
+
|
|
16
|
+
Project: sdpy
|
|
17
|
+
Author: Clark Chang
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
# date: 2026-10-20
|
|
21
|
+
"""审计字段与删除标记的自动填充(仿 MyBatis-Plus MetaObjectHandler)
|
|
22
|
+
|
|
23
|
+
填充字段(固定,不做别名映射):
|
|
24
|
+
|
|
25
|
+
- ``created_at`` / ``created_by`` / ``updated_at`` / ``updated_by``(审计四件套)
|
|
26
|
+
- ``deleted``(逻辑删除标记:``0`` 正常 / ``1`` 删除)
|
|
27
|
+
|
|
28
|
+
判定规则(唯一):**表中存在该列** 且 **本次写入数据中该字段为空** → 填充。
|
|
29
|
+
"空" 指 ``None`` / 空串 / 纯空白串(``0`` 与 ``False`` 不算空)。
|
|
30
|
+
调用方显式给了非空值一律保留,**不存在覆盖语义**。
|
|
31
|
+
|
|
32
|
+
填充矩阵:
|
|
33
|
+
|
|
34
|
+
======== ============ ====================== ==========
|
|
35
|
+
动作 created_* updated_* deleted
|
|
36
|
+
======== ============ ====================== ==========
|
|
37
|
+
INSERT 空才填 空才填(与 created_* 同值) 空才填 0
|
|
38
|
+
UPDATE 不参与 空才填 不参与
|
|
39
|
+
======== ============ ====================== ==========
|
|
40
|
+
|
|
41
|
+
"当前值" 指**本次写入的数据**,不是数据库已存值:
|
|
42
|
+
|
|
43
|
+
- dict 路径(``raw_insert`` / ``raw_update`` / ``CRUDBase.update_multi`` / ``upsert``):
|
|
44
|
+
看 ``data`` 中该键的值;
|
|
45
|
+
- ORM 路径(``before_flush``):看本次 flush 是否显式给该属性赋值(属性变更历史)。
|
|
46
|
+
|
|
47
|
+
因此业务没传 ``updated_at`` 时,每次更新都会刷新它。
|
|
48
|
+
|
|
49
|
+
三条落点:
|
|
50
|
+
|
|
51
|
+
1. ORM flush(``session.add`` / 属性修改 + flush)→ ``before_flush`` 事件;
|
|
52
|
+
2. Core DML(``CRUDBase.update_multi`` / ``upsert``)→ 方法内填 dict;
|
|
53
|
+
3. 原生构建器(``raw_insert`` / ``raw_update``)→ 构建 SQL 前填 dict。
|
|
54
|
+
|
|
55
|
+
``execute()`` 手写 SQL 与批量 executemany **不填充**(不做 SQL 文本改写)。
|
|
56
|
+
|
|
57
|
+
默认不启用:未调用 :meth:`AuditConfig.enable` 时,kit 所有写入口行为与本模块
|
|
58
|
+
引入前完全一致(含 ``raw_insert`` 的 SQL 文本)。
|
|
59
|
+
|
|
60
|
+
用法::
|
|
61
|
+
|
|
62
|
+
from kit.db import AuditConfig
|
|
63
|
+
|
|
64
|
+
AuditConfig.enable(user_resolver=get_current_userid) # 应用启动时调用一次
|
|
65
|
+
"""
|
|
66
|
+
|
|
67
|
+
import threading
|
|
68
|
+
from collections.abc import Callable, Mapping, MutableMapping
|
|
69
|
+
from datetime import UTC, datetime
|
|
70
|
+
|
|
71
|
+
from sqlalchemy import String, event, inspect as sa_inspect
|
|
72
|
+
from sqlalchemy.exc import NoSuchTableError
|
|
73
|
+
from sqlalchemy.orm import Session
|
|
74
|
+
from sqlalchemy.types import TypeEngine
|
|
75
|
+
|
|
76
|
+
from kit.config.log_config import get_default_logger
|
|
77
|
+
from kit.db.engine import get_engine
|
|
78
|
+
|
|
79
|
+
logger = get_default_logger(__name__)
|
|
80
|
+
|
|
81
|
+
# 审计字段(与 kit/db/models/base.py::AuditMixin 命名一致)
|
|
82
|
+
CREATED_AT = "created_at"
|
|
83
|
+
CREATED_BY = "created_by"
|
|
84
|
+
UPDATED_AT = "updated_at"
|
|
85
|
+
UPDATED_BY = "updated_by"
|
|
86
|
+
AUDIT_FIELDS: tuple[str, ...] = (CREATED_AT, CREATED_BY, UPDATED_AT, UPDATED_BY)
|
|
87
|
+
|
|
88
|
+
# 逻辑删除标记:0 正常 / 1 删除
|
|
89
|
+
DELETED = "deleted"
|
|
90
|
+
|
|
91
|
+
# 填充器实际关注的列(审计四件套 + 删除标记)
|
|
92
|
+
FILL_FIELDS: tuple[str, ...] = (*AUDIT_FIELDS, DELETED)
|
|
93
|
+
|
|
94
|
+
# 列探测缓存:(db_type, 表名) -> {列名: SQLAlchemy 类型对象}
|
|
95
|
+
# 仅缓存**探测成功**的结果;失败不缓存,表稍后创建可自愈
|
|
96
|
+
_columns_cache: dict[tuple[str, str], dict[str, TypeEngine]] = {}
|
|
97
|
+
_columns_lock = threading.Lock()
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _default_clock() -> datetime:
|
|
101
|
+
"""默认时钟:UTC aware(与 models/base.py::_utcnow 同口径)"""
|
|
102
|
+
return datetime.now(UTC)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _is_empty(value: object) -> bool:
|
|
106
|
+
"""是否为「空」:None / 空串 / 纯空白串(0 与 False 不算空)"""
|
|
107
|
+
if value is None:
|
|
108
|
+
return True
|
|
109
|
+
return isinstance(value, str) and not value.strip()
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _deleted_default(column_type: TypeEngine) -> object:
|
|
113
|
+
"""删除标记的正常值:字符串列(VARCHAR/CHAR/TEXT)填 "0",整数列填 0
|
|
114
|
+
|
|
115
|
+
字符串列写整数在 PostgreSQL(``character varying``)与 MySQL 严格模式下
|
|
116
|
+
会直接报类型错误,故按列类型区分。
|
|
117
|
+
"""
|
|
118
|
+
if isinstance(column_type, String):
|
|
119
|
+
return "0"
|
|
120
|
+
return 0
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
class AuditConfig:
|
|
124
|
+
"""审计字段自动填充的进程级配置(默认关闭)
|
|
125
|
+
|
|
126
|
+
进程级类属性 + 线程锁;``enable`` / ``disable`` 属启动期一次性调用,
|
|
127
|
+
不做运行期热切换承诺。模块导入零副作用。
|
|
128
|
+
"""
|
|
129
|
+
|
|
130
|
+
_lock = threading.RLock()
|
|
131
|
+
_enabled = False
|
|
132
|
+
_user_resolver: Callable[[], object] | None = None
|
|
133
|
+
_clock: Callable[[], datetime] = _default_clock
|
|
134
|
+
_listening = False
|
|
135
|
+
|
|
136
|
+
@classmethod
|
|
137
|
+
def enable(
|
|
138
|
+
cls,
|
|
139
|
+
*,
|
|
140
|
+
user_resolver: Callable[[], object] | None = None,
|
|
141
|
+
clock: Callable[[], datetime] | None = None,
|
|
142
|
+
) -> None:
|
|
143
|
+
"""启用自动填充(幂等)
|
|
144
|
+
|
|
145
|
+
Args:
|
|
146
|
+
user_resolver: 返回当前操作人的钩子(str 或 int,与列类型一致)。
|
|
147
|
+
返回 None / 空串时跳过 ``*_by`` 填充;抛异常时记 warning 并跳过,
|
|
148
|
+
不影响写入。
|
|
149
|
+
clock: 取时间钩子,默认 ``datetime.now(UTC)``(测试可注入冻结时钟)。
|
|
150
|
+
|
|
151
|
+
Note:
|
|
152
|
+
事件监听挂在父类 :class:`sqlalchemy.orm.Session` 上,因此
|
|
153
|
+
``session_scope`` 新建的 Session 与应用自建的原生 Session 都会触发。
|
|
154
|
+
"""
|
|
155
|
+
with cls._lock:
|
|
156
|
+
if user_resolver is not None:
|
|
157
|
+
cls._user_resolver = user_resolver
|
|
158
|
+
if clock is not None:
|
|
159
|
+
cls._clock = clock
|
|
160
|
+
if not cls._listening:
|
|
161
|
+
event.listen(Session, "before_flush", _on_before_flush)
|
|
162
|
+
cls._listening = True
|
|
163
|
+
cls._enabled = True
|
|
164
|
+
logger.info("✅ 审计字段与删除标记自动填充已启用")
|
|
165
|
+
|
|
166
|
+
@classmethod
|
|
167
|
+
def disable(cls) -> None:
|
|
168
|
+
"""关闭自动填充(保留 user_resolver / clock 配置,再次 enable 可沿用)"""
|
|
169
|
+
with cls._lock:
|
|
170
|
+
cls._enabled = False
|
|
171
|
+
if cls._listening:
|
|
172
|
+
event.remove(Session, "before_flush", _on_before_flush)
|
|
173
|
+
cls._listening = False
|
|
174
|
+
logger.info("🔌 审计字段与删除标记自动填充已关闭")
|
|
175
|
+
|
|
176
|
+
@classmethod
|
|
177
|
+
def is_enabled(cls) -> bool:
|
|
178
|
+
"""当前是否启用"""
|
|
179
|
+
return cls._enabled
|
|
180
|
+
|
|
181
|
+
@classmethod
|
|
182
|
+
def now(cls) -> datetime:
|
|
183
|
+
"""当前时间(UTC aware;clock 异常时记 warning 并回退 UTC now)"""
|
|
184
|
+
try:
|
|
185
|
+
return cls._clock()
|
|
186
|
+
except Exception as e:
|
|
187
|
+
logger.warning(f"⚠️ 审计时钟调用失败,回退 UTC now: {e}")
|
|
188
|
+
return datetime.now(UTC)
|
|
189
|
+
|
|
190
|
+
@classmethod
|
|
191
|
+
def current_user(cls) -> object | None:
|
|
192
|
+
"""当前操作人(resolver 未注册或异常时返回 None)"""
|
|
193
|
+
resolver = cls._user_resolver
|
|
194
|
+
if resolver is None:
|
|
195
|
+
return None
|
|
196
|
+
try:
|
|
197
|
+
return resolver()
|
|
198
|
+
except Exception as e:
|
|
199
|
+
logger.warning(f"⚠️ user_resolver 调用失败,跳过 *_by 填充: {e}")
|
|
200
|
+
return None
|
|
201
|
+
|
|
202
|
+
@classmethod
|
|
203
|
+
def clear_columns_cache(cls, *tables: str) -> None:
|
|
204
|
+
"""清空列探测缓存(表结构变更后调用;不传参清空全部)
|
|
205
|
+
|
|
206
|
+
Args:
|
|
207
|
+
tables: 表名(可多个);不传则清空全部缓存
|
|
208
|
+
"""
|
|
209
|
+
with _columns_lock:
|
|
210
|
+
if not tables:
|
|
211
|
+
_columns_cache.clear()
|
|
212
|
+
return
|
|
213
|
+
targets = set(tables)
|
|
214
|
+
for key in [k for k in _columns_cache if k[1] in targets]:
|
|
215
|
+
del _columns_cache[key]
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
# ==================== 列探测(原生路径) ====================
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def table_columns(db_type: str, table_name: str) -> dict[str, TypeEngine]:
|
|
222
|
+
"""表「列名 → SQLAlchemy 类型对象」映射(仅缓存探测成功的结果)
|
|
223
|
+
|
|
224
|
+
Args:
|
|
225
|
+
db_type: 数据库类型(postgresql / mysql / sqlite)
|
|
226
|
+
table_name: 表名(非限定名,按当前 search_path / database 解析)
|
|
227
|
+
|
|
228
|
+
Returns:
|
|
229
|
+
列名到类型对象的映射;表不存在或探测失败时返回**空字典**(= 不填充),
|
|
230
|
+
且不写入缓存(下次调用重新探测,表稍后创建可自愈)
|
|
231
|
+
|
|
232
|
+
Note:
|
|
233
|
+
保留类型对象而非类型名:``deleted`` 的取值需要 ``isinstance`` 判定
|
|
234
|
+
(``String`` 家族 → ``"0"``,其余 → ``0``)。
|
|
235
|
+
"""
|
|
236
|
+
key = (db_type, table_name)
|
|
237
|
+
with _columns_lock:
|
|
238
|
+
cached = _columns_cache.get(key)
|
|
239
|
+
if cached is not None:
|
|
240
|
+
return cached
|
|
241
|
+
|
|
242
|
+
try:
|
|
243
|
+
inspector = sa_inspect(get_engine(db_type))
|
|
244
|
+
columns = {col["name"]: col["type"] for col in inspector.get_columns(table_name)}
|
|
245
|
+
except NoSuchTableError:
|
|
246
|
+
logger.debug(f"表 {table_name} 不存在,跳过审计字段填充")
|
|
247
|
+
return {}
|
|
248
|
+
except Exception as e:
|
|
249
|
+
logger.warning(f"⚠️ 列探测失败({db_type}:{table_name}),跳过填充: {e}")
|
|
250
|
+
return {}
|
|
251
|
+
|
|
252
|
+
with _columns_lock:
|
|
253
|
+
_columns_cache[key] = columns
|
|
254
|
+
return columns
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
# ==================== dict 路径填充(Core DML / 原生构建器) ====================
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def _put(
|
|
261
|
+
data: MutableMapping[str, object],
|
|
262
|
+
columns: Mapping[str, TypeEngine],
|
|
263
|
+
name: str,
|
|
264
|
+
value: object,
|
|
265
|
+
) -> None:
|
|
266
|
+
"""按唯一规则写一个字段:有列 + 填充值非空 + 当前值为空"""
|
|
267
|
+
if name not in columns or _is_empty(value):
|
|
268
|
+
return
|
|
269
|
+
if not _is_empty(data.get(name)):
|
|
270
|
+
return
|
|
271
|
+
data[name] = value
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
def fill_insert(data: MutableMapping[str, object], columns: Mapping[str, TypeEngine]) -> None:
|
|
275
|
+
"""INSERT 半区:created_* 与 updated_* 同值填补,deleted 补正常值
|
|
276
|
+
|
|
277
|
+
Args:
|
|
278
|
+
data: 待写入的列名 → 值(**原地修改**,调用方应传入副本)
|
|
279
|
+
columns: 表列名 → 类型对象(为空表示表无审计列,不填)
|
|
280
|
+
"""
|
|
281
|
+
if not AuditConfig.is_enabled():
|
|
282
|
+
return
|
|
283
|
+
now = AuditConfig.now()
|
|
284
|
+
user = AuditConfig.current_user()
|
|
285
|
+
_put(data, columns, CREATED_AT, now)
|
|
286
|
+
_put(data, columns, UPDATED_AT, now)
|
|
287
|
+
_put(data, columns, CREATED_BY, user)
|
|
288
|
+
_put(data, columns, UPDATED_BY, user)
|
|
289
|
+
if DELETED in columns:
|
|
290
|
+
_put(data, columns, DELETED, _deleted_default(columns[DELETED]))
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
def fill_update(data: MutableMapping[str, object], columns: Mapping[str, TypeEngine]) -> None:
|
|
294
|
+
"""UPDATE 半区:只动 updated_*(不碰 created_*,也不碰 deleted)
|
|
295
|
+
|
|
296
|
+
Args:
|
|
297
|
+
data: 待更新的列名 → 值(**原地修改**,调用方应传入副本)
|
|
298
|
+
columns: 表列名 → 类型对象
|
|
299
|
+
"""
|
|
300
|
+
if not AuditConfig.is_enabled():
|
|
301
|
+
return
|
|
302
|
+
now = AuditConfig.now()
|
|
303
|
+
user = AuditConfig.current_user()
|
|
304
|
+
_put(data, columns, UPDATED_AT, now)
|
|
305
|
+
_put(data, columns, UPDATED_BY, user)
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
def fill_insert_for(
|
|
309
|
+
db_type: str, table_name: str, data: MutableMapping[str, object]
|
|
310
|
+
) -> None:
|
|
311
|
+
"""原生路径的 INSERT 入口:**先判启用再探测**(未启用时零额外查询)"""
|
|
312
|
+
if not AuditConfig.is_enabled():
|
|
313
|
+
return
|
|
314
|
+
fill_insert(data, table_columns(db_type, table_name))
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
def fill_update_for(
|
|
318
|
+
db_type: str, table_name: str, data: MutableMapping[str, object]
|
|
319
|
+
) -> None:
|
|
320
|
+
"""原生路径的 UPDATE 入口:**先判启用再探测**(未启用时零额外查询)"""
|
|
321
|
+
if not AuditConfig.is_enabled():
|
|
322
|
+
return
|
|
323
|
+
fill_update(data, table_columns(db_type, table_name))
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
# ==================== ORM 路径填充(before_flush) ====================
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
def _object_columns(obj: object) -> dict[str, TypeEngine]:
|
|
330
|
+
"""对象映射到的「表列名 → 类型对象」(无 ``__table__`` 时返回空字典)"""
|
|
331
|
+
table = getattr(obj, "__table__", None)
|
|
332
|
+
if table is None:
|
|
333
|
+
return {}
|
|
334
|
+
return {column.name: column.type for column in table.columns}
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
def _fill_obj(
|
|
338
|
+
obj: object, *, now: datetime, user: object | None, insert: bool
|
|
339
|
+
) -> None:
|
|
340
|
+
"""按矩阵填充单个 ORM 对象(列不存在 / 已有非空值 → 跳过)"""
|
|
341
|
+
columns = _object_columns(obj)
|
|
342
|
+
pairs: list[tuple[str, object]] = (
|
|
343
|
+
[(CREATED_AT, now), (UPDATED_AT, now), (CREATED_BY, user), (UPDATED_BY, user)]
|
|
344
|
+
if insert
|
|
345
|
+
else [(UPDATED_AT, now), (UPDATED_BY, user)]
|
|
346
|
+
)
|
|
347
|
+
if insert and DELETED in columns:
|
|
348
|
+
pairs.append((DELETED, _deleted_default(columns[DELETED])))
|
|
349
|
+
|
|
350
|
+
state = sa_inspect(obj)
|
|
351
|
+
for name, value in pairs:
|
|
352
|
+
if name not in columns or _is_empty(value) or not hasattr(obj, name):
|
|
353
|
+
continue
|
|
354
|
+
if name not in state.attrs:
|
|
355
|
+
continue
|
|
356
|
+
current = getattr(obj, name, None)
|
|
357
|
+
if insert:
|
|
358
|
+
# INSERT:属性当前值非空 → 调用方已指定
|
|
359
|
+
if not _is_empty(current):
|
|
360
|
+
continue
|
|
361
|
+
elif state.attrs[name].history.has_changes() and not _is_empty(current):
|
|
362
|
+
# UPDATE:本次显式赋了非空值 → 保留;未赋值 / 赋空 → 填充
|
|
363
|
+
continue
|
|
364
|
+
setattr(obj, name, value)
|
|
365
|
+
|
|
366
|
+
|
|
367
|
+
def _on_before_flush(
|
|
368
|
+
session: Session, flush_context: object, instances: object
|
|
369
|
+
) -> None:
|
|
370
|
+
"""``before_flush`` 钩子:INSERT 走 new 集合,UPDATE 走 dirty 集合
|
|
371
|
+
|
|
372
|
+
时间与用户在一次 flush 内只取一次,同批对象取值一致。
|
|
373
|
+
"""
|
|
374
|
+
if not AuditConfig.is_enabled():
|
|
375
|
+
return
|
|
376
|
+
now = AuditConfig.now()
|
|
377
|
+
user = AuditConfig.current_user()
|
|
378
|
+
for obj in session.new:
|
|
379
|
+
_fill_obj(obj, now=now, user=user, insert=True)
|
|
380
|
+
for obj in session.dirty:
|
|
381
|
+
if session.is_modified(obj, include_collections=False):
|
|
382
|
+
_fill_obj(obj, now=now, user=user, insert=False)
|
|
@@ -56,6 +56,7 @@ from sqlmodel import Session, SQLModel
|
|
|
56
56
|
|
|
57
57
|
from kit.common_kit import CommonKit
|
|
58
58
|
from kit.config.log_config import get_default_logger
|
|
59
|
+
from kit.db.audit import fill_insert_for, fill_update_for
|
|
59
60
|
from kit.db.crud import CRUDBase
|
|
60
61
|
from kit.db.engine import (
|
|
61
62
|
close_engine,
|
|
@@ -144,6 +145,18 @@ _RESERVED_BIND_NAMES = frozenset({"id", "vec"})
|
|
|
144
145
|
_BIND_NAME_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$")
|
|
145
146
|
|
|
146
147
|
|
|
148
|
+
def _check_identifier(name: str) -> None:
|
|
149
|
+
"""校验表名 / 列名为合法标识符(拼接进 SQL 前的白名单)
|
|
150
|
+
|
|
151
|
+
Raises:
|
|
152
|
+
ValueError: 含字母、数字、下划线以外的字符,或以数字开头
|
|
153
|
+
"""
|
|
154
|
+
if not _BIND_NAME_RE.match(name):
|
|
155
|
+
raise ValueError(
|
|
156
|
+
f"{name!r} 不是合法标识符(表名/列名仅允许字母、数字、下划线)"
|
|
157
|
+
)
|
|
158
|
+
|
|
159
|
+
|
|
147
160
|
def _check_extra_key(key: str, id_col: str, column: str) -> None:
|
|
148
161
|
"""校验 ``extra_data`` 的键可作为绑定参数名(拼装前抛出可操作的 ``ValueError``)
|
|
149
162
|
|
|
@@ -806,16 +819,87 @@ class BaseDbKit(ABC):
|
|
|
806
819
|
"""向指定表插入一行数据(键为列名)
|
|
807
820
|
|
|
808
821
|
Args:
|
|
809
|
-
table_name:
|
|
822
|
+
table_name: 表名(仅接受合法标识符)
|
|
810
823
|
data: 列名 → 值 字典
|
|
811
824
|
|
|
812
825
|
Returns:
|
|
813
826
|
受影响的行数
|
|
827
|
+
|
|
828
|
+
Note:
|
|
829
|
+
调用 ``AuditConfig.enable()`` 后,若表存在 ``created_at`` / ``created_by``
|
|
830
|
+
/ ``updated_at`` / ``updated_by`` / ``deleted`` 列,且 ``data`` 中该键缺失
|
|
831
|
+
或为空,则自动补齐;显式非空值一律保留。未启用时行为与引入前完全一致。
|
|
832
|
+
|
|
833
|
+
Raises:
|
|
834
|
+
ValueError: 表名不是合法标识符
|
|
814
835
|
"""
|
|
815
|
-
|
|
816
|
-
|
|
836
|
+
_check_identifier(table_name)
|
|
837
|
+
payload = dict(data) # 副本:不污染调用方 dict
|
|
838
|
+
fill_insert_for(cls.DB_TYPE, table_name, payload)
|
|
839
|
+
columns = ", ".join(payload.keys())
|
|
840
|
+
placeholders = ", ".join(f":{name}" for name in payload)
|
|
817
841
|
sql = f"INSERT INTO {table_name} ({columns}) VALUES ({placeholders})"
|
|
818
|
-
return cls.execute(sql,
|
|
842
|
+
return cls.execute(sql, payload)
|
|
843
|
+
|
|
844
|
+
@classmethod
|
|
845
|
+
def raw_update(
|
|
846
|
+
cls,
|
|
847
|
+
table_name: str,
|
|
848
|
+
data: dict[str, object],
|
|
849
|
+
where: str,
|
|
850
|
+
params: dict[str, object] | None = None,
|
|
851
|
+
) -> int:
|
|
852
|
+
"""按 where 条件更新数据(自动补 updated_*,需启用自动填充)
|
|
853
|
+
|
|
854
|
+
Args:
|
|
855
|
+
table_name: 表名(仅接受合法标识符)
|
|
856
|
+
data: 列名 → 新值(业务字段;``updated_*`` 由填充器补)
|
|
857
|
+
where: WHERE 子句(**不含** ``WHERE`` 关键字,如 ``"id = :id"``)
|
|
858
|
+
params: where 子句的绑定参数(键名不得与 ``data`` 的键重复)
|
|
859
|
+
|
|
860
|
+
Returns:
|
|
861
|
+
受影响的行数
|
|
862
|
+
|
|
863
|
+
Raises:
|
|
864
|
+
ValueError: ``data`` 为空 / ``where`` 为空 / 标识符非法 /
|
|
865
|
+
``data`` 与 ``params`` 的绑定名冲突
|
|
866
|
+
|
|
867
|
+
Note:
|
|
868
|
+
- 无 WHERE 的全表更新属危险操作,请显式用 ``execute()`` 书写。
|
|
869
|
+
- ``where`` 中出现的 ``:name`` 必须由 ``params`` 提供,否则由
|
|
870
|
+
SQLAlchemy 抛 ``A value is required for bind parameter``。
|
|
871
|
+
- 未启用自动填充时,``data`` 原样写入(不加 ``updated_*``)。
|
|
872
|
+
|
|
873
|
+
用法::
|
|
874
|
+
|
|
875
|
+
PgKit.raw_update(
|
|
876
|
+
"writing_project",
|
|
877
|
+
{"title": "新标题"},
|
|
878
|
+
"id = :id AND created_by = :owner",
|
|
879
|
+
{"id": 123, "owner": "admin"},
|
|
880
|
+
)
|
|
881
|
+
"""
|
|
882
|
+
if not data:
|
|
883
|
+
raise ValueError("raw_update 的 data 不能为空")
|
|
884
|
+
if not where or not where.strip():
|
|
885
|
+
raise ValueError("raw_update 必须提供非空 where 子句")
|
|
886
|
+
_check_identifier(table_name)
|
|
887
|
+
for name in data:
|
|
888
|
+
_check_identifier(name)
|
|
889
|
+
|
|
890
|
+
payload = dict(data) # 副本:不污染调用方 dict
|
|
891
|
+
fill_update_for(cls.DB_TYPE, table_name, payload)
|
|
892
|
+
assignments = ", ".join(f"{name} = :{name}" for name in payload)
|
|
893
|
+
|
|
894
|
+
bound = dict(params or {})
|
|
895
|
+
conflict = set(payload) & set(bound)
|
|
896
|
+
if conflict:
|
|
897
|
+
raise ValueError(
|
|
898
|
+
f"data 与 params 的绑定名冲突: {sorted(conflict)};请改用其它参数名"
|
|
899
|
+
)
|
|
900
|
+
bound.update(payload)
|
|
901
|
+
sql = f"UPDATE {table_name} SET {assignments} WHERE {where}"
|
|
902
|
+
return cls.execute(sql, bound)
|
|
819
903
|
|
|
820
904
|
@classmethod
|
|
821
905
|
def raw_get_by(
|
|
@@ -36,10 +36,12 @@ from sqlalchemy import Column, Table, delete, func, update
|
|
|
36
36
|
from sqlalchemy.dialects import mysql as mysql_dialect
|
|
37
37
|
from sqlalchemy.dialects import postgresql as pg_dialect
|
|
38
38
|
from sqlalchemy.dialects import sqlite as sqlite_dialect
|
|
39
|
+
from sqlalchemy.types import TypeEngine
|
|
39
40
|
from sqlmodel import Session, SQLModel, select
|
|
40
41
|
from sqlmodel.sql._expression_select_cls import SelectOfScalar
|
|
41
42
|
|
|
42
43
|
from kit.config.log_config import get_default_logger
|
|
44
|
+
from kit.db.audit import CREATED_AT, CREATED_BY, fill_insert, fill_update
|
|
43
45
|
from kit.db.exceptions import DBError, NotFoundError
|
|
44
46
|
|
|
45
47
|
logger = get_default_logger(__name__)
|
|
@@ -79,6 +81,13 @@ class CRUDBase[
|
|
|
79
81
|
"""模型对应的 Table(避免 SQLModel 泛型无 __table__ 的类型缺陷)"""
|
|
80
82
|
return cast(Table, getattr(self.model, "__table__"))
|
|
81
83
|
|
|
84
|
+
def _column_names(self) -> dict[str, TypeEngine]:
|
|
85
|
+
"""模型映射表的「列名 → SQLAlchemy 类型对象」(读元数据,零 DB 查询)
|
|
86
|
+
|
|
87
|
+
供审计字段自动填充判断「表中是否存在该列」,以及 ``deleted`` 的取值类型。
|
|
88
|
+
"""
|
|
89
|
+
return {column.name: column.type for column in self._table().columns}
|
|
90
|
+
|
|
82
91
|
# ==================== 查询方法 ====================
|
|
83
92
|
|
|
84
93
|
def get(self, session: Session, id: Any) -> ModelType | None:
|
|
@@ -219,12 +228,17 @@ class CRUDBase[
|
|
|
219
228
|
ids: list[Any],
|
|
220
229
|
obj_in: UpdateSchemaType | dict[str, Any],
|
|
221
230
|
) -> int:
|
|
222
|
-
"""批量更新记录(单条 UPDATE SQL,不 commit)
|
|
223
|
-
|
|
231
|
+
"""批量更新记录(单条 UPDATE SQL,不 commit)
|
|
232
|
+
|
|
233
|
+
启用审计填充时自动补 ``updated_at`` / ``updated_by``
|
|
234
|
+
(不碰 ``created_*`` 与 ``deleted``)。
|
|
235
|
+
"""
|
|
236
|
+
update_data = dict(
|
|
224
237
|
obj_in
|
|
225
238
|
if isinstance(obj_in, dict)
|
|
226
239
|
else obj_in.model_dump(exclude_unset=True)
|
|
227
240
|
)
|
|
241
|
+
fill_update(update_data, self._column_names())
|
|
228
242
|
id_col = self._id_col()
|
|
229
243
|
statement = update(self.model).where(id_col.in_(ids)).values(**update_data)
|
|
230
244
|
result = session.execute(statement)
|
|
@@ -294,7 +308,7 @@ class CRUDBase[
|
|
|
294
308
|
obj_in: 创建/更新数据
|
|
295
309
|
conflict_fields: 用于判断冲突的字段列表(需为唯一约束)
|
|
296
310
|
"""
|
|
297
|
-
data = (
|
|
311
|
+
data = dict(
|
|
298
312
|
obj_in
|
|
299
313
|
if isinstance(obj_in, dict)
|
|
300
314
|
else obj_in.model_dump(exclude_unset=True)
|
|
@@ -302,17 +316,26 @@ class CRUDBase[
|
|
|
302
316
|
if not conflict_fields:
|
|
303
317
|
raise DBError("upsert 必须提供 conflict_fields")
|
|
304
318
|
|
|
305
|
-
|
|
319
|
+
columns = self._column_names()
|
|
320
|
+
|
|
321
|
+
# 更新集:排除冲突字段、主键,以及**只在 INSERT 填充**的 created_*
|
|
322
|
+
# (若把 created_* 带进 set_,每次冲突更新都会把创建时间刷成 now)
|
|
306
323
|
pk_cols = {c.name for c in self._table().primary_key.columns}
|
|
307
324
|
update_cols = {
|
|
308
325
|
k: v
|
|
309
326
|
for k, v in data.items()
|
|
310
|
-
if k not in conflict_fields
|
|
327
|
+
if k not in conflict_fields
|
|
328
|
+
and k not in pk_cols
|
|
329
|
+
and k not in (CREATED_AT, CREATED_BY)
|
|
311
330
|
}
|
|
312
331
|
if not update_cols:
|
|
313
332
|
# 无更新字段时退化为空更新(保持合法 SQL)
|
|
314
333
|
update_cols = {conflict_fields[0]: data[conflict_fields[0]]}
|
|
315
334
|
|
|
335
|
+
# INSERT 半区补 created_*/updated_*/deleted;UPDATE 半区补 updated_*
|
|
336
|
+
fill_insert(data, columns)
|
|
337
|
+
fill_update(update_cols, columns)
|
|
338
|
+
|
|
316
339
|
# 各数据库方言的 INSERT ... ON CONFLICT / ON DUPLICATE KEY(原子单语句)
|
|
317
340
|
dialect = session.get_bind().dialect.name
|
|
318
341
|
if dialect == "postgresql":
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|