sdpy-kit 0.5.1__tar.gz → 0.5.3__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.3}/PKG-INFO +1 -1
- {sdpy_kit-0.5.1 → sdpy_kit-0.5.3}/kit/__init__.py +4 -1
- {sdpy_kit-0.5.1 → sdpy_kit-0.5.3}/kit/db/__init__.py +3 -0
- sdpy_kit-0.5.3/kit/db/audit.py +555 -0
- {sdpy_kit-0.5.1 → sdpy_kit-0.5.3}/kit/db/base_db_kit.py +102 -6
- {sdpy_kit-0.5.1 → sdpy_kit-0.5.3}/kit/db/crud.py +89 -19
- {sdpy_kit-0.5.1 → sdpy_kit-0.5.3}/.gitignore +0 -0
- {sdpy_kit-0.5.1 → sdpy_kit-0.5.3}/README.md +0 -0
- {sdpy_kit-0.5.1 → sdpy_kit-0.5.3}/kit/ai/__init__.py +0 -0
- {sdpy_kit-0.5.1 → sdpy_kit-0.5.3}/kit/ai/base_service.py +0 -0
- {sdpy_kit-0.5.1 → sdpy_kit-0.5.3}/kit/ai/chat_service.py +0 -0
- {sdpy_kit-0.5.1 → sdpy_kit-0.5.3}/kit/ai/embedding_service.py +0 -0
- {sdpy_kit-0.5.1 → sdpy_kit-0.5.3}/kit/ai/exceptions.py +0 -0
- {sdpy_kit-0.5.1 → sdpy_kit-0.5.3}/kit/ai/failover.py +0 -0
- {sdpy_kit-0.5.1 → sdpy_kit-0.5.3}/kit/ai/image_service.py +0 -0
- {sdpy_kit-0.5.1 → sdpy_kit-0.5.3}/kit/ai/model_builder.py +0 -0
- {sdpy_kit-0.5.1 → sdpy_kit-0.5.3}/kit/ai/model_client.py +0 -0
- {sdpy_kit-0.5.1 → sdpy_kit-0.5.3}/kit/ai/pool.py +0 -0
- {sdpy_kit-0.5.1 → sdpy_kit-0.5.3}/kit/ai/profiles.py +0 -0
- {sdpy_kit-0.5.1 → sdpy_kit-0.5.3}/kit/ai/rerank_service.py +0 -0
- {sdpy_kit-0.5.1 → sdpy_kit-0.5.3}/kit/ai/runner.py +0 -0
- {sdpy_kit-0.5.1 → sdpy_kit-0.5.3}/kit/ai/types.py +0 -0
- {sdpy_kit-0.5.1 → sdpy_kit-0.5.3}/kit/common_kit.py +0 -0
- {sdpy_kit-0.5.1 → sdpy_kit-0.5.3}/kit/config/__init__.py +0 -0
- {sdpy_kit-0.5.1 → sdpy_kit-0.5.3}/kit/config/kit_config.py +0 -0
- {sdpy_kit-0.5.1 → sdpy_kit-0.5.3}/kit/config/log_config.py +0 -0
- {sdpy_kit-0.5.1 → sdpy_kit-0.5.3}/kit/crawl/__init__.py +0 -0
- {sdpy_kit-0.5.1 → sdpy_kit-0.5.3}/kit/crawl/article_models.py +0 -0
- {sdpy_kit-0.5.1 → sdpy_kit-0.5.3}/kit/crawl/web_article_kit.py +0 -0
- {sdpy_kit-0.5.1 → sdpy_kit-0.5.3}/kit/crawl/wx_article_kit.py +0 -0
- {sdpy_kit-0.5.1 → sdpy_kit-0.5.3}/kit/db/engine.py +0 -0
- {sdpy_kit-0.5.1 → sdpy_kit-0.5.3}/kit/db/exceptions.py +0 -0
- {sdpy_kit-0.5.1 → sdpy_kit-0.5.3}/kit/db/models/__init__.py +0 -0
- {sdpy_kit-0.5.1 → sdpy_kit-0.5.3}/kit/db/models/base.py +0 -0
- {sdpy_kit-0.5.1 → sdpy_kit-0.5.3}/kit/db/mysql_kit.py +0 -0
- {sdpy_kit-0.5.1 → sdpy_kit-0.5.3}/kit/db/pg_kit.py +0 -0
- {sdpy_kit-0.5.1 → sdpy_kit-0.5.3}/kit/db/query.py +0 -0
- {sdpy_kit-0.5.1 → sdpy_kit-0.5.3}/kit/db/session.py +0 -0
- {sdpy_kit-0.5.1 → sdpy_kit-0.5.3}/kit/db/sqlite_kit.py +0 -0
- {sdpy_kit-0.5.1 → sdpy_kit-0.5.3}/kit/db/transaction.py +0 -0
- {sdpy_kit-0.5.1 → sdpy_kit-0.5.3}/kit/docs/__init__.py +0 -0
- {sdpy_kit-0.5.1 → sdpy_kit-0.5.3}/kit/docs/anydoc_kit.py +0 -0
- {sdpy_kit-0.5.1 → sdpy_kit-0.5.3}/kit/docs/docx_kit.py +0 -0
- {sdpy_kit-0.5.1 → sdpy_kit-0.5.3}/kit/docs/marker_kit.py +0 -0
- {sdpy_kit-0.5.1 → sdpy_kit-0.5.3}/kit/docs/md2doc_kit.py +0 -0
- {sdpy_kit-0.5.1 → sdpy_kit-0.5.3}/kit/docs/mineru_kit.py +0 -0
- {sdpy_kit-0.5.1 → sdpy_kit-0.5.3}/kit/docs/rc_docparser_kit.py +0 -0
- {sdpy_kit-0.5.1 → sdpy_kit-0.5.3}/kit/file_kit.py +0 -0
- {sdpy_kit-0.5.1 → sdpy_kit-0.5.3}/kit/laya_kit.py +0 -0
- {sdpy_kit-0.5.1 → sdpy_kit-0.5.3}/kit/license_kit.py +0 -0
- {sdpy_kit-0.5.1 → sdpy_kit-0.5.3}/kit/redis_kit.py +0 -0
- {sdpy_kit-0.5.1 → sdpy_kit-0.5.3}/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.3"
|
|
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,555 @@
|
|
|
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, Generator, Mapping, MutableMapping
|
|
69
|
+
from contextlib import contextmanager
|
|
70
|
+
from contextvars import ContextVar
|
|
71
|
+
from datetime import UTC, datetime
|
|
72
|
+
from typing import Any, ClassVar
|
|
73
|
+
|
|
74
|
+
from sqlalchemy import String, event, inspect as sa_inspect
|
|
75
|
+
from sqlalchemy.exc import NoSuchTableError
|
|
76
|
+
from sqlalchemy.orm import Session
|
|
77
|
+
from sqlalchemy.types import TypeEngine
|
|
78
|
+
|
|
79
|
+
from kit.config.log_config import get_default_logger
|
|
80
|
+
from kit.db.engine import get_engine
|
|
81
|
+
|
|
82
|
+
logger = get_default_logger(__name__)
|
|
83
|
+
|
|
84
|
+
# 字段「槽位 → 默认列名」:槽位是内部语义键(也是默认列名),
|
|
85
|
+
# 实际列名可用 AuditConfig.set_fields() 覆盖(如把 created_at 换成 create_time)
|
|
86
|
+
CREATED_AT, CREATED_BY, UPDATED_AT, UPDATED_BY, DELETED = (
|
|
87
|
+
"created_at",
|
|
88
|
+
"created_by",
|
|
89
|
+
"updated_at",
|
|
90
|
+
"updated_by",
|
|
91
|
+
"deleted",
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
# 审计四件套槽位(created_at / created_by / updated_at / updated_by)
|
|
95
|
+
AUDIT_FIELDS: tuple[str, ...] = (CREATED_AT, CREATED_BY, UPDATED_AT, UPDATED_BY)
|
|
96
|
+
|
|
97
|
+
# 填充器实际关注的槽位(审计四件套 + 删除标记)
|
|
98
|
+
FILL_FIELDS: tuple[str, ...] = (*AUDIT_FIELDS, DELETED)
|
|
99
|
+
|
|
100
|
+
# 槽位 → 默认列名(默认同名;set_fields 只覆盖发生变化的槽位)
|
|
101
|
+
DEFAULT_FIELDS: dict[str, str] = {slot: slot for slot in FILL_FIELDS}
|
|
102
|
+
|
|
103
|
+
# 列探测缓存:(db_type, 表名) -> {列名: SQLAlchemy 类型对象}
|
|
104
|
+
# 仅缓存**探测成功**的结果;失败不缓存,表稍后创建可自愈
|
|
105
|
+
_columns_cache: dict[tuple[str, str], dict[str, TypeEngine[Any]]] = {}
|
|
106
|
+
_columns_lock = threading.Lock()
|
|
107
|
+
|
|
108
|
+
# 「查已删除」上下文开关:进入 AuditConfig.include_deleted() 后临时关闭过滤
|
|
109
|
+
_include_deleted: ContextVar[bool] = ContextVar("audit_include_deleted", default=False)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _default_clock() -> datetime:
|
|
113
|
+
"""默认时钟:UTC aware(与 models/base.py::_utcnow 同口径)"""
|
|
114
|
+
return datetime.now(UTC)
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _is_empty(value: object) -> bool:
|
|
118
|
+
"""是否为「空」:None / 空串 / 纯空白串(0 与 False 不算空)"""
|
|
119
|
+
if value is None:
|
|
120
|
+
return True
|
|
121
|
+
return isinstance(value, str) and not value.strip()
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _is_string_type(column_type: TypeEngine[Any]) -> bool:
|
|
125
|
+
"""是否为字符串列(兼容 SQLModel 的 ``AutoString`` 这类 ``TypeDecorator``)
|
|
126
|
+
|
|
127
|
+
三级判定(任一命中即视为字符串):
|
|
128
|
+
1. ``isinstance(type, String)`` —— 原生 ``VARCHAR`` / ``CHAR`` / ``TEXT``;
|
|
129
|
+
2. ``python_type is str`` —— 多数类型装饰器;
|
|
130
|
+
3. ``impl`` 是 ``String`` 或其子类 —— SQLModel 的 ``AutoString``
|
|
131
|
+
(``TypeDecorator``,前两级都判定不到)。
|
|
132
|
+
"""
|
|
133
|
+
if isinstance(column_type, String):
|
|
134
|
+
return True
|
|
135
|
+
try:
|
|
136
|
+
if column_type.python_type is str:
|
|
137
|
+
return True
|
|
138
|
+
except (AttributeError, NotImplementedError):
|
|
139
|
+
pass
|
|
140
|
+
impl = getattr(column_type, "impl", None)
|
|
141
|
+
if isinstance(impl, String):
|
|
142
|
+
return True
|
|
143
|
+
return isinstance(impl, type) and issubclass(impl, String)
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def normal_value(column_type: TypeEngine[Any]) -> object:
|
|
147
|
+
"""删除标记的「正常」值:字符串列(VARCHAR/CHAR/TEXT)为 ``"0"``,整数列为 ``0``
|
|
148
|
+
|
|
149
|
+
写入填充与查询过滤共用同一个值,避免两侧口径不一致。
|
|
150
|
+
字符串列写整数在 PostgreSQL(``character varying``)与 MySQL 严格模式下
|
|
151
|
+
会直接报类型错误,故按列类型区分。
|
|
152
|
+
|
|
153
|
+
⚠ 内部符号(不进 ``kit.db.__all__``)。
|
|
154
|
+
"""
|
|
155
|
+
return "0" if _is_string_type(column_type) else 0
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
class AuditConfig:
|
|
159
|
+
"""审计字段自动填充的进程级配置(默认关闭)
|
|
160
|
+
|
|
161
|
+
进程级类属性 + 线程锁;``enable`` / ``disable`` 属启动期一次性调用,
|
|
162
|
+
不做运行期热切换承诺。模块导入零副作用。
|
|
163
|
+
"""
|
|
164
|
+
|
|
165
|
+
_lock: ClassVar[Any] = threading.RLock()
|
|
166
|
+
_enabled: ClassVar[bool] = False
|
|
167
|
+
_user_resolver: ClassVar[Callable[[], object] | None] = None
|
|
168
|
+
_clock: ClassVar[Callable[[], datetime]] = _default_clock
|
|
169
|
+
_fields: ClassVar[dict[str, str]] = dict(DEFAULT_FIELDS)
|
|
170
|
+
_soft_delete_filter: ClassVar[bool] = False
|
|
171
|
+
_listening: ClassVar[bool] = False
|
|
172
|
+
|
|
173
|
+
@classmethod
|
|
174
|
+
def enable(
|
|
175
|
+
cls,
|
|
176
|
+
*,
|
|
177
|
+
user_resolver: Callable[[], object] | None = None,
|
|
178
|
+
clock: Callable[[], datetime] | None = None,
|
|
179
|
+
fields: Mapping[str, str] | None = None,
|
|
180
|
+
soft_delete_filter: bool | None = None,
|
|
181
|
+
) -> None:
|
|
182
|
+
"""启用自动填充(幂等)
|
|
183
|
+
|
|
184
|
+
Args:
|
|
185
|
+
user_resolver: 返回当前操作人的钩子(str 或 int,与列类型一致)。
|
|
186
|
+
返回 None / 空串时跳过 ``*_by`` 填充;抛异常时记 warning 并跳过,
|
|
187
|
+
不影响写入。
|
|
188
|
+
clock: 取时间钩子,默认 ``datetime.now(UTC)``(测试可注入冻结时钟)。
|
|
189
|
+
fields: 自定义字段名(槽位 → 实际列名),只给需要改的项,如
|
|
190
|
+
``{"created_at": "create_time", "deleted": "del_flag"}``;
|
|
191
|
+
透传给 :meth:`set_fields`。
|
|
192
|
+
soft_delete_filter: 是否让查询方法自动追加「deleted = 正常值」条件
|
|
193
|
+
(None = 保持当前设置;默认关闭)。
|
|
194
|
+
|
|
195
|
+
Note:
|
|
196
|
+
事件监听挂在父类 :class:`sqlalchemy.orm.Session` 上,因此
|
|
197
|
+
``session_scope`` 新建的 Session 与应用自建的原生 Session 都会触发。
|
|
198
|
+
"""
|
|
199
|
+
with cls._lock:
|
|
200
|
+
if fields is not None:
|
|
201
|
+
cls.set_fields(fields)
|
|
202
|
+
if soft_delete_filter is not None:
|
|
203
|
+
cls._soft_delete_filter = soft_delete_filter
|
|
204
|
+
# ClassVar 上的 Callable 赋值:pyright 会误判为方法绑定,运行时正确
|
|
205
|
+
if user_resolver is not None:
|
|
206
|
+
cls._user_resolver = user_resolver # pyright: ignore[reportAttributeAccessIssue]
|
|
207
|
+
if clock is not None:
|
|
208
|
+
cls._clock = clock # pyright: ignore[reportAttributeAccessIssue]
|
|
209
|
+
if not cls._listening:
|
|
210
|
+
event.listen(Session, "before_flush", _on_before_flush)
|
|
211
|
+
cls._listening = True
|
|
212
|
+
cls._enabled = True
|
|
213
|
+
logger.info("✅ 审计字段与删除标记自动填充已启用")
|
|
214
|
+
|
|
215
|
+
@classmethod
|
|
216
|
+
def fields(cls) -> dict[str, str]:
|
|
217
|
+
"""当前「槽位 → 实际列名」映射(副本,可安全修改)"""
|
|
218
|
+
return dict(cls._fields)
|
|
219
|
+
|
|
220
|
+
@classmethod
|
|
221
|
+
def set_fields(cls, fields: Mapping[str, str] | None = None) -> None:
|
|
222
|
+
"""配置字段名(不传 / 传 None 恢复默认)
|
|
223
|
+
|
|
224
|
+
Args:
|
|
225
|
+
fields: 槽位 → 实际列名,只给需要改的槽位。可用槽位:
|
|
226
|
+
``created_at`` / ``created_by`` / ``updated_at`` / ``updated_by`` /
|
|
227
|
+
``deleted``
|
|
228
|
+
|
|
229
|
+
Raises:
|
|
230
|
+
ValueError: 出现未知槽位(防手误,例如把 ``created_at`` 写成 ``create_at``)
|
|
231
|
+
|
|
232
|
+
用法::
|
|
233
|
+
|
|
234
|
+
AuditConfig.set_fields(
|
|
235
|
+
{"created_at": "create_time", "deleted": "del_flag"}
|
|
236
|
+
)
|
|
237
|
+
"""
|
|
238
|
+
merged = dict(DEFAULT_FIELDS)
|
|
239
|
+
if fields:
|
|
240
|
+
unknown = set(fields) - set(DEFAULT_FIELDS)
|
|
241
|
+
if unknown:
|
|
242
|
+
raise ValueError(
|
|
243
|
+
f"未知的字段槽位: {sorted(unknown)};"
|
|
244
|
+
f"可用槽位: {sorted(DEFAULT_FIELDS)}"
|
|
245
|
+
)
|
|
246
|
+
merged.update(fields)
|
|
247
|
+
with cls._lock:
|
|
248
|
+
cls._fields = merged
|
|
249
|
+
|
|
250
|
+
@classmethod
|
|
251
|
+
def set_soft_delete_filter(cls, enabled: bool) -> None:
|
|
252
|
+
"""开关「查询自动过滤软删除记录」(默认关闭)
|
|
253
|
+
|
|
254
|
+
Args:
|
|
255
|
+
enabled: True 时,kit 的查询方法对**有 deleted 列的表/模型**追加
|
|
256
|
+
``deleted = 正常值`` 条件(整数字段为 ``0``,字符串字段为 ``"0"``)。
|
|
257
|
+
|
|
258
|
+
Note:
|
|
259
|
+
- 只影响**查询**:`CRUDBase` 的 get / get_or_raise / get_multi /
|
|
260
|
+
get_by_field / get_multi_by_field / count / exists,以及 `raw_get_by`;
|
|
261
|
+
- 写方法(create / update / update_multi / delete / upsert / raw_*)
|
|
262
|
+
不追加该条件;
|
|
263
|
+
- `query()` 手写 SQL **不处理**(与 `execute()` 同一条原则);
|
|
264
|
+
- 需要读取已删除记录时用 :meth:`include_deleted` 上下文。
|
|
265
|
+
"""
|
|
266
|
+
with cls._lock:
|
|
267
|
+
cls._soft_delete_filter = enabled
|
|
268
|
+
logger.info(f"🔎 软删除查询过滤已{'开启' if enabled else '关闭'}")
|
|
269
|
+
|
|
270
|
+
@classmethod
|
|
271
|
+
def filter_deleted(cls) -> bool:
|
|
272
|
+
"""当前查询是否应追加「deleted = 正常值」条件
|
|
273
|
+
|
|
274
|
+
综合两件事:过滤开关已开启,且当前不在 :meth:`include_deleted` 上下文中。
|
|
275
|
+
"""
|
|
276
|
+
return cls._soft_delete_filter and not _include_deleted.get()
|
|
277
|
+
|
|
278
|
+
@classmethod
|
|
279
|
+
@contextmanager
|
|
280
|
+
def include_deleted(cls) -> Generator[None, None, None]:
|
|
281
|
+
"""临时查询已删除记录的上下文(仅本上下文内关闭过滤)
|
|
282
|
+
|
|
283
|
+
用法::
|
|
284
|
+
|
|
285
|
+
with AuditConfig.include_deleted():
|
|
286
|
+
rows = PgKit.query(...) # 手写 SQL 本就不受过滤影响
|
|
287
|
+
obj = PgKit.get(Model, 1) # 能查到 deleted=1 的记录
|
|
288
|
+
"""
|
|
289
|
+
token = _include_deleted.set(True)
|
|
290
|
+
try:
|
|
291
|
+
yield
|
|
292
|
+
finally:
|
|
293
|
+
_include_deleted.reset(token)
|
|
294
|
+
|
|
295
|
+
@classmethod
|
|
296
|
+
def disable(cls) -> None:
|
|
297
|
+
"""关闭自动填充(保留 user_resolver / clock 配置,再次 enable 可沿用)"""
|
|
298
|
+
with cls._lock:
|
|
299
|
+
cls._enabled = False
|
|
300
|
+
if cls._listening:
|
|
301
|
+
event.remove(Session, "before_flush", _on_before_flush)
|
|
302
|
+
cls._listening = False
|
|
303
|
+
logger.info("🔌 审计字段与删除标记自动填充已关闭")
|
|
304
|
+
|
|
305
|
+
@classmethod
|
|
306
|
+
def is_enabled(cls) -> bool:
|
|
307
|
+
"""当前是否启用"""
|
|
308
|
+
return cls._enabled
|
|
309
|
+
|
|
310
|
+
@classmethod
|
|
311
|
+
def now(cls) -> datetime:
|
|
312
|
+
"""当前时间(UTC aware;clock 异常时记 warning 并回退 UTC now)"""
|
|
313
|
+
try:
|
|
314
|
+
return cls._clock() # pyright: ignore[reportAttributeAccessIssue]
|
|
315
|
+
except Exception as e:
|
|
316
|
+
logger.warning(f"⚠️ 审计时钟调用失败,回退 UTC now: {e}")
|
|
317
|
+
return datetime.now(UTC)
|
|
318
|
+
|
|
319
|
+
@classmethod
|
|
320
|
+
def current_user(cls) -> object | None:
|
|
321
|
+
"""当前操作人(resolver 未注册或异常时返回 None)"""
|
|
322
|
+
resolver = cls._user_resolver # pyright: ignore[reportAttributeAccessIssue]
|
|
323
|
+
if resolver is None:
|
|
324
|
+
return None
|
|
325
|
+
try:
|
|
326
|
+
return resolver()
|
|
327
|
+
except Exception as e:
|
|
328
|
+
logger.warning(f"⚠️ user_resolver 调用失败,跳过 *_by 填充: {e}")
|
|
329
|
+
return None
|
|
330
|
+
|
|
331
|
+
@classmethod
|
|
332
|
+
def clear_columns_cache(cls, *tables: str) -> None:
|
|
333
|
+
"""清空列探测缓存(表结构变更后调用;不传参清空全部)
|
|
334
|
+
|
|
335
|
+
Args:
|
|
336
|
+
tables: 表名(可多个);不传则清空全部缓存
|
|
337
|
+
"""
|
|
338
|
+
with _columns_lock:
|
|
339
|
+
if not tables:
|
|
340
|
+
_columns_cache.clear()
|
|
341
|
+
return
|
|
342
|
+
targets = set(tables)
|
|
343
|
+
for key in [k for k in _columns_cache if k[1] in targets]:
|
|
344
|
+
del _columns_cache[key]
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
# ==================== 列探测(原生路径) ====================
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
def table_columns(db_type: str, table_name: str) -> dict[str, TypeEngine[Any]]:
|
|
351
|
+
"""表「列名 → SQLAlchemy 类型对象」映射(仅缓存探测成功的结果)
|
|
352
|
+
|
|
353
|
+
Args:
|
|
354
|
+
db_type: 数据库类型(postgresql / mysql / sqlite)
|
|
355
|
+
table_name: 表名(非限定名,按当前 search_path / database 解析)
|
|
356
|
+
|
|
357
|
+
Returns:
|
|
358
|
+
列名到类型对象的映射;表不存在或探测失败时返回**空字典**(= 不填充),
|
|
359
|
+
且不写入缓存(下次调用重新探测,表稍后创建可自愈)
|
|
360
|
+
|
|
361
|
+
Note:
|
|
362
|
+
保留类型对象而非类型名:``deleted`` 的取值需要 ``isinstance`` 判定
|
|
363
|
+
(``String`` 家族 → ``"0"``,其余 → ``0``)。
|
|
364
|
+
"""
|
|
365
|
+
key = (db_type, table_name)
|
|
366
|
+
with _columns_lock:
|
|
367
|
+
cached = _columns_cache.get(key)
|
|
368
|
+
if cached is not None:
|
|
369
|
+
return cached
|
|
370
|
+
|
|
371
|
+
try:
|
|
372
|
+
inspector = sa_inspect(get_engine(db_type))
|
|
373
|
+
columns = {col["name"]: col["type"] for col in inspector.get_columns(table_name)}
|
|
374
|
+
except NoSuchTableError:
|
|
375
|
+
logger.debug(f"表 {table_name} 不存在,跳过审计字段填充")
|
|
376
|
+
return {}
|
|
377
|
+
except Exception as e:
|
|
378
|
+
logger.warning(f"⚠️ 列探测失败({db_type}:{table_name}),跳过填充: {e}")
|
|
379
|
+
return {}
|
|
380
|
+
|
|
381
|
+
with _columns_lock:
|
|
382
|
+
_columns_cache[key] = columns
|
|
383
|
+
return columns
|
|
384
|
+
|
|
385
|
+
|
|
386
|
+
def deleted_predicate(
|
|
387
|
+
db_type: str, table_name: str
|
|
388
|
+
) -> tuple[str, dict[str, object]] | None:
|
|
389
|
+
"""软删除过滤条件(SQL 片段 + 绑定参数);不适用时返回 None
|
|
390
|
+
|
|
391
|
+
适用条件:过滤开关已开启、当前不在 :meth:`AuditConfig.include_deleted` 上下文、
|
|
392
|
+
表中存在 deleted 列。
|
|
393
|
+
|
|
394
|
+
Returns:
|
|
395
|
+
``("deleted = :_audit_deleted", {"_audit_deleted": 0})``;字符串列的值是
|
|
396
|
+
``"0"``。条件不适用时返回 None(调用方据此不加条件)。
|
|
397
|
+
|
|
398
|
+
Note:
|
|
399
|
+
严格等值,不对 ``NULL`` 做特殊处理——本工具类只提供底层约定,
|
|
400
|
+
不为业务历史数据兜底。
|
|
401
|
+
|
|
402
|
+
⚠ 内部符号:只供 kit 自建 SQL 的**查询**方法(如 ``raw_get_by``)使用,
|
|
403
|
+
写方法不得调用。
|
|
404
|
+
"""
|
|
405
|
+
if not AuditConfig.filter_deleted():
|
|
406
|
+
return None
|
|
407
|
+
name = AuditConfig.fields()[DELETED]
|
|
408
|
+
columns = table_columns(db_type, table_name)
|
|
409
|
+
if name not in columns:
|
|
410
|
+
return None
|
|
411
|
+
return f"{name} = :_audit_deleted", {"_audit_deleted": normal_value(columns[name])}
|
|
412
|
+
|
|
413
|
+
|
|
414
|
+
# ==================== dict 路径填充(Core DML / 原生构建器) ====================
|
|
415
|
+
|
|
416
|
+
|
|
417
|
+
def _put(
|
|
418
|
+
data: MutableMapping[str, object],
|
|
419
|
+
columns: Mapping[str, TypeEngine[Any]],
|
|
420
|
+
name: str,
|
|
421
|
+
value: object,
|
|
422
|
+
) -> None:
|
|
423
|
+
"""按唯一规则写一个字段:有列 + 填充值非空 + 当前值为空"""
|
|
424
|
+
if name not in columns or _is_empty(value):
|
|
425
|
+
return
|
|
426
|
+
if not _is_empty(data.get(name)):
|
|
427
|
+
return
|
|
428
|
+
data[name] = value
|
|
429
|
+
|
|
430
|
+
|
|
431
|
+
def fill_insert(
|
|
432
|
+
data: MutableMapping[str, object], columns: Mapping[str, TypeEngine[Any]]
|
|
433
|
+
) -> None:
|
|
434
|
+
"""INSERT 半区:created_* 与 updated_* 同值填补,deleted 补正常值
|
|
435
|
+
|
|
436
|
+
Args:
|
|
437
|
+
data: 待写入的列名 → 值(**原地修改**,调用方应传入副本)
|
|
438
|
+
columns: 表列名 → 类型对象(为空表示表无审计列,不填)
|
|
439
|
+
"""
|
|
440
|
+
if not AuditConfig.is_enabled():
|
|
441
|
+
return
|
|
442
|
+
now = AuditConfig.now()
|
|
443
|
+
user = AuditConfig.current_user()
|
|
444
|
+
names = AuditConfig._fields
|
|
445
|
+
_put(data, columns, names[CREATED_AT], now)
|
|
446
|
+
_put(data, columns, names[UPDATED_AT], now)
|
|
447
|
+
_put(data, columns, names[CREATED_BY], user)
|
|
448
|
+
_put(data, columns, names[UPDATED_BY], user)
|
|
449
|
+
deleted_col = names[DELETED]
|
|
450
|
+
if deleted_col in columns:
|
|
451
|
+
_put(data, columns, deleted_col, normal_value(columns[deleted_col]))
|
|
452
|
+
|
|
453
|
+
|
|
454
|
+
def fill_update(
|
|
455
|
+
data: MutableMapping[str, object], columns: Mapping[str, TypeEngine[Any]]
|
|
456
|
+
) -> None:
|
|
457
|
+
"""UPDATE 半区:只动 updated_*(不碰 created_*,也不碰 deleted)
|
|
458
|
+
|
|
459
|
+
Args:
|
|
460
|
+
data: 待更新的列名 → 值(**原地修改**,调用方应传入副本)
|
|
461
|
+
columns: 表列名 → 类型对象
|
|
462
|
+
"""
|
|
463
|
+
if not AuditConfig.is_enabled():
|
|
464
|
+
return
|
|
465
|
+
now = AuditConfig.now()
|
|
466
|
+
user = AuditConfig.current_user()
|
|
467
|
+
names = AuditConfig._fields
|
|
468
|
+
_put(data, columns, names[UPDATED_AT], now)
|
|
469
|
+
_put(data, columns, names[UPDATED_BY], user)
|
|
470
|
+
|
|
471
|
+
|
|
472
|
+
def fill_insert_for(
|
|
473
|
+
db_type: str, table_name: str, data: MutableMapping[str, object]
|
|
474
|
+
) -> None:
|
|
475
|
+
"""原生路径的 INSERT 入口:**先判启用再探测**(未启用时零额外查询)"""
|
|
476
|
+
if not AuditConfig.is_enabled():
|
|
477
|
+
return
|
|
478
|
+
fill_insert(data, table_columns(db_type, table_name))
|
|
479
|
+
|
|
480
|
+
|
|
481
|
+
def fill_update_for(
|
|
482
|
+
db_type: str, table_name: str, data: MutableMapping[str, object]
|
|
483
|
+
) -> None:
|
|
484
|
+
"""原生路径的 UPDATE 入口:**先判启用再探测**(未启用时零额外查询)"""
|
|
485
|
+
if not AuditConfig.is_enabled():
|
|
486
|
+
return
|
|
487
|
+
fill_update(data, table_columns(db_type, table_name))
|
|
488
|
+
|
|
489
|
+
|
|
490
|
+
# ==================== ORM 路径填充(before_flush) ====================
|
|
491
|
+
|
|
492
|
+
|
|
493
|
+
def _object_columns(obj: object) -> dict[str, TypeEngine[Any]]:
|
|
494
|
+
"""对象映射到的「表列名 → 类型对象」(无 ``__table__`` 时返回空字典)"""
|
|
495
|
+
table = getattr(obj, "__table__", None)
|
|
496
|
+
if table is None:
|
|
497
|
+
return {}
|
|
498
|
+
return {column.name: column.type for column in table.columns}
|
|
499
|
+
|
|
500
|
+
|
|
501
|
+
def _fill_obj(
|
|
502
|
+
obj: object, *, now: datetime, user: object | None, insert: bool
|
|
503
|
+
) -> None:
|
|
504
|
+
"""按矩阵填充单个 ORM 对象(列不存在 / 已有非空值 → 跳过)"""
|
|
505
|
+
columns = _object_columns(obj)
|
|
506
|
+
names = AuditConfig._fields
|
|
507
|
+
pairs: list[tuple[str, object]] = (
|
|
508
|
+
[
|
|
509
|
+
(names[CREATED_AT], now),
|
|
510
|
+
(names[UPDATED_AT], now),
|
|
511
|
+
(names[CREATED_BY], user),
|
|
512
|
+
(names[UPDATED_BY], user),
|
|
513
|
+
]
|
|
514
|
+
if insert
|
|
515
|
+
else [(names[UPDATED_AT], now), (names[UPDATED_BY], user)]
|
|
516
|
+
)
|
|
517
|
+
deleted_col = names[DELETED]
|
|
518
|
+
if insert and deleted_col in columns:
|
|
519
|
+
pairs.append((deleted_col, normal_value(columns[deleted_col])))
|
|
520
|
+
|
|
521
|
+
state = sa_inspect(obj)
|
|
522
|
+
if state is None: # 理论上实例一定有状态,防御性判断
|
|
523
|
+
return
|
|
524
|
+
for name, value in pairs:
|
|
525
|
+
if name not in columns or _is_empty(value) or not hasattr(obj, name):
|
|
526
|
+
continue
|
|
527
|
+
if name not in state.attrs:
|
|
528
|
+
continue
|
|
529
|
+
current = getattr(obj, name, None)
|
|
530
|
+
if insert:
|
|
531
|
+
# INSERT:属性当前值非空 → 调用方已指定
|
|
532
|
+
if not _is_empty(current):
|
|
533
|
+
continue
|
|
534
|
+
elif state.attrs[name].history.has_changes() and not _is_empty(current):
|
|
535
|
+
# UPDATE:本次显式赋了非空值 → 保留;未赋值 / 赋空 → 填充
|
|
536
|
+
continue
|
|
537
|
+
setattr(obj, name, value)
|
|
538
|
+
|
|
539
|
+
|
|
540
|
+
def _on_before_flush(
|
|
541
|
+
session: Session, flush_context: object, instances: object
|
|
542
|
+
) -> None:
|
|
543
|
+
"""``before_flush`` 钩子:INSERT 走 new 集合,UPDATE 走 dirty 集合
|
|
544
|
+
|
|
545
|
+
时间与用户在一次 flush 内只取一次,同批对象取值一致。
|
|
546
|
+
"""
|
|
547
|
+
if not AuditConfig.is_enabled():
|
|
548
|
+
return
|
|
549
|
+
now = AuditConfig.now()
|
|
550
|
+
user = AuditConfig.current_user()
|
|
551
|
+
for obj in session.new:
|
|
552
|
+
_fill_obj(obj, now=now, user=user, insert=True)
|
|
553
|
+
for obj in session.dirty:
|
|
554
|
+
if session.is_modified(obj, include_collections=False):
|
|
555
|
+
_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 deleted_predicate, 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(
|
|
@@ -830,10 +914,22 @@ class BaseDbKit(ABC):
|
|
|
830
914
|
|
|
831
915
|
Returns:
|
|
832
916
|
匹配的第一行(字典);无匹配返回 None
|
|
917
|
+
|
|
918
|
+
Note:
|
|
919
|
+
开启软删除查询过滤(`AuditConfig.set_soft_delete_filter(True)`)后,
|
|
920
|
+
若表存在 deleted 列,会自动追加 ``deleted = 正常值`` 条件;
|
|
921
|
+
需读取已删除行时用 ``with AuditConfig.include_deleted():`` 包裹。
|
|
833
922
|
"""
|
|
923
|
+
where = f"{column} = :value"
|
|
924
|
+
params: dict[str, object] = {"value": value}
|
|
925
|
+
predicate = deleted_predicate(cls.DB_TYPE, table_name)
|
|
926
|
+
if predicate is not None:
|
|
927
|
+
fragment, extra = predicate
|
|
928
|
+
where = f"{where} AND {fragment}"
|
|
929
|
+
params.update(extra)
|
|
930
|
+
|
|
834
931
|
rows = cls.query(
|
|
835
|
-
f"SELECT * FROM {table_name} WHERE {
|
|
836
|
-
{"value": value},
|
|
932
|
+
f"SELECT * FROM {table_name} WHERE {where} LIMIT 1", params
|
|
837
933
|
)
|
|
838
934
|
return rows[0] if rows else None
|
|
839
935
|
|
|
@@ -36,10 +36,20 @@ 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 (
|
|
45
|
+
CREATED_AT,
|
|
46
|
+
CREATED_BY,
|
|
47
|
+
DELETED,
|
|
48
|
+
AuditConfig,
|
|
49
|
+
fill_insert,
|
|
50
|
+
fill_update,
|
|
51
|
+
normal_value,
|
|
52
|
+
)
|
|
43
53
|
from kit.db.exceptions import DBError, NotFoundError
|
|
44
54
|
|
|
45
55
|
logger = get_default_logger(__name__)
|
|
@@ -67,9 +77,37 @@ class CRUDBase[
|
|
|
67
77
|
def __init__(self, model: type[ModelType]):
|
|
68
78
|
self.model = model
|
|
69
79
|
|
|
70
|
-
def _select_all(self) -> SelectOfScalar[ModelType]:
|
|
71
|
-
"""返回 sqlmodel.select 的标量查询(SQLModel 类型桥接)
|
|
72
|
-
|
|
80
|
+
def _select_all(self, *, include_deleted: bool = False) -> SelectOfScalar[ModelType]:
|
|
81
|
+
"""返回 sqlmodel.select 的标量查询(SQLModel 类型桥接)
|
|
82
|
+
|
|
83
|
+
Args:
|
|
84
|
+
include_deleted: 是否跳过软删除过滤(默认 False;仅过滤开关开启时有效)
|
|
85
|
+
"""
|
|
86
|
+
statement = cast(SelectOfScalar[ModelType], select(self.model))
|
|
87
|
+
condition = self._deleted_condition(include_deleted=include_deleted)
|
|
88
|
+
return statement.where(condition) if condition is not None else statement
|
|
89
|
+
|
|
90
|
+
def _deleted_condition(self, *, include_deleted: bool = False) -> Any:
|
|
91
|
+
"""软删除过滤条件(过滤关闭 / 模型无 deleted 列时返回 None)
|
|
92
|
+
|
|
93
|
+
严格等值:``deleted = 正常值``(整数列 ``0``、字符串列 ``"0"``)。
|
|
94
|
+
``NULL`` 不视为未删除——本工具类只做底层约定,不为业务历史数据兜底。
|
|
95
|
+
"""
|
|
96
|
+
if include_deleted or not AuditConfig.filter_deleted():
|
|
97
|
+
return None
|
|
98
|
+
column = getattr(self.model, AuditConfig.fields()[DELETED], None)
|
|
99
|
+
if column is None:
|
|
100
|
+
return None
|
|
101
|
+
return column == normal_value(column.type)
|
|
102
|
+
|
|
103
|
+
def _is_visible(self, obj: ModelType) -> bool:
|
|
104
|
+
"""对象是否未被软删除(过滤关闭 / 模型无该字段时恒为 True)"""
|
|
105
|
+
if not AuditConfig.filter_deleted():
|
|
106
|
+
return True
|
|
107
|
+
column = getattr(self.model, AuditConfig.fields()[DELETED], None)
|
|
108
|
+
if column is None:
|
|
109
|
+
return True
|
|
110
|
+
return getattr(obj, column.key, None) == normal_value(column.type)
|
|
73
111
|
|
|
74
112
|
def _id_col(self) -> Column[Any]:
|
|
75
113
|
"""约定的主键列(避免 SQLModel 泛型无 id 字段的类型缺陷)"""
|
|
@@ -79,20 +117,30 @@ class CRUDBase[
|
|
|
79
117
|
"""模型对应的 Table(避免 SQLModel 泛型无 __table__ 的类型缺陷)"""
|
|
80
118
|
return cast(Table, getattr(self.model, "__table__"))
|
|
81
119
|
|
|
120
|
+
def _column_names(self) -> dict[str, TypeEngine[Any]]:
|
|
121
|
+
"""模型映射表的「列名 → SQLAlchemy 类型对象」(读元数据,零 DB 查询)
|
|
122
|
+
|
|
123
|
+
供审计字段自动填充判断「表中是否存在该列」,以及 ``deleted`` 的取值类型。
|
|
124
|
+
"""
|
|
125
|
+
return {column.name: column.type for column in self._table().columns}
|
|
126
|
+
|
|
82
127
|
# ==================== 查询方法 ====================
|
|
83
128
|
|
|
84
129
|
def get(self, session: Session, id: Any) -> ModelType | None:
|
|
85
|
-
"""
|
|
130
|
+
"""根据主键获取单条记录(开启软删除过滤时隐藏已删除记录)"""
|
|
86
131
|
obj = session.get(self.model, id)
|
|
132
|
+
if obj is not None and not self._is_visible(obj):
|
|
133
|
+
logger.info(f"🔍 查询 {self.model.__name__} id={id} → 已软删除(过滤隐藏)")
|
|
134
|
+
return None
|
|
87
135
|
logger.info(
|
|
88
136
|
f"🔍 查询 {self.model.__name__} id={id} → {'命中' if obj else '未找到'}"
|
|
89
137
|
)
|
|
90
138
|
return obj
|
|
91
139
|
|
|
92
140
|
def get_or_raise(self, session: Session, id: Any) -> ModelType:
|
|
93
|
-
"""
|
|
141
|
+
"""根据主键获取记录,不存在(或已软删除)时抛出 NotFoundError"""
|
|
94
142
|
obj = session.get(self.model, id)
|
|
95
|
-
if obj is None:
|
|
143
|
+
if obj is None or not self._is_visible(obj):
|
|
96
144
|
logger.warning(
|
|
97
145
|
f"❌ {self.model.__name__} id={id} 不存在,抛出 NotFoundError"
|
|
98
146
|
)
|
|
@@ -219,12 +267,17 @@ class CRUDBase[
|
|
|
219
267
|
ids: list[Any],
|
|
220
268
|
obj_in: UpdateSchemaType | dict[str, Any],
|
|
221
269
|
) -> int:
|
|
222
|
-
"""批量更新记录(单条 UPDATE SQL,不 commit)
|
|
223
|
-
|
|
270
|
+
"""批量更新记录(单条 UPDATE SQL,不 commit)
|
|
271
|
+
|
|
272
|
+
启用审计填充时自动补 ``updated_at`` / ``updated_by``
|
|
273
|
+
(不碰 ``created_*`` 与 ``deleted``)。
|
|
274
|
+
"""
|
|
275
|
+
update_data = dict(
|
|
224
276
|
obj_in
|
|
225
277
|
if isinstance(obj_in, dict)
|
|
226
278
|
else obj_in.model_dump(exclude_unset=True)
|
|
227
279
|
)
|
|
280
|
+
fill_update(update_data, self._column_names())
|
|
228
281
|
id_col = self._id_col()
|
|
229
282
|
statement = update(self.model).where(id_col.in_(ids)).values(**update_data)
|
|
230
283
|
result = session.execute(statement)
|
|
@@ -257,19 +310,24 @@ class CRUDBase[
|
|
|
257
310
|
# ==================== 聚合查询 ====================
|
|
258
311
|
|
|
259
312
|
def count(self, session: Session) -> int:
|
|
260
|
-
"""
|
|
261
|
-
|
|
313
|
+
"""获取记录总数(开启软删除过滤时不计已删除记录)"""
|
|
314
|
+
statement = select(func.count()).select_from(self.model)
|
|
315
|
+
condition = self._deleted_condition()
|
|
316
|
+
if condition is not None:
|
|
317
|
+
statement = statement.where(condition)
|
|
318
|
+
total = session.exec(statement).one()
|
|
262
319
|
logger.info(f"🔍 统计 {self.model.__name__} 记录总数 → {total}")
|
|
263
320
|
return total
|
|
264
321
|
|
|
265
322
|
def exists(self, session: Session, id: Any) -> bool:
|
|
266
323
|
"""检查记录是否存在(仅查询主键列,不载入整对象)"""
|
|
267
324
|
id_col = self._id_col()
|
|
325
|
+
statement = select(id_col).where(id_col == id)
|
|
326
|
+
condition = self._deleted_condition()
|
|
327
|
+
if condition is not None:
|
|
328
|
+
statement = statement.where(condition)
|
|
268
329
|
found = (
|
|
269
|
-
session.exec(
|
|
270
|
-
cast(SelectOfScalar[object], select(id_col).where(id_col == id))
|
|
271
|
-
).first()
|
|
272
|
-
is not None
|
|
330
|
+
session.exec(cast(SelectOfScalar[object], statement)).first() is not None
|
|
273
331
|
)
|
|
274
332
|
logger.info(
|
|
275
333
|
f"🔍 检查 {self.model.__name__} id={id} 是否存在 → "
|
|
@@ -294,7 +352,7 @@ class CRUDBase[
|
|
|
294
352
|
obj_in: 创建/更新数据
|
|
295
353
|
conflict_fields: 用于判断冲突的字段列表(需为唯一约束)
|
|
296
354
|
"""
|
|
297
|
-
data = (
|
|
355
|
+
data = dict(
|
|
298
356
|
obj_in
|
|
299
357
|
if isinstance(obj_in, dict)
|
|
300
358
|
else obj_in.model_dump(exclude_unset=True)
|
|
@@ -302,17 +360,28 @@ class CRUDBase[
|
|
|
302
360
|
if not conflict_fields:
|
|
303
361
|
raise DBError("upsert 必须提供 conflict_fields")
|
|
304
362
|
|
|
305
|
-
|
|
363
|
+
columns = self._column_names()
|
|
364
|
+
names = AuditConfig.fields()
|
|
365
|
+
|
|
366
|
+
# 更新集:排除冲突字段、主键,以及**只在 INSERT 填充**的 created_*
|
|
367
|
+
# (若把 created_* 带进 set_,每次冲突更新都会把创建时间刷成 now)
|
|
368
|
+
insert_only = (names[CREATED_AT], names[CREATED_BY])
|
|
306
369
|
pk_cols = {c.name for c in self._table().primary_key.columns}
|
|
307
370
|
update_cols = {
|
|
308
371
|
k: v
|
|
309
372
|
for k, v in data.items()
|
|
310
|
-
if k not in conflict_fields
|
|
373
|
+
if k not in conflict_fields
|
|
374
|
+
and k not in pk_cols
|
|
375
|
+
and k not in insert_only
|
|
311
376
|
}
|
|
312
377
|
if not update_cols:
|
|
313
378
|
# 无更新字段时退化为空更新(保持合法 SQL)
|
|
314
379
|
update_cols = {conflict_fields[0]: data[conflict_fields[0]]}
|
|
315
380
|
|
|
381
|
+
# INSERT 半区补 created_*/updated_*/deleted;UPDATE 半区补 updated_*
|
|
382
|
+
fill_insert(data, columns)
|
|
383
|
+
fill_update(update_cols, columns)
|
|
384
|
+
|
|
316
385
|
# 各数据库方言的 INSERT ... ON CONFLICT / ON DUPLICATE KEY(原子单语句)
|
|
317
386
|
dialect = session.get_bind().dialect.name
|
|
318
387
|
if dialect == "postgresql":
|
|
@@ -336,12 +405,13 @@ class CRUDBase[
|
|
|
336
405
|
else:
|
|
337
406
|
raise DBError(f"不支持的数据库类型: {dialect}")
|
|
338
407
|
|
|
339
|
-
# 按冲突字段回查返回实例(populate_existing
|
|
408
|
+
# 按冲突字段回查返回实例(populate_existing 强制刷新,避免身份映射返回旧值);
|
|
409
|
+
# 回查**必须跳过软删除过滤**:冲突行可能已被软删除,否则会误报回查失败
|
|
340
410
|
conditions = [
|
|
341
411
|
getattr(self.model, field) == data.get(field) for field in conflict_fields
|
|
342
412
|
]
|
|
343
413
|
re_stmt = (
|
|
344
|
-
self._select_all()
|
|
414
|
+
self._select_all(include_deleted=True)
|
|
345
415
|
.where(*conditions)
|
|
346
416
|
.execution_options(populate_existing=True)
|
|
347
417
|
)
|
|
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
|