befly 3.9.40 → 3.10.0
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.
- package/README.md +39 -8
- package/befly.config.ts +19 -2
- package/checks/checkApi.ts +79 -77
- package/checks/checkHook.ts +48 -0
- package/checks/checkMenu.ts +168 -0
- package/checks/checkPlugin.ts +48 -0
- package/checks/checkTable.ts +137 -183
- package/docs/README.md +1 -1
- package/docs/api/api.md +1 -1
- package/docs/guide/quickstart.md +16 -9
- package/docs/hooks/hook.md +2 -2
- package/docs/hooks/rateLimit.md +1 -1
- package/docs/infra/redis.md +7 -7
- package/docs/plugins/plugin.md +23 -21
- package/docs/quickstart.md +16 -9
- package/docs/reference/addon.md +12 -1
- package/docs/reference/config.md +13 -30
- package/docs/reference/sync.md +62 -193
- package/docs/reference/table.md +27 -29
- package/hooks/auth.ts +3 -4
- package/hooks/cors.ts +4 -6
- package/hooks/parser.ts +3 -4
- package/hooks/permission.ts +3 -4
- package/hooks/validator.ts +3 -4
- package/lib/cacheHelper.ts +89 -153
- package/lib/cacheKeys.ts +1 -1
- package/lib/connect.ts +9 -13
- package/lib/dbDialect.ts +285 -0
- package/lib/dbHelper.ts +179 -507
- package/lib/dbUtils.ts +450 -0
- package/lib/logger.ts +41 -5
- package/lib/redisHelper.ts +1 -0
- package/lib/sqlBuilder.ts +358 -58
- package/lib/sqlCheck.ts +136 -0
- package/lib/validator.ts +1 -1
- package/loader/loadApis.ts +23 -126
- package/loader/loadHooks.ts +31 -46
- package/loader/loadPlugins.ts +37 -52
- package/main.ts +58 -19
- package/package.json +24 -25
- package/paths.ts +14 -14
- package/plugins/cache.ts +12 -6
- package/plugins/cipher.ts +2 -2
- package/plugins/config.ts +6 -8
- package/plugins/db.ts +14 -19
- package/plugins/jwt.ts +6 -7
- package/plugins/logger.ts +7 -9
- package/plugins/redis.ts +8 -10
- package/plugins/tool.ts +3 -4
- package/router/api.ts +3 -2
- package/router/static.ts +7 -5
- package/sync/syncApi.ts +80 -235
- package/sync/syncCache.ts +16 -0
- package/sync/syncDev.ts +167 -202
- package/sync/syncMenu.ts +230 -444
- package/sync/syncTable.ts +1247 -0
- package/tests/_mocks/mockSqliteDb.ts +204 -0
- package/tests/addonHelper-cache.test.ts +32 -0
- package/tests/apiHandler-routePath-only.test.ts +32 -0
- package/tests/cacheHelper.test.ts +16 -51
- package/tests/checkApi-routePath-strict.test.ts +166 -0
- package/tests/checkMenu.test.ts +346 -0
- package/tests/checkTable-smoke.test.ts +157 -0
- package/tests/dbDialect-cache.test.ts +23 -0
- package/tests/dbDialect.test.ts +46 -0
- package/tests/dbHelper-advanced.test.ts +1 -1
- package/tests/dbHelper-all-array-types.test.ts +15 -15
- package/tests/dbHelper-batch-write.test.ts +90 -0
- package/tests/dbHelper-columns.test.ts +36 -54
- package/tests/dbHelper-execute.test.ts +26 -26
- package/tests/dbHelper-joins.test.ts +85 -176
- package/tests/fixtures/scanFilesAddon/node_modules/@befly-addon/demo/apis/sub/b.ts +3 -0
- package/tests/fixtures/scanFilesApis/a.ts +3 -0
- package/tests/fixtures/scanFilesApis/sub/b.ts +3 -0
- package/tests/loadPlugins-order-smoke.test.ts +75 -0
- package/tests/logger.test.ts +6 -6
- package/tests/redisHelper.test.ts +6 -1
- package/tests/scanFiles-routePath.test.ts +46 -0
- package/tests/smoke-sql.test.ts +24 -0
- package/tests/sqlBuilder-advanced.test.ts +18 -5
- package/tests/sqlBuilder.test.ts +24 -0
- package/tests/sync-init-guard.test.ts +105 -0
- package/tests/syncApi-insBatch-fields-consistent.test.ts +61 -0
- package/tests/syncApi-obsolete-records.test.ts +69 -0
- package/tests/syncApi-type-compat.test.ts +72 -0
- package/tests/syncDev-permissions.test.ts +81 -0
- package/tests/syncMenu-disableMenus-hard-delete.test.ts +88 -0
- package/tests/syncMenu-duplicate-path.test.ts +122 -0
- package/tests/syncMenu-obsolete-records.test.ts +161 -0
- package/tests/syncMenu-parentPath-from-tree.test.ts +75 -0
- package/tests/syncMenu-paths.test.ts +0 -9
- package/tests/{syncDb-apply.test.ts → syncTable-apply.test.ts} +14 -24
- package/tests/{syncDb-array-number.test.ts → syncTable-array-number.test.ts} +31 -31
- package/tests/syncTable-constants.test.ts +101 -0
- package/tests/syncTable-db-integration.test.ts +237 -0
- package/tests/{syncDb-ddl.test.ts → syncTable-ddl.test.ts} +67 -53
- package/tests/{syncDb-helpers.test.ts → syncTable-helpers.test.ts} +12 -26
- package/tests/syncTable-schema.test.ts +99 -0
- package/tests/syncTable-testkit.test.ts +25 -0
- package/tests/syncTable-types.test.ts +122 -0
- package/tests/tableRef-and-deserialize.test.ts +67 -0
- package/tsconfig.json +1 -1
- package/types/api.d.ts +1 -1
- package/types/befly.d.ts +13 -12
- package/types/cache.d.ts +2 -2
- package/types/context.d.ts +1 -1
- package/types/database.d.ts +0 -5
- package/types/hook.d.ts +1 -10
- package/types/plugin.d.ts +2 -96
- package/types/sync.d.ts +19 -25
- package/utils/convertBigIntFields.ts +38 -0
- package/utils/disableMenusGlob.ts +85 -0
- package/utils/importDefault.ts +21 -0
- package/utils/isDirentDirectory.ts +23 -0
- package/utils/loadMenuConfigs.ts +145 -0
- package/utils/processFields.ts +25 -0
- package/utils/scanAddons.ts +72 -0
- package/utils/scanFiles.ts +129 -21
- package/utils/scanSources.ts +64 -0
- package/utils/sortModules.ts +137 -0
- package/checks/checkApp.ts +0 -55
- package/hooks/rateLimit.ts +0 -276
- package/sync/syncAll.ts +0 -35
- package/sync/syncDb/apply.ts +0 -192
- package/sync/syncDb/constants.ts +0 -119
- package/sync/syncDb/ddl.ts +0 -251
- package/sync/syncDb/helpers.ts +0 -84
- package/sync/syncDb/schema.ts +0 -202
- package/sync/syncDb/sqlite.ts +0 -48
- package/sync/syncDb/table.ts +0 -207
- package/sync/syncDb/tableCreate.ts +0 -163
- package/sync/syncDb/types.ts +0 -132
- package/sync/syncDb/version.ts +0 -69
- package/sync/syncDb.ts +0 -168
- package/tests/rateLimit-hook.test.ts +0 -477
- package/tests/syncDb-constants.test.ts +0 -130
- package/tests/syncDb-schema.test.ts +0 -179
- package/tests/syncDb-types.test.ts +0 -139
- package/utils/addonHelper.ts +0 -90
- package/utils/modules.ts +0 -98
- package/utils/route.ts +0 -23
package/lib/dbHelper.ts
CHANGED
|
@@ -3,83 +3,56 @@
|
|
|
3
3
|
* 提供数据库 CRUD 操作的封装
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
|
-
import type { BeflyContext } from "../types/befly.js";
|
|
7
6
|
import type { WhereConditions, JoinOption } from "../types/common.js";
|
|
8
7
|
import type { QueryOptions, InsertOptions, UpdateOptions, DeleteOptions, ListResult, AllResult, TransactionCallback } from "../types/database.js";
|
|
8
|
+
import type { DbDialect } from "./dbDialect.js";
|
|
9
9
|
|
|
10
10
|
import { snakeCase } from "es-toolkit/string";
|
|
11
11
|
|
|
12
12
|
import { arrayKeysToCamel } from "../utils/arrayKeysToCamel.js";
|
|
13
|
+
import { convertBigIntFields } from "../utils/convertBigIntFields.js";
|
|
13
14
|
import { fieldClear } from "../utils/fieldClear.js";
|
|
14
15
|
import { keysToCamel } from "../utils/keysToCamel.js";
|
|
15
|
-
import { keysToSnake } from "../utils/keysToSnake.js";
|
|
16
16
|
import { CacheKeys } from "./cacheKeys.js";
|
|
17
|
+
import { MySqlDialect } from "./dbDialect.js";
|
|
18
|
+
import { DbUtils } from "./dbUtils.js";
|
|
17
19
|
import { Logger } from "./logger.js";
|
|
18
20
|
import { SqlBuilder } from "./sqlBuilder.js";
|
|
21
|
+
import { SqlCheck } from "./sqlCheck.js";
|
|
19
22
|
|
|
20
23
|
const TABLE_COLUMNS_CACHE_TTL_SECONDS = 3600;
|
|
21
24
|
|
|
25
|
+
type RedisCacheLike = {
|
|
26
|
+
getObject<T = any>(key: string): Promise<T | null>;
|
|
27
|
+
setObject<T = any>(key: string, obj: T, ttl?: number | null): Promise<string | null>;
|
|
28
|
+
genTimeID(): Promise<number>;
|
|
29
|
+
};
|
|
30
|
+
|
|
22
31
|
/**
|
|
23
32
|
* 数据库助手类
|
|
24
33
|
*/
|
|
25
34
|
export class DbHelper {
|
|
26
|
-
private
|
|
35
|
+
private redis: RedisCacheLike;
|
|
36
|
+
private dialect: DbDialect;
|
|
27
37
|
private sql: any = null;
|
|
28
38
|
private isTransaction: boolean = false;
|
|
29
39
|
|
|
30
40
|
/**
|
|
31
41
|
* 构造函数
|
|
32
|
-
* @param
|
|
42
|
+
* @param redis - Redis 实例
|
|
33
43
|
* @param sql - Bun SQL 客户端(可选,用于事务)
|
|
34
44
|
*/
|
|
35
|
-
constructor(
|
|
36
|
-
this.
|
|
37
|
-
this.sql = sql;
|
|
38
|
-
this.isTransaction = !!sql;
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
/**
|
|
42
|
-
* 验证 fields 格式并分类
|
|
43
|
-
* @returns { type: 'all' | 'include' | 'exclude', fields: string[] }
|
|
44
|
-
* @throws 如果 fields 格式非法
|
|
45
|
-
*/
|
|
46
|
-
private validateAndClassifyFields(fields?: string[]): {
|
|
47
|
-
type: "all" | "include" | "exclude";
|
|
48
|
-
fields: string[];
|
|
49
|
-
} {
|
|
50
|
-
// 情况1:空数组或 undefined,表示查询所有
|
|
51
|
-
if (!fields || fields.length === 0) {
|
|
52
|
-
return { type: "all", fields: [] };
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
// 检测是否有星号(禁止)
|
|
56
|
-
if (fields.some((f) => f === "*")) {
|
|
57
|
-
throw new Error("fields 不支持 * 星号,请使用空数组 [] 或不传参数表示查询所有字段");
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
// 检测是否有空字符串或无效值
|
|
61
|
-
if (fields.some((f) => !f || typeof f !== "string" || f.trim() === "")) {
|
|
62
|
-
throw new Error("fields 不能包含空字符串或无效值");
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
// 统计包含字段和排除字段
|
|
66
|
-
const includeFields = fields.filter((f) => !f.startsWith("!"));
|
|
67
|
-
const excludeFields = fields.filter((f) => f.startsWith("!"));
|
|
68
|
-
|
|
69
|
-
// 情况2:全部是包含字段
|
|
70
|
-
if (includeFields.length > 0 && excludeFields.length === 0) {
|
|
71
|
-
return { type: "include", fields: includeFields };
|
|
72
|
-
}
|
|
45
|
+
constructor(options: { redis: RedisCacheLike; sql?: any | null; dialect?: DbDialect }) {
|
|
46
|
+
this.redis = options.redis;
|
|
47
|
+
this.sql = options.sql || null;
|
|
48
|
+
this.isTransaction = !!options.sql;
|
|
73
49
|
|
|
74
|
-
//
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
const cleanExcludeFields = excludeFields.map((f) => f.substring(1));
|
|
78
|
-
return { type: "exclude", fields: cleanExcludeFields };
|
|
79
|
-
}
|
|
50
|
+
// 默认使用 MySQL 方言(当前 core 的表结构/语法也主要基于 MySQL)
|
|
51
|
+
this.dialect = options.dialect ? options.dialect : new MySqlDialect();
|
|
52
|
+
}
|
|
80
53
|
|
|
81
|
-
|
|
82
|
-
|
|
54
|
+
private createSqlBuilder(): SqlBuilder {
|
|
55
|
+
return new SqlBuilder({ quoteIdent: this.dialect.quoteIdent.bind(this.dialect) });
|
|
83
56
|
}
|
|
84
57
|
|
|
85
58
|
/**
|
|
@@ -90,226 +63,68 @@ export class DbHelper {
|
|
|
90
63
|
private async getTableColumns(table: string): Promise<string[]> {
|
|
91
64
|
// 1. 先查 Redis 缓存
|
|
92
65
|
const cacheKey = CacheKeys.tableColumns(table);
|
|
93
|
-
const columns = await this.
|
|
66
|
+
const columns = await this.redis.getObject<string[]>(cacheKey);
|
|
94
67
|
|
|
95
68
|
if (columns && columns.length > 0) {
|
|
96
69
|
return columns;
|
|
97
70
|
}
|
|
98
71
|
|
|
99
72
|
// 2. 缓存未命中,查询数据库
|
|
100
|
-
const
|
|
101
|
-
const result = await this.executeWithConn(sql);
|
|
73
|
+
const query = this.dialect.getTableColumnsQuery(table);
|
|
74
|
+
const result = await this.executeWithConn(query.sql, query.params);
|
|
102
75
|
|
|
103
76
|
if (!result || result.length === 0) {
|
|
104
77
|
throw new Error(`表 ${table} 不存在或没有字段`);
|
|
105
78
|
}
|
|
106
79
|
|
|
107
|
-
const columnNames =
|
|
80
|
+
const columnNames = this.dialect.getTableColumnsFromResult(result);
|
|
108
81
|
|
|
109
82
|
// 3. 写入 Redis 缓存
|
|
110
|
-
await this.
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
}
|
|
114
|
-
|
|
115
|
-
/**
|
|
116
|
-
* 字段数组转下划线格式(私有方法)
|
|
117
|
-
* 支持排除字段语法
|
|
118
|
-
*/
|
|
119
|
-
private async fieldsToSnake(table: string, fields: string[]): Promise<string[]> {
|
|
120
|
-
if (!fields || !Array.isArray(fields)) return ["*"];
|
|
121
|
-
|
|
122
|
-
// 验证并分类字段
|
|
123
|
-
const { type, fields: classifiedFields } = this.validateAndClassifyFields(fields);
|
|
124
|
-
|
|
125
|
-
// 情况1:查询所有字段
|
|
126
|
-
if (type === "all") {
|
|
127
|
-
return ["*"];
|
|
128
|
-
}
|
|
129
|
-
|
|
130
|
-
// 情况2:指定包含字段
|
|
131
|
-
if (type === "include") {
|
|
132
|
-
return classifiedFields.map((field) => {
|
|
133
|
-
// 保留函数和特殊字段
|
|
134
|
-
if (field.includes("(") || field.includes(" ")) {
|
|
135
|
-
return field;
|
|
136
|
-
}
|
|
137
|
-
return snakeCase(field);
|
|
138
|
-
});
|
|
139
|
-
}
|
|
140
|
-
|
|
141
|
-
// 情况3:排除字段
|
|
142
|
-
if (type === "exclude") {
|
|
143
|
-
// 获取表的所有字段
|
|
144
|
-
const allColumns = await this.getTableColumns(table);
|
|
145
|
-
|
|
146
|
-
// 转换排除字段为下划线格式
|
|
147
|
-
const excludeSnakeFields = classifiedFields.map((f) => snakeCase(f));
|
|
148
|
-
|
|
149
|
-
// 过滤掉排除字段
|
|
150
|
-
const resultFields = allColumns.filter((col) => !excludeSnakeFields.includes(col));
|
|
151
|
-
|
|
152
|
-
if (resultFields.length === 0) {
|
|
153
|
-
throw new Error(`排除字段后没有剩余字段可查询。表: ${table}, 排除: ${excludeSnakeFields.join(", ")}`);
|
|
154
|
-
}
|
|
155
|
-
|
|
156
|
-
return resultFields;
|
|
157
|
-
}
|
|
158
|
-
|
|
159
|
-
return ["*"];
|
|
160
|
-
}
|
|
161
|
-
|
|
162
|
-
/**
|
|
163
|
-
* orderBy 数组转下划线格式(私有方法)
|
|
164
|
-
*/
|
|
165
|
-
private orderByToSnake(orderBy: string[]): string[] {
|
|
166
|
-
if (!orderBy || !Array.isArray(orderBy)) return orderBy;
|
|
167
|
-
return orderBy.map((item) => {
|
|
168
|
-
if (typeof item !== "string" || !item.includes("#")) return item;
|
|
169
|
-
const [field, direction] = item.split("#");
|
|
170
|
-
return `${snakeCase(field.trim())}#${direction.trim()}`;
|
|
171
|
-
});
|
|
172
|
-
}
|
|
173
|
-
|
|
174
|
-
/**
|
|
175
|
-
* 处理表名(转下划线格式)
|
|
176
|
-
* 'userProfile' -> 'user_profile'
|
|
177
|
-
*/
|
|
178
|
-
private processTableName(table: string): string {
|
|
179
|
-
return snakeCase(table.trim());
|
|
180
|
-
}
|
|
181
|
-
|
|
182
|
-
/**
|
|
183
|
-
* 处理联查字段(支持表名.字段名格式)
|
|
184
|
-
* 'user.userId' -> 'user.user_id'
|
|
185
|
-
* 'username' -> 'user_name'
|
|
186
|
-
*/
|
|
187
|
-
private processJoinField(field: string): string {
|
|
188
|
-
// 跳过函数、星号、已处理的字段
|
|
189
|
-
if (field.includes("(") || field === "*" || field.startsWith("`")) {
|
|
190
|
-
return field;
|
|
191
|
-
}
|
|
192
|
-
|
|
193
|
-
// 处理别名 AS
|
|
194
|
-
if (field.toUpperCase().includes(" AS ")) {
|
|
195
|
-
const [fieldPart, aliasPart] = field.split(/\s+AS\s+/i);
|
|
196
|
-
return `${this.processJoinField(fieldPart.trim())} AS ${aliasPart.trim()}`;
|
|
83
|
+
const cacheRes = await this.redis.setObject(cacheKey, columnNames, TABLE_COLUMNS_CACHE_TTL_SECONDS);
|
|
84
|
+
if (cacheRes === null) {
|
|
85
|
+
Logger.warn({ table: table, cacheKey: cacheKey }, "表字段缓存写入 Redis 失败");
|
|
197
86
|
}
|
|
198
87
|
|
|
199
|
-
|
|
200
|
-
if (field.includes(".")) {
|
|
201
|
-
const [tableName, fieldName] = field.split(".");
|
|
202
|
-
return `${snakeCase(tableName)}.${snakeCase(fieldName)}`;
|
|
203
|
-
}
|
|
204
|
-
|
|
205
|
-
// 普通字段
|
|
206
|
-
return snakeCase(field);
|
|
207
|
-
}
|
|
208
|
-
|
|
209
|
-
/**
|
|
210
|
-
* 处理联查的 where 条件键名
|
|
211
|
-
* 'user.userId': 1 -> 'user.user_id': 1
|
|
212
|
-
* 'user.status$in': [...] -> 'user.status$in': [...]
|
|
213
|
-
*/
|
|
214
|
-
private processJoinWhereKey(key: string): string {
|
|
215
|
-
// 保留逻辑操作符
|
|
216
|
-
if (key === "$or" || key === "$and") {
|
|
217
|
-
return key;
|
|
218
|
-
}
|
|
219
|
-
|
|
220
|
-
// 处理带操作符的字段名(如 user.userId$gt)
|
|
221
|
-
if (key.includes("$")) {
|
|
222
|
-
const lastDollarIndex = key.lastIndexOf("$");
|
|
223
|
-
const fieldPart = key.substring(0, lastDollarIndex);
|
|
224
|
-
const operator = key.substring(lastDollarIndex);
|
|
225
|
-
|
|
226
|
-
if (fieldPart.includes(".")) {
|
|
227
|
-
const [tableName, fieldName] = fieldPart.split(".");
|
|
228
|
-
return `${snakeCase(tableName)}.${snakeCase(fieldName)}${operator}`;
|
|
229
|
-
}
|
|
230
|
-
return `${snakeCase(fieldPart)}${operator}`;
|
|
231
|
-
}
|
|
232
|
-
|
|
233
|
-
// 处理表名.字段名
|
|
234
|
-
if (key.includes(".")) {
|
|
235
|
-
const [tableName, fieldName] = key.split(".");
|
|
236
|
-
return `${snakeCase(tableName)}.${snakeCase(fieldName)}`;
|
|
237
|
-
}
|
|
238
|
-
|
|
239
|
-
// 普通字段
|
|
240
|
-
return snakeCase(key);
|
|
241
|
-
}
|
|
242
|
-
|
|
243
|
-
/**
|
|
244
|
-
* 递归处理联查的 where 条件
|
|
245
|
-
*/
|
|
246
|
-
private processJoinWhere(where: any): any {
|
|
247
|
-
if (!where || typeof where !== "object") return where;
|
|
248
|
-
|
|
249
|
-
if (Array.isArray(where)) {
|
|
250
|
-
return where.map((item) => this.processJoinWhere(item));
|
|
251
|
-
}
|
|
252
|
-
|
|
253
|
-
const result: any = {};
|
|
254
|
-
for (const [key, value] of Object.entries(where)) {
|
|
255
|
-
const newKey = this.processJoinWhereKey(key);
|
|
256
|
-
|
|
257
|
-
if (key === "$or" || key === "$and") {
|
|
258
|
-
result[newKey] = (value as any[]).map((item) => this.processJoinWhere(item));
|
|
259
|
-
} else if (typeof value === "object" && value !== null && !Array.isArray(value)) {
|
|
260
|
-
result[newKey] = this.processJoinWhere(value);
|
|
261
|
-
} else {
|
|
262
|
-
result[newKey] = value;
|
|
263
|
-
}
|
|
264
|
-
}
|
|
265
|
-
return result;
|
|
266
|
-
}
|
|
267
|
-
|
|
268
|
-
/**
|
|
269
|
-
* 处理联查的 orderBy
|
|
270
|
-
* 'o.createdAt#DESC' -> 'o.created_at#DESC'
|
|
271
|
-
*/
|
|
272
|
-
private processJoinOrderBy(orderBy: string[]): string[] {
|
|
273
|
-
if (!orderBy || !Array.isArray(orderBy)) return orderBy;
|
|
274
|
-
return orderBy.map((item) => {
|
|
275
|
-
if (typeof item !== "string" || !item.includes("#")) return item;
|
|
276
|
-
const [field, direction] = item.split("#");
|
|
277
|
-
return `${this.processJoinField(field.trim())}#${direction.trim()}`;
|
|
278
|
-
});
|
|
88
|
+
return columnNames;
|
|
279
89
|
}
|
|
280
90
|
|
|
281
91
|
/**
|
|
282
92
|
* 统一的查询参数预处理方法
|
|
283
93
|
*/
|
|
284
94
|
private async prepareQueryOptions(options: QueryOptions) {
|
|
285
|
-
const cleanWhere =
|
|
95
|
+
const cleanWhere = fieldClear(options.where || {}, { excludeValues: [null, undefined] });
|
|
286
96
|
const hasJoins = options.joins && options.joins.length > 0;
|
|
287
97
|
|
|
288
98
|
// 联查时使用特殊处理逻辑
|
|
289
99
|
if (hasJoins) {
|
|
290
100
|
// 联查时字段直接处理(支持表名.字段名格式)
|
|
291
|
-
const processedFields = (options.fields || []).map((f) =>
|
|
101
|
+
const processedFields = (options.fields || []).map((f) => DbUtils.processJoinField(f));
|
|
102
|
+
|
|
103
|
+
const normalizedTableRef = DbUtils.normalizeTableRef(options.table);
|
|
104
|
+
const mainQualifier = DbUtils.getJoinMainQualifier(options.table);
|
|
292
105
|
|
|
293
106
|
return {
|
|
294
|
-
table:
|
|
107
|
+
table: normalizedTableRef,
|
|
108
|
+
tableQualifier: mainQualifier,
|
|
295
109
|
fields: processedFields.length > 0 ? processedFields : ["*"],
|
|
296
|
-
where:
|
|
110
|
+
where: DbUtils.processJoinWhere(cleanWhere),
|
|
297
111
|
joins: options.joins,
|
|
298
|
-
orderBy:
|
|
112
|
+
orderBy: DbUtils.processJoinOrderBy(options.orderBy || []),
|
|
299
113
|
page: options.page || 1,
|
|
300
114
|
limit: options.limit || 10
|
|
301
115
|
};
|
|
302
116
|
}
|
|
303
117
|
|
|
304
118
|
// 单表查询使用原有逻辑
|
|
305
|
-
const processedFields = await
|
|
119
|
+
const processedFields = await DbUtils.fieldsToSnake(snakeCase(options.table), options.fields || [], this.getTableColumns.bind(this));
|
|
306
120
|
|
|
307
121
|
return {
|
|
308
122
|
table: snakeCase(options.table),
|
|
123
|
+
tableQualifier: snakeCase(options.table),
|
|
309
124
|
fields: processedFields,
|
|
310
|
-
where:
|
|
125
|
+
where: DbUtils.whereKeysToSnake(cleanWhere),
|
|
311
126
|
joins: undefined,
|
|
312
|
-
orderBy:
|
|
127
|
+
orderBy: DbUtils.orderByToSnake(options.orderBy || []),
|
|
313
128
|
page: options.page || 1,
|
|
314
129
|
limit: options.limit || 10
|
|
315
130
|
};
|
|
@@ -322,7 +137,7 @@ export class DbHelper {
|
|
|
322
137
|
if (!joins || joins.length === 0) return;
|
|
323
138
|
|
|
324
139
|
for (const join of joins) {
|
|
325
|
-
const processedTable =
|
|
140
|
+
const processedTable = DbUtils.normalizeTableRef(join.table);
|
|
326
141
|
const type = join.type || "left";
|
|
327
142
|
|
|
328
143
|
switch (type) {
|
|
@@ -339,180 +154,6 @@ export class DbHelper {
|
|
|
339
154
|
}
|
|
340
155
|
}
|
|
341
156
|
}
|
|
342
|
-
|
|
343
|
-
/**
|
|
344
|
-
* 添加默认的 state 过滤条件
|
|
345
|
-
* 默认查询 state > 0 的数据(排除已删除和特殊状态)
|
|
346
|
-
* @param where - where 条件
|
|
347
|
-
* @param table - 主表名(JOIN 查询时需要,用于添加表名前缀避免歧义)
|
|
348
|
-
* @param hasJoins - 是否有 JOIN 查询
|
|
349
|
-
*/
|
|
350
|
-
private addDefaultStateFilter(where: WhereConditions = {}, table?: string, hasJoins: boolean = false): WhereConditions {
|
|
351
|
-
// 如果用户已经指定了 state 条件,优先使用用户的条件
|
|
352
|
-
const hasStateCondition = Object.keys(where).some((key) => key.startsWith("state") || key.includes(".state"));
|
|
353
|
-
|
|
354
|
-
if (hasStateCondition) {
|
|
355
|
-
return where;
|
|
356
|
-
}
|
|
357
|
-
|
|
358
|
-
// JOIN 查询时需要指定主表名前缀避免歧义
|
|
359
|
-
if (hasJoins && table) {
|
|
360
|
-
return {
|
|
361
|
-
...where,
|
|
362
|
-
[`${table}.state$gt`]: 0
|
|
363
|
-
};
|
|
364
|
-
}
|
|
365
|
-
|
|
366
|
-
// 默认查询 state > 0 的数据
|
|
367
|
-
return {
|
|
368
|
-
...where,
|
|
369
|
-
state$gt: 0
|
|
370
|
-
};
|
|
371
|
-
}
|
|
372
|
-
|
|
373
|
-
/**
|
|
374
|
-
* 清理数据或 where 条件(默认排除 null 和 undefined)
|
|
375
|
-
*/
|
|
376
|
-
public cleanFields<T extends Record<string, any>>(data: T, excludeValues: any[] = [null, undefined], keepValues: Record<string, any> = {}): Partial<T> {
|
|
377
|
-
return fieldClear(data || ({} as T), { excludeValues, keepMap: keepValues });
|
|
378
|
-
}
|
|
379
|
-
|
|
380
|
-
/**
|
|
381
|
-
* 转换数据库 BIGINT 字段为数字类型(私有方法)
|
|
382
|
-
* 当 bigint: false 时,Bun SQL 会将大于 u32 的 BIGINT 返回为字符串,此方法将其转换为 number
|
|
383
|
-
*
|
|
384
|
-
* 转换规则:
|
|
385
|
-
* 1. 白名单中的字段会被转换
|
|
386
|
-
* 2. 所有以 'Id' 或 '_id' 结尾的字段会被自动转换
|
|
387
|
-
* 3. 所有以 'At' 或 '_at' 结尾的字段会被自动转换(时间戳字段)
|
|
388
|
-
* 4. 其他字段保持不变
|
|
389
|
-
*/
|
|
390
|
-
private convertBigIntFields<T = any>(arr: Record<string, any>[], fields: string[] = ["id", "pid", "sort"]): T[] {
|
|
391
|
-
if (!arr || !Array.isArray(arr)) return arr as T[];
|
|
392
|
-
|
|
393
|
-
return arr.map((item) => {
|
|
394
|
-
const converted = { ...item };
|
|
395
|
-
|
|
396
|
-
// 遍历对象的所有字段
|
|
397
|
-
for (const [key, value] of Object.entries(converted)) {
|
|
398
|
-
// 跳过 undefined 和 null
|
|
399
|
-
if (value === undefined || value === null) {
|
|
400
|
-
continue;
|
|
401
|
-
}
|
|
402
|
-
|
|
403
|
-
// 判断是否需要转换:
|
|
404
|
-
// 1. 在白名单中
|
|
405
|
-
// 2. 以 'Id' 结尾(如 userId, roleId, categoryId)
|
|
406
|
-
// 3. 以 '_id' 结尾(如 user_id, role_id)
|
|
407
|
-
// 4. 以 'At' 结尾(如 createdAt, updatedAt)
|
|
408
|
-
// 5. 以 '_at' 结尾(如 created_at, updated_at)
|
|
409
|
-
const shouldConvert = fields.includes(key) || key.endsWith("Id") || key.endsWith("_id") || key.endsWith("At") || key.endsWith("_at");
|
|
410
|
-
|
|
411
|
-
if (shouldConvert && typeof value === "string") {
|
|
412
|
-
const num = Number(value);
|
|
413
|
-
if (!isNaN(num)) {
|
|
414
|
-
converted[key] = num;
|
|
415
|
-
}
|
|
416
|
-
}
|
|
417
|
-
// number 类型保持不变(小于 u32 的值)
|
|
418
|
-
}
|
|
419
|
-
|
|
420
|
-
return converted as T;
|
|
421
|
-
}) as T[];
|
|
422
|
-
}
|
|
423
|
-
|
|
424
|
-
/**
|
|
425
|
-
* 序列化数组字段(写入数据库前)
|
|
426
|
-
* 将数组类型的字段转换为 JSON 字符串
|
|
427
|
-
*/
|
|
428
|
-
private serializeArrayFields(data: Record<string, any>): Record<string, any> {
|
|
429
|
-
const serialized = { ...data };
|
|
430
|
-
|
|
431
|
-
for (const [key, value] of Object.entries(serialized)) {
|
|
432
|
-
// 跳过 null 和 undefined
|
|
433
|
-
if (value === null || value === undefined) continue;
|
|
434
|
-
|
|
435
|
-
// 数组类型序列化为 JSON 字符串
|
|
436
|
-
if (Array.isArray(value)) {
|
|
437
|
-
serialized[key] = JSON.stringify(value);
|
|
438
|
-
}
|
|
439
|
-
}
|
|
440
|
-
|
|
441
|
-
return serialized;
|
|
442
|
-
}
|
|
443
|
-
|
|
444
|
-
/**
|
|
445
|
-
* 反序列化数组字段(从数据库读取后)
|
|
446
|
-
* 将 JSON 字符串转换回数组
|
|
447
|
-
*/
|
|
448
|
-
private deserializeArrayFields<T = any>(data: Record<string, any> | null): T | null {
|
|
449
|
-
if (!data) return null;
|
|
450
|
-
|
|
451
|
-
const deserialized = { ...data };
|
|
452
|
-
|
|
453
|
-
for (const [key, value] of Object.entries(deserialized)) {
|
|
454
|
-
// 跳过非字符串值
|
|
455
|
-
if (typeof value !== "string") continue;
|
|
456
|
-
|
|
457
|
-
// 尝试解析 JSON 数组字符串
|
|
458
|
-
// 只解析符合 JSON 数组格式的字符串(以 [ 开头,以 ] 结尾)
|
|
459
|
-
if (value.startsWith("[") && value.endsWith("]")) {
|
|
460
|
-
try {
|
|
461
|
-
const parsed = JSON.parse(value);
|
|
462
|
-
if (Array.isArray(parsed)) {
|
|
463
|
-
deserialized[key] = parsed;
|
|
464
|
-
}
|
|
465
|
-
} catch {
|
|
466
|
-
// 解析失败则保持原值
|
|
467
|
-
}
|
|
468
|
-
}
|
|
469
|
-
}
|
|
470
|
-
|
|
471
|
-
return deserialized as T;
|
|
472
|
-
}
|
|
473
|
-
|
|
474
|
-
/**
|
|
475
|
-
* Where 条件键名转下划线格式(递归处理嵌套)(私有方法)
|
|
476
|
-
* 支持操作符字段(如 userId$gt)和逻辑操作符($or, $and)
|
|
477
|
-
*/
|
|
478
|
-
private whereKeysToSnake(where: any): any {
|
|
479
|
-
if (!where || typeof where !== "object") return where;
|
|
480
|
-
|
|
481
|
-
// 处理数组($or, $and 等)
|
|
482
|
-
if (Array.isArray(where)) {
|
|
483
|
-
return where.map((item) => this.whereKeysToSnake(item));
|
|
484
|
-
}
|
|
485
|
-
|
|
486
|
-
const result: any = {};
|
|
487
|
-
for (const [key, value] of Object.entries(where)) {
|
|
488
|
-
// 保留 $or, $and 等逻辑操作符
|
|
489
|
-
if (key === "$or" || key === "$and") {
|
|
490
|
-
result[key] = (value as any[]).map((item) => this.whereKeysToSnake(item));
|
|
491
|
-
continue;
|
|
492
|
-
}
|
|
493
|
-
|
|
494
|
-
// 处理带操作符的字段名(如 userId$gt)
|
|
495
|
-
if (key.includes("$")) {
|
|
496
|
-
const lastDollarIndex = key.lastIndexOf("$");
|
|
497
|
-
const fieldName = key.substring(0, lastDollarIndex);
|
|
498
|
-
const operator = key.substring(lastDollarIndex);
|
|
499
|
-
const snakeKey = snakeCase(fieldName) + operator;
|
|
500
|
-
result[snakeKey] = value;
|
|
501
|
-
continue;
|
|
502
|
-
}
|
|
503
|
-
|
|
504
|
-
// 普通字段:转换键名,递归处理值(支持嵌套对象)
|
|
505
|
-
const snakeKey = snakeCase(key);
|
|
506
|
-
if (typeof value === "object" && value !== null && !Array.isArray(value)) {
|
|
507
|
-
result[snakeKey] = this.whereKeysToSnake(value);
|
|
508
|
-
} else {
|
|
509
|
-
result[snakeKey] = value;
|
|
510
|
-
}
|
|
511
|
-
}
|
|
512
|
-
|
|
513
|
-
return result;
|
|
514
|
-
}
|
|
515
|
-
|
|
516
157
|
/**
|
|
517
158
|
* 执行 SQL(使用 sql.unsafe,带慢查询日志和错误处理)
|
|
518
159
|
*/
|
|
@@ -580,6 +221,16 @@ export class DbHelper {
|
|
|
580
221
|
}
|
|
581
222
|
}
|
|
582
223
|
|
|
224
|
+
/**
|
|
225
|
+
* 执行原生 SQL(内部工具/同步脚本专用)
|
|
226
|
+
*
|
|
227
|
+
* - 复用当前 DbHelper 持有的连接/事务
|
|
228
|
+
* - 统一走 executeWithConn,保持参数校验与错误行为一致
|
|
229
|
+
*/
|
|
230
|
+
public async unsafe(sqlStr: string, params?: any[]): Promise<any> {
|
|
231
|
+
return await this.executeWithConn(sqlStr, params);
|
|
232
|
+
}
|
|
233
|
+
|
|
583
234
|
/**
|
|
584
235
|
* 检查表是否存在
|
|
585
236
|
* @param tableName - 表名(支持小驼峰,会自动转换为下划线)
|
|
@@ -589,7 +240,8 @@ export class DbHelper {
|
|
|
589
240
|
// 将表名转换为下划线格式
|
|
590
241
|
const snakeTableName = snakeCase(tableName);
|
|
591
242
|
|
|
592
|
-
const
|
|
243
|
+
const query = this.dialect.tableExistsQuery(snakeTableName);
|
|
244
|
+
const result = await this.executeWithConn(query.sql, query.params);
|
|
593
245
|
|
|
594
246
|
return result?.[0]?.count > 0;
|
|
595
247
|
}
|
|
@@ -612,12 +264,12 @@ export class DbHelper {
|
|
|
612
264
|
* });
|
|
613
265
|
*/
|
|
614
266
|
async getCount(options: Omit<QueryOptions, "fields" | "page" | "limit" | "orderBy">): Promise<number> {
|
|
615
|
-
const { table, where, joins } = await this.prepareQueryOptions(options as QueryOptions);
|
|
267
|
+
const { table, where, joins, tableQualifier } = await this.prepareQueryOptions(options as QueryOptions);
|
|
616
268
|
|
|
617
|
-
const builder =
|
|
618
|
-
.
|
|
269
|
+
const builder = this.createSqlBuilder()
|
|
270
|
+
.selectRaw("COUNT(*) as count")
|
|
619
271
|
.from(table)
|
|
620
|
-
.where(
|
|
272
|
+
.where(DbUtils.addDefaultStateFilter(where, tableQualifier, !!joins));
|
|
621
273
|
|
|
622
274
|
// 添加 JOIN
|
|
623
275
|
this.applyJoins(builder, joins);
|
|
@@ -645,12 +297,12 @@ export class DbHelper {
|
|
|
645
297
|
* })
|
|
646
298
|
*/
|
|
647
299
|
async getOne<T extends Record<string, any> = Record<string, any>>(options: QueryOptions): Promise<T | null> {
|
|
648
|
-
const { table, fields, where, joins } = await this.prepareQueryOptions(options);
|
|
300
|
+
const { table, fields, where, joins, tableQualifier } = await this.prepareQueryOptions(options);
|
|
649
301
|
|
|
650
|
-
const builder =
|
|
302
|
+
const builder = this.createSqlBuilder()
|
|
651
303
|
.select(fields)
|
|
652
304
|
.from(table)
|
|
653
|
-
.where(
|
|
305
|
+
.where(DbUtils.addDefaultStateFilter(where, tableQualifier, !!joins));
|
|
654
306
|
|
|
655
307
|
// 添加 JOIN
|
|
656
308
|
this.applyJoins(builder, joins);
|
|
@@ -665,11 +317,11 @@ export class DbHelper {
|
|
|
665
317
|
const camelRow = keysToCamel<T>(row);
|
|
666
318
|
|
|
667
319
|
// 反序列化数组字段(JSON 字符串 → 数组)
|
|
668
|
-
const deserialized =
|
|
320
|
+
const deserialized = DbUtils.deserializeArrayFields<T>(camelRow);
|
|
669
321
|
if (!deserialized) return null;
|
|
670
322
|
|
|
671
323
|
// 转换 BIGINT 字段(id, pid 等)为数字类型
|
|
672
|
-
return
|
|
324
|
+
return convertBigIntFields<T>([deserialized])[0];
|
|
673
325
|
}
|
|
674
326
|
|
|
675
327
|
/**
|
|
@@ -706,10 +358,10 @@ export class DbHelper {
|
|
|
706
358
|
}
|
|
707
359
|
|
|
708
360
|
// 构建查询
|
|
709
|
-
const whereFiltered =
|
|
361
|
+
const whereFiltered = DbUtils.addDefaultStateFilter(prepared.where, prepared.tableQualifier, !!prepared.joins);
|
|
710
362
|
|
|
711
363
|
// 查询总数
|
|
712
|
-
const countBuilder =
|
|
364
|
+
const countBuilder = this.createSqlBuilder().selectRaw("COUNT(*) as total").from(prepared.table).where(whereFiltered);
|
|
713
365
|
|
|
714
366
|
// 添加 JOIN(计数也需要)
|
|
715
367
|
this.applyJoins(countBuilder, prepared.joins);
|
|
@@ -731,7 +383,7 @@ export class DbHelper {
|
|
|
731
383
|
|
|
732
384
|
// 查询数据
|
|
733
385
|
const offset = (prepared.page - 1) * prepared.limit;
|
|
734
|
-
const dataBuilder =
|
|
386
|
+
const dataBuilder = this.createSqlBuilder().select(prepared.fields).from(prepared.table).where(whereFiltered).limit(prepared.limit).offset(offset);
|
|
735
387
|
|
|
736
388
|
// 添加 JOIN
|
|
737
389
|
this.applyJoins(dataBuilder, prepared.joins);
|
|
@@ -748,11 +400,11 @@ export class DbHelper {
|
|
|
748
400
|
const camelList = arrayKeysToCamel<T>(list);
|
|
749
401
|
|
|
750
402
|
// 反序列化数组字段
|
|
751
|
-
const deserializedList = camelList.map((item) =>
|
|
403
|
+
const deserializedList = camelList.map((item) => DbUtils.deserializeArrayFields<T>(item)).filter((item): item is T => item !== null);
|
|
752
404
|
|
|
753
405
|
// 转换 BIGINT 字段(id, pid 等)为数字类型
|
|
754
406
|
return {
|
|
755
|
-
lists:
|
|
407
|
+
lists: convertBigIntFields<T>(deserializedList),
|
|
756
408
|
total: total,
|
|
757
409
|
page: prepared.page,
|
|
758
410
|
limit: prepared.limit,
|
|
@@ -784,10 +436,10 @@ export class DbHelper {
|
|
|
784
436
|
|
|
785
437
|
const prepared = await this.prepareQueryOptions({ ...options, page: 1, limit: 10 });
|
|
786
438
|
|
|
787
|
-
const whereFiltered =
|
|
439
|
+
const whereFiltered = DbUtils.addDefaultStateFilter(prepared.where, prepared.tableQualifier, !!prepared.joins);
|
|
788
440
|
|
|
789
441
|
// 查询真实总数
|
|
790
|
-
const countBuilder =
|
|
442
|
+
const countBuilder = this.createSqlBuilder().selectRaw("COUNT(*) as total").from(prepared.table).where(whereFiltered);
|
|
791
443
|
|
|
792
444
|
// 添加 JOIN(计数也需要)
|
|
793
445
|
this.applyJoins(countBuilder, prepared.joins);
|
|
@@ -805,7 +457,7 @@ export class DbHelper {
|
|
|
805
457
|
}
|
|
806
458
|
|
|
807
459
|
// 查询数据(受上限保护)
|
|
808
|
-
const dataBuilder =
|
|
460
|
+
const dataBuilder = this.createSqlBuilder().select(prepared.fields).from(prepared.table).where(whereFiltered).limit(MAX_LIMIT);
|
|
809
461
|
|
|
810
462
|
// 添加 JOIN
|
|
811
463
|
this.applyJoins(dataBuilder, prepared.joins);
|
|
@@ -831,10 +483,10 @@ export class DbHelper {
|
|
|
831
483
|
const camelResult = arrayKeysToCamel<T>(result);
|
|
832
484
|
|
|
833
485
|
// 反序列化数组字段
|
|
834
|
-
const deserializedList = camelResult.map((item) =>
|
|
486
|
+
const deserializedList = camelResult.map((item) => DbUtils.deserializeArrayFields<T>(item)).filter((item): item is T => item !== null);
|
|
835
487
|
|
|
836
488
|
// 转换 BIGINT 字段(id, pid 等)为数字类型
|
|
837
|
-
const lists =
|
|
489
|
+
const lists = convertBigIntFields<T>(deserializedList);
|
|
838
490
|
|
|
839
491
|
return {
|
|
840
492
|
lists: lists,
|
|
@@ -849,43 +501,24 @@ export class DbHelper {
|
|
|
849
501
|
async insData(options: InsertOptions): Promise<number> {
|
|
850
502
|
const { table, data } = options;
|
|
851
503
|
|
|
852
|
-
// 清理数据(排除 null 和 undefined)
|
|
853
|
-
const cleanData = this.cleanFields(data);
|
|
854
|
-
|
|
855
|
-
// 转换表名:小驼峰 → 下划线
|
|
856
504
|
const snakeTable = snakeCase(table);
|
|
857
505
|
|
|
858
|
-
|
|
859
|
-
// 字段名转换:小驼峰 → 下划线
|
|
860
|
-
const snakeData = keysToSnake(cleanData);
|
|
861
|
-
|
|
862
|
-
// 序列化数组字段(数组 → JSON 字符串)
|
|
863
|
-
const serializedData = this.serializeArrayFields(snakeData);
|
|
864
|
-
|
|
865
|
-
// 复制用户数据,但移除系统字段(防止用户尝试覆盖)
|
|
866
|
-
const { id: _id, created_at: _created_at, updated_at: _updated_at, deleted_at: _deleted_at, state: _state, ...userData } = serializedData;
|
|
867
|
-
|
|
868
|
-
const processed: Record<string, any> = { ...userData };
|
|
506
|
+
const now = Date.now();
|
|
869
507
|
|
|
870
|
-
|
|
508
|
+
let id: number;
|
|
871
509
|
try {
|
|
872
|
-
|
|
510
|
+
id = await this.redis.genTimeID();
|
|
873
511
|
} catch (error: any) {
|
|
874
|
-
throw new Error(`生成 ID 失败,Redis 可能不可用 (table: ${table})`, error);
|
|
512
|
+
throw new Error(`生成 ID 失败,Redis 可能不可用 (table: ${table})`, { cause: error });
|
|
875
513
|
}
|
|
876
514
|
|
|
877
|
-
|
|
878
|
-
const now = Date.now();
|
|
879
|
-
processed.created_at = now;
|
|
880
|
-
processed.updated_at = now;
|
|
881
|
-
|
|
882
|
-
// 强制设置 state 为 1(激活状态,不可被用户覆盖)
|
|
883
|
-
processed.state = 1;
|
|
515
|
+
const processed = DbUtils.buildInsertRow({ data: data, id: id, now: now });
|
|
884
516
|
|
|
885
|
-
//
|
|
517
|
+
// 入口校验:保证进入 SqlBuilder 的数据无 undefined
|
|
518
|
+
SqlCheck.assertNoUndefinedInRecord(processed as any, `insData 插入数据 (table: ${snakeTable})`);
|
|
886
519
|
|
|
887
520
|
// 构建 SQL
|
|
888
|
-
const builder =
|
|
521
|
+
const builder = this.createSqlBuilder();
|
|
889
522
|
const { sql, params } = builder.toInsertSql(snakeTable, processed);
|
|
890
523
|
|
|
891
524
|
// 执行
|
|
@@ -917,36 +550,20 @@ export class DbHelper {
|
|
|
917
550
|
// 批量生成 ID(逐个获取)
|
|
918
551
|
const ids: number[] = [];
|
|
919
552
|
for (let i = 0; i < dataList.length; i++) {
|
|
920
|
-
ids.push(await this.
|
|
553
|
+
ids.push(await this.redis.genTimeID());
|
|
921
554
|
}
|
|
922
555
|
const now = Date.now();
|
|
923
556
|
|
|
924
557
|
// 处理所有数据(自动添加系统字段)
|
|
925
558
|
const processedList = dataList.map((data, index) => {
|
|
926
|
-
|
|
927
|
-
const cleanData = this.cleanFields(data);
|
|
928
|
-
|
|
929
|
-
// 字段名转换:小驼峰 → 下划线
|
|
930
|
-
const snakeData = keysToSnake(cleanData);
|
|
931
|
-
|
|
932
|
-
// 序列化数组字段(数组 → JSON 字符串)
|
|
933
|
-
const serializedData = this.serializeArrayFields(snakeData);
|
|
934
|
-
|
|
935
|
-
// 移除系统字段(防止用户尝试覆盖)
|
|
936
|
-
const { id: _id, created_at: _created_at, updated_at: _updated_at, deleted_at: _deleted_at, state: _state, ...userData } = serializedData;
|
|
937
|
-
|
|
938
|
-
// 强制生成系统字段(不可被用户覆盖)
|
|
939
|
-
return {
|
|
940
|
-
...userData,
|
|
941
|
-
id: ids[index],
|
|
942
|
-
created_at: now,
|
|
943
|
-
updated_at: now,
|
|
944
|
-
state: 1
|
|
945
|
-
};
|
|
559
|
+
return DbUtils.buildInsertRow({ data: data, id: ids[index], now: now });
|
|
946
560
|
});
|
|
947
561
|
|
|
562
|
+
// 入口校验:保证进入 SqlBuilder 的批量数据结构一致且无 undefined
|
|
563
|
+
const insertFields = SqlCheck.assertBatchInsertRowsConsistent(processedList as any, { table: snakeTable });
|
|
564
|
+
|
|
948
565
|
// 构建批量插入 SQL
|
|
949
|
-
const builder =
|
|
566
|
+
const builder = this.createSqlBuilder();
|
|
950
567
|
const { sql, params } = builder.toInsertSql(snakeTable, processedList);
|
|
951
568
|
|
|
952
569
|
// 在事务中执行批量插入
|
|
@@ -954,11 +571,79 @@ export class DbHelper {
|
|
|
954
571
|
await this.executeWithConn(sql, params);
|
|
955
572
|
return ids;
|
|
956
573
|
} catch (error: any) {
|
|
957
|
-
Logger.error(
|
|
574
|
+
Logger.error(
|
|
575
|
+
{
|
|
576
|
+
err: error,
|
|
577
|
+
table: table,
|
|
578
|
+
snakeTable: snakeTable,
|
|
579
|
+
count: dataList.length,
|
|
580
|
+
fields: insertFields
|
|
581
|
+
},
|
|
582
|
+
"批量插入失败"
|
|
583
|
+
);
|
|
958
584
|
throw error;
|
|
959
585
|
}
|
|
960
586
|
}
|
|
961
587
|
|
|
588
|
+
async delForceBatch(table: string, ids: number[]): Promise<number> {
|
|
589
|
+
if (ids.length === 0) {
|
|
590
|
+
return 0;
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
const snakeTable = snakeCase(table);
|
|
594
|
+
|
|
595
|
+
const query = SqlBuilder.toDeleteInSql({
|
|
596
|
+
table: snakeTable,
|
|
597
|
+
idField: "id",
|
|
598
|
+
ids: ids,
|
|
599
|
+
quoteIdent: this.dialect.quoteIdent.bind(this.dialect)
|
|
600
|
+
});
|
|
601
|
+
const result: any = await this.executeWithConn(query.sql, query.params);
|
|
602
|
+
return result?.changes || 0;
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
async updBatch(table: string, dataList: Array<{ id: number; data: Record<string, any> }>): Promise<number> {
|
|
606
|
+
if (dataList.length === 0) {
|
|
607
|
+
return 0;
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
const snakeTable = snakeCase(table);
|
|
611
|
+
const now = Date.now();
|
|
612
|
+
|
|
613
|
+
const processedList: Array<{ id: number; data: Record<string, any> }> = [];
|
|
614
|
+
const fieldSet = new Set<string>();
|
|
615
|
+
|
|
616
|
+
for (const item of dataList) {
|
|
617
|
+
const userData = DbUtils.buildPartialUpdateData({ data: item.data, allowState: true });
|
|
618
|
+
|
|
619
|
+
for (const key of Object.keys(userData)) {
|
|
620
|
+
fieldSet.add(key);
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
processedList.push({ id: item.id, data: userData });
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
const fields = Array.from(fieldSet).sort();
|
|
627
|
+
if (fields.length === 0) {
|
|
628
|
+
return 0;
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
const query = SqlBuilder.toUpdateCaseByIdSql({
|
|
632
|
+
table: snakeTable,
|
|
633
|
+
idField: "id",
|
|
634
|
+
rows: processedList,
|
|
635
|
+
fields: fields,
|
|
636
|
+
quoteIdent: this.dialect.quoteIdent.bind(this.dialect),
|
|
637
|
+
updatedAtField: "updated_at",
|
|
638
|
+
updatedAtValue: now,
|
|
639
|
+
stateField: "state",
|
|
640
|
+
stateGtZero: true
|
|
641
|
+
});
|
|
642
|
+
|
|
643
|
+
const result: any = await this.executeWithConn(query.sql, query.params);
|
|
644
|
+
return result?.changes || 0;
|
|
645
|
+
}
|
|
646
|
+
|
|
962
647
|
/**
|
|
963
648
|
* 更新数据(强制更新时间戳,系统字段不可修改)
|
|
964
649
|
* @param options.table - 表名(支持小驼峰或下划线格式,会自动转换)
|
|
@@ -966,33 +651,18 @@ export class DbHelper {
|
|
|
966
651
|
async updData(options: UpdateOptions): Promise<number> {
|
|
967
652
|
const { table, data, where } = options;
|
|
968
653
|
|
|
969
|
-
//
|
|
970
|
-
const
|
|
971
|
-
const cleanWhere = this.cleanFields(where);
|
|
654
|
+
// 清理条件(排除 null 和 undefined)
|
|
655
|
+
const cleanWhere = fieldClear(where, { excludeValues: [null, undefined] });
|
|
972
656
|
|
|
973
657
|
// 转换表名:小驼峰 → 下划线
|
|
974
658
|
const snakeTable = snakeCase(table);
|
|
659
|
+
const snakeWhere = DbUtils.whereKeysToSnake(cleanWhere);
|
|
975
660
|
|
|
976
|
-
|
|
977
|
-
const snakeData = keysToSnake(cleanData);
|
|
978
|
-
const snakeWhere = this.whereKeysToSnake(cleanWhere);
|
|
979
|
-
|
|
980
|
-
// 序列化数组字段(数组 → JSON 字符串)
|
|
981
|
-
const serializedData = this.serializeArrayFields(snakeData);
|
|
982
|
-
|
|
983
|
-
// 移除系统字段(防止用户尝试修改)
|
|
984
|
-
// 注意:state 允许用户修改(用于设置禁用状态 state=2)
|
|
985
|
-
const { id: _id, created_at: _created_at, updated_at: _updated_at, deleted_at: _deleted_at, ...userData } = serializedData;
|
|
986
|
-
|
|
987
|
-
// 强制更新时间戳(不可被用户覆盖)
|
|
988
|
-
const processed: Record<string, any> = {
|
|
989
|
-
...userData,
|
|
990
|
-
updated_at: Date.now()
|
|
991
|
-
};
|
|
661
|
+
const processed = DbUtils.buildUpdateRow({ data: data, now: Date.now(), allowState: true });
|
|
992
662
|
|
|
993
663
|
// 构建 SQL
|
|
994
|
-
const whereFiltered =
|
|
995
|
-
const builder =
|
|
664
|
+
const whereFiltered = DbUtils.addDefaultStateFilter(snakeWhere, snakeTable, false);
|
|
665
|
+
const builder = this.createSqlBuilder().where(whereFiltered);
|
|
996
666
|
const { sql, params } = builder.toUpdateSql(snakeTable, processed);
|
|
997
667
|
|
|
998
668
|
// 执行
|
|
@@ -1025,11 +695,11 @@ export class DbHelper {
|
|
|
1025
695
|
const snakeTable = snakeCase(table);
|
|
1026
696
|
|
|
1027
697
|
// 清理条件字段
|
|
1028
|
-
const cleanWhere =
|
|
1029
|
-
const snakeWhere =
|
|
698
|
+
const cleanWhere = fieldClear(where, { excludeValues: [null, undefined] });
|
|
699
|
+
const snakeWhere = DbUtils.whereKeysToSnake(cleanWhere);
|
|
1030
700
|
|
|
1031
701
|
// 物理删除
|
|
1032
|
-
const builder =
|
|
702
|
+
const builder = this.createSqlBuilder().where(snakeWhere);
|
|
1033
703
|
const { sql, params } = builder.toDeleteSql(snakeTable);
|
|
1034
704
|
|
|
1035
705
|
const result = await this.executeWithConn(sql, params);
|
|
@@ -1081,7 +751,7 @@ export class DbHelper {
|
|
|
1081
751
|
// 使用 Bun SQL 的 begin 方法开启事务
|
|
1082
752
|
// begin 方法会自动处理 commit/rollback
|
|
1083
753
|
return await this.sql.begin(async (tx: any) => {
|
|
1084
|
-
const trans = new DbHelper(this.
|
|
754
|
+
const trans = new DbHelper({ redis: this.redis, sql: tx, dialect: this.dialect });
|
|
1085
755
|
return await callback(trans);
|
|
1086
756
|
});
|
|
1087
757
|
}
|
|
@@ -1098,13 +768,13 @@ export class DbHelper {
|
|
|
1098
768
|
* @param options.table - 表名(支持小驼峰或下划线格式,会自动转换)
|
|
1099
769
|
*/
|
|
1100
770
|
async exists(options: Omit<QueryOptions, "fields" | "orderBy" | "page" | "limit">): Promise<boolean> {
|
|
1101
|
-
const { table, where } = await this.prepareQueryOptions({ ...options, page: 1, limit: 1 });
|
|
771
|
+
const { table, where, tableQualifier } = await this.prepareQueryOptions({ ...options, page: 1, limit: 1 } as any);
|
|
1102
772
|
|
|
1103
773
|
// 使用 COUNT(1) 性能更好
|
|
1104
|
-
const builder =
|
|
774
|
+
const builder = this.createSqlBuilder()
|
|
1105
775
|
.select(["COUNT(1) as cnt"])
|
|
1106
776
|
.from(table)
|
|
1107
|
-
.where(
|
|
777
|
+
.where(DbUtils.addDefaultStateFilter(where, tableQualifier, false))
|
|
1108
778
|
.limit(1);
|
|
1109
779
|
|
|
1110
780
|
const { sql, params } = builder.toSelectSql();
|
|
@@ -1180,18 +850,20 @@ export class DbHelper {
|
|
|
1180
850
|
}
|
|
1181
851
|
|
|
1182
852
|
// 清理 where 条件(排除 null 和 undefined)
|
|
1183
|
-
const cleanWhere =
|
|
853
|
+
const cleanWhere = fieldClear(where, { excludeValues: [null, undefined] });
|
|
1184
854
|
|
|
1185
855
|
// 转换 where 条件字段名:小驼峰 → 下划线
|
|
1186
|
-
const snakeWhere =
|
|
856
|
+
const snakeWhere = DbUtils.whereKeysToSnake(cleanWhere);
|
|
1187
857
|
|
|
1188
858
|
// 使用 SqlBuilder 构建安全的 WHERE 条件
|
|
1189
|
-
const whereFiltered =
|
|
1190
|
-
const builder =
|
|
859
|
+
const whereFiltered = DbUtils.addDefaultStateFilter(snakeWhere, snakeTable, false);
|
|
860
|
+
const builder = this.createSqlBuilder().where(whereFiltered);
|
|
1191
861
|
const { sql: whereClause, params: whereParams } = builder.getWhereConditions();
|
|
1192
862
|
|
|
1193
863
|
// 构建安全的 UPDATE SQL(表名和字段名使用反引号转义,已经是下划线格式)
|
|
1194
|
-
const
|
|
864
|
+
const quotedTable = this.dialect.quoteIdent(snakeTable);
|
|
865
|
+
const quotedField = this.dialect.quoteIdent(snakeField);
|
|
866
|
+
const sql = whereClause ? `UPDATE ${quotedTable} SET ${quotedField} = ${quotedField} + ? WHERE ${whereClause}` : `UPDATE ${quotedTable} SET ${quotedField} = ${quotedField} + ?`;
|
|
1195
867
|
|
|
1196
868
|
const result = await this.executeWithConn(sql, [value, ...whereParams]);
|
|
1197
869
|
return result?.changes || 0;
|