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/sqlBuilder.ts
CHANGED
|
@@ -5,6 +5,63 @@
|
|
|
5
5
|
|
|
6
6
|
import type { WhereConditions, WhereOperator, OrderDirection, SqlQuery, InsertData, UpdateData, SqlValue } from "../types/common.js";
|
|
7
7
|
|
|
8
|
+
import { SqlCheck } from "./sqlCheck.js";
|
|
9
|
+
|
|
10
|
+
const SqlBuilderError = {
|
|
11
|
+
QUOTE_IDENT_NEED_STRING: (identifier: unknown) => `quoteIdent 需要字符串类型标识符 (identifier: ${String(identifier)})`,
|
|
12
|
+
IDENT_EMPTY: "SQL 标识符不能为空",
|
|
13
|
+
|
|
14
|
+
FIELD_EXPR_NOT_ALLOWED: (field: string) => `字段包含函数/表达式,请使用 selectRaw/whereRaw (field: ${field})`,
|
|
15
|
+
|
|
16
|
+
FROM_EMPTY: "FROM 表名不能为空",
|
|
17
|
+
FROM_NEED_NON_EMPTY: (table: unknown) => `FROM 表名必须是非空字符串 (table: ${String(table)})`,
|
|
18
|
+
FROM_REQUIRED: "FROM 表名是必需的",
|
|
19
|
+
COUNT_NEED_FROM: "COUNT 需要 FROM 表名",
|
|
20
|
+
|
|
21
|
+
TABLE_REF_TOO_MANY_PARTS: (table: string) => `不支持的表引用格式(包含过多片段)。请使用 fromRaw 显式传入复杂表达式 (table: ${table})`,
|
|
22
|
+
TABLE_REF_SCHEMA_TOO_DEEP: (table: string) => `不支持的表引用格式(schema 层级过深)。请使用 fromRaw (table: ${table})`,
|
|
23
|
+
SCHEMA_QUOTE_NOT_PAIRED: (schema: string) => `schema 标识符引用不完整,请使用成对的 \`...\` 或 "..." (schema: ${schema})`,
|
|
24
|
+
TABLE_QUOTE_NOT_PAIRED: (tableName: string) => `table 标识符引用不完整,请使用成对的 \`...\` 或 "..." (table: ${tableName})`,
|
|
25
|
+
|
|
26
|
+
SELECT_FIELDS_INVALID: "SELECT 字段必须是字符串或数组",
|
|
27
|
+
SELECT_RAW_NEED_NON_EMPTY: (expr: unknown) => `selectRaw 需要非空字符串 (expr: ${String(expr)})`,
|
|
28
|
+
FROM_RAW_NEED_NON_EMPTY: (tableExpr: unknown) => `fromRaw 需要非空字符串 (tableExpr: ${String(tableExpr)})`,
|
|
29
|
+
WHERE_RAW_NEED_NON_EMPTY: (sql: unknown) => `whereRaw 需要非空字符串 (sql: ${String(sql)})`,
|
|
30
|
+
WHERE_VALUE_REQUIRED: "where(field, value) 不允许省略 value。若需传入原始 WHERE,请使用 whereRaw",
|
|
31
|
+
|
|
32
|
+
JOIN_NEED_STRING: (table: unknown, on: unknown) => `JOIN 表名和条件必须是字符串 (table: ${String(table)}, on: ${String(on)})`,
|
|
33
|
+
|
|
34
|
+
ORDER_BY_NEED_ARRAY: 'orderBy 必须是字符串数组,格式为 "字段#方向"',
|
|
35
|
+
ORDER_BY_ITEM_NEED_HASH: (item: unknown) => `orderBy 字段必须是 "字段#方向" 格式的字符串(例如:"name#ASC", "id#DESC") (item: ${String(item)})`,
|
|
36
|
+
ORDER_BY_FIELD_EMPTY: (item: string) => `orderBy 中字段名不能为空 (item: ${item})`,
|
|
37
|
+
ORDER_BY_DIR_INVALID: (direction: string) => `ORDER BY 方向必须是 ASC 或 DESC (direction: ${direction})`,
|
|
38
|
+
|
|
39
|
+
LIMIT_MUST_NON_NEGATIVE: (count: unknown) => `LIMIT 数量必须是非负数 (count: ${String(count)})`,
|
|
40
|
+
OFFSET_MUST_NON_NEGATIVE: (offset: unknown) => `OFFSET 必须是非负数 (offset: ${String(offset)})`,
|
|
41
|
+
OFFSET_COUNT_MUST_NON_NEGATIVE: (count: unknown) => `OFFSET 必须是非负数 (count: ${String(count)})`,
|
|
42
|
+
|
|
43
|
+
INSERT_NEED_TABLE: (table: unknown) => `INSERT 需要表名 (table: ${String(table)})`,
|
|
44
|
+
INSERT_NEED_DATA: (table: unknown, data: unknown) => `INSERT 需要数据 (table: ${String(table)}, data: ${JSON.stringify(data)})`,
|
|
45
|
+
INSERT_NEED_AT_LEAST_ONE_FIELD: (table: string) => `插入数据必须至少有一个字段 (table: ${table})`,
|
|
46
|
+
|
|
47
|
+
UPDATE_NEED_TABLE: "UPDATE 需要表名",
|
|
48
|
+
UPDATE_NEED_OBJECT: "UPDATE 需要数据对象",
|
|
49
|
+
UPDATE_NEED_AT_LEAST_ONE_FIELD: "更新数据必须至少有一个字段",
|
|
50
|
+
UPDATE_NEED_WHERE: "为安全起见,UPDATE 需要 WHERE 条件",
|
|
51
|
+
|
|
52
|
+
DELETE_NEED_TABLE: "DELETE 需要表名",
|
|
53
|
+
DELETE_NEED_WHERE: "为安全起见,DELETE 需要 WHERE 条件",
|
|
54
|
+
|
|
55
|
+
TO_DELETE_IN_NEED_TABLE: (table: unknown) => `toDeleteInSql 需要非空表名 (table: ${String(table)})`,
|
|
56
|
+
TO_DELETE_IN_NEED_ID_FIELD: (idField: unknown) => `toDeleteInSql 需要非空 idField (idField: ${String(idField)})`,
|
|
57
|
+
TO_DELETE_IN_NEED_IDS: "toDeleteInSql 需要 ids 数组",
|
|
58
|
+
|
|
59
|
+
TO_UPDATE_CASE_NEED_TABLE: (table: unknown) => `toUpdateCaseByIdSql 需要非空表名 (table: ${String(table)})`,
|
|
60
|
+
TO_UPDATE_CASE_NEED_ID_FIELD: (idField: unknown) => `toUpdateCaseByIdSql 需要非空 idField (idField: ${String(idField)})`,
|
|
61
|
+
TO_UPDATE_CASE_NEED_ROWS: "toUpdateCaseByIdSql 需要 rows 数组",
|
|
62
|
+
TO_UPDATE_CASE_NEED_FIELDS: "toUpdateCaseByIdSql 需要 fields 数组"
|
|
63
|
+
} as const;
|
|
64
|
+
|
|
8
65
|
/**
|
|
9
66
|
* SQL 构建器类
|
|
10
67
|
*/
|
|
@@ -19,6 +76,36 @@ export class SqlBuilder {
|
|
|
19
76
|
private _limit: number | null = null;
|
|
20
77
|
private _offset: number | null = null;
|
|
21
78
|
private _params: SqlValue[] = [];
|
|
79
|
+
private _quoteIdent: (identifier: string) => string;
|
|
80
|
+
|
|
81
|
+
constructor(options?: { quoteIdent?: (identifier: string) => string }) {
|
|
82
|
+
if (options && options.quoteIdent) {
|
|
83
|
+
this._quoteIdent = options.quoteIdent;
|
|
84
|
+
} else {
|
|
85
|
+
this._quoteIdent = (identifier: string) => {
|
|
86
|
+
if (typeof identifier !== "string") {
|
|
87
|
+
throw new Error(SqlBuilderError.QUOTE_IDENT_NEED_STRING(identifier));
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const trimmed = identifier.trim();
|
|
91
|
+
if (!trimmed) {
|
|
92
|
+
throw new Error(SqlBuilderError.IDENT_EMPTY);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// 默认行为(MySQL 风格):允许特殊字符,但对反引号进行转义
|
|
96
|
+
const escaped = trimmed.replace(/`/g, "``");
|
|
97
|
+
return `\`${escaped}\``;
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
private _isQuotedIdent(value: string): boolean {
|
|
103
|
+
return SqlCheck.isQuotedIdentPaired(value);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
private _startsWithQuote(value: string): boolean {
|
|
107
|
+
return SqlCheck.startsWithQuote(value);
|
|
108
|
+
}
|
|
22
109
|
|
|
23
110
|
/**
|
|
24
111
|
* 重置构建器状态
|
|
@@ -47,16 +134,28 @@ export class SqlBuilder {
|
|
|
47
134
|
|
|
48
135
|
field = field.trim();
|
|
49
136
|
|
|
50
|
-
//
|
|
51
|
-
|
|
137
|
+
// 防止不完整引用被误认为“已安全引用”
|
|
138
|
+
SqlCheck.assertPairedQuotedIdentIfStartsWithQuote(field, "字段标识符");
|
|
139
|
+
|
|
140
|
+
// 如果是 * 或已经被引用,直接返回
|
|
141
|
+
if (field === "*" || this._isQuotedIdent(field)) {
|
|
52
142
|
return field;
|
|
53
143
|
}
|
|
54
144
|
|
|
145
|
+
try {
|
|
146
|
+
SqlCheck.assertNoExprField(field);
|
|
147
|
+
} catch {
|
|
148
|
+
// 保持 SqlBuilder 报错文案统一
|
|
149
|
+
throw new Error(SqlBuilderError.FIELD_EXPR_NOT_ALLOWED(field));
|
|
150
|
+
}
|
|
151
|
+
|
|
55
152
|
// 处理别名(AS关键字)
|
|
56
153
|
if (field.toUpperCase().includes(" AS ")) {
|
|
57
154
|
const parts = field.split(/\s+AS\s+/i);
|
|
58
155
|
const fieldPart = parts[0].trim();
|
|
59
156
|
const aliasPart = parts[1].trim();
|
|
157
|
+
// alias 仅允许安全标识符或已被引用
|
|
158
|
+
SqlCheck.assertSafeAlias(aliasPart);
|
|
60
159
|
return `${this._escapeField(fieldPart)} AS ${aliasPart}`;
|
|
61
160
|
}
|
|
62
161
|
|
|
@@ -66,16 +165,21 @@ export class SqlBuilder {
|
|
|
66
165
|
return parts
|
|
67
166
|
.map((part) => {
|
|
68
167
|
part = part.trim();
|
|
69
|
-
if (part === "*" ||
|
|
168
|
+
if (part === "*" || this._isQuotedIdent(part)) {
|
|
70
169
|
return part;
|
|
71
170
|
}
|
|
72
|
-
|
|
171
|
+
SqlCheck.assertPairedQuotedIdentIfStartsWithQuote(part, "字段标识符");
|
|
172
|
+
return this._quoteIdent(part);
|
|
73
173
|
})
|
|
74
174
|
.join(".");
|
|
75
175
|
}
|
|
76
176
|
|
|
77
177
|
// 处理单个字段名
|
|
78
|
-
return
|
|
178
|
+
return this._quoteIdent(field);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
private _validateIdentifierPart(part: string, kind: "table" | "schema" | "alias" | "field"): void {
|
|
182
|
+
SqlCheck.assertSafeIdentifierPart(part, kind);
|
|
79
183
|
}
|
|
80
184
|
|
|
81
185
|
/**
|
|
@@ -88,32 +192,86 @@ export class SqlBuilder {
|
|
|
88
192
|
|
|
89
193
|
table = table.trim();
|
|
90
194
|
|
|
91
|
-
|
|
195
|
+
// 防止不完整引用被误认为“已安全引用”
|
|
196
|
+
if (this._startsWithQuote(table) && !this._isQuotedIdent(table)) {
|
|
197
|
+
// 注意:这里可能是 `table` alias 的形式,整体不成对,但 namePart 可能成对。
|
|
198
|
+
// 因此这里只做“整体是单段引用”的判断,具体在后续 namePart 分支里校验。
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
if (this._isQuotedIdent(table)) {
|
|
92
202
|
return table;
|
|
93
203
|
}
|
|
94
204
|
|
|
95
|
-
|
|
96
|
-
if (
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
205
|
+
const parts = table.split(/\s+/).filter((p) => p.length > 0);
|
|
206
|
+
if (parts.length === 0) {
|
|
207
|
+
throw new Error(SqlBuilderError.FROM_EMPTY);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
if (parts.length > 2) {
|
|
211
|
+
throw new Error(SqlBuilderError.TABLE_REF_TOO_MANY_PARTS(table));
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
const namePart = parts[0].trim();
|
|
215
|
+
const aliasPart = parts.length === 2 ? parts[1].trim() : null;
|
|
216
|
+
|
|
217
|
+
const nameSegments = namePart.split(".");
|
|
218
|
+
if (nameSegments.length > 2) {
|
|
219
|
+
throw new Error(SqlBuilderError.TABLE_REF_SCHEMA_TOO_DEEP(table));
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
let escapedName = "";
|
|
223
|
+
if (nameSegments.length === 2) {
|
|
224
|
+
const schema = nameSegments[0].trim();
|
|
225
|
+
const tableName = nameSegments[1].trim();
|
|
226
|
+
|
|
227
|
+
const escapedSchema = this._isQuotedIdent(schema)
|
|
228
|
+
? schema
|
|
229
|
+
: (() => {
|
|
230
|
+
if (this._startsWithQuote(schema) && !this._isQuotedIdent(schema)) {
|
|
231
|
+
throw new Error(SqlBuilderError.SCHEMA_QUOTE_NOT_PAIRED(schema));
|
|
232
|
+
}
|
|
233
|
+
this._validateIdentifierPart(schema, "schema");
|
|
234
|
+
return this._quoteIdent(schema);
|
|
235
|
+
})();
|
|
236
|
+
|
|
237
|
+
const escapedTableName = this._isQuotedIdent(tableName)
|
|
238
|
+
? tableName
|
|
239
|
+
: (() => {
|
|
240
|
+
if (this._startsWithQuote(tableName) && !this._isQuotedIdent(tableName)) {
|
|
241
|
+
throw new Error(SqlBuilderError.TABLE_QUOTE_NOT_PAIRED(tableName));
|
|
242
|
+
}
|
|
243
|
+
this._validateIdentifierPart(tableName, "table");
|
|
244
|
+
return this._quoteIdent(tableName);
|
|
245
|
+
})();
|
|
246
|
+
|
|
247
|
+
escapedName = `${escapedSchema}.${escapedTableName}`;
|
|
248
|
+
} else {
|
|
249
|
+
const tableName = nameSegments[0].trim();
|
|
250
|
+
|
|
251
|
+
if (this._isQuotedIdent(tableName)) {
|
|
252
|
+
escapedName = tableName;
|
|
102
253
|
} else {
|
|
103
|
-
|
|
254
|
+
if (this._startsWithQuote(tableName) && !this._isQuotedIdent(tableName)) {
|
|
255
|
+
throw new Error(SqlBuilderError.TABLE_QUOTE_NOT_PAIRED(tableName));
|
|
256
|
+
}
|
|
257
|
+
this._validateIdentifierPart(tableName, "table");
|
|
258
|
+
escapedName = this._quoteIdent(tableName);
|
|
104
259
|
}
|
|
105
260
|
}
|
|
106
261
|
|
|
107
|
-
|
|
262
|
+
if (aliasPart) {
|
|
263
|
+
this._validateIdentifierPart(aliasPart, "alias");
|
|
264
|
+
return `${escapedName} ${aliasPart}`;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
return escapedName;
|
|
108
268
|
}
|
|
109
269
|
|
|
110
270
|
/**
|
|
111
271
|
* 验证参数
|
|
112
272
|
*/
|
|
113
273
|
private _validateParam(value: any): void {
|
|
114
|
-
|
|
115
|
-
throw new Error(`参数值不能为 undefined`);
|
|
116
|
-
}
|
|
274
|
+
SqlCheck.assertNoUndefinedParam(value, "SQL 参数值");
|
|
117
275
|
}
|
|
118
276
|
|
|
119
277
|
/**
|
|
@@ -253,7 +411,7 @@ export class SqlBuilder {
|
|
|
253
411
|
const tempParams: SqlValue[] = [];
|
|
254
412
|
|
|
255
413
|
value.forEach((condition) => {
|
|
256
|
-
const tempBuilder = new SqlBuilder();
|
|
414
|
+
const tempBuilder = new SqlBuilder({ quoteIdent: this._quoteIdent });
|
|
257
415
|
tempBuilder._processWhereConditions(condition);
|
|
258
416
|
if (tempBuilder._where.length > 0) {
|
|
259
417
|
orConditions.push(`(${tempBuilder._where.join(" AND ")})`);
|
|
@@ -309,8 +467,19 @@ export class SqlBuilder {
|
|
|
309
467
|
} else if (typeof fields === "string") {
|
|
310
468
|
this._select.push(this._escapeField(fields));
|
|
311
469
|
} else {
|
|
312
|
-
throw new Error(
|
|
470
|
+
throw new Error(SqlBuilderError.SELECT_FIELDS_INVALID);
|
|
471
|
+
}
|
|
472
|
+
return this;
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
/**
|
|
476
|
+
* SELECT 原始表达式(不做转义)
|
|
477
|
+
*/
|
|
478
|
+
selectRaw(expr: string): this {
|
|
479
|
+
if (typeof expr !== "string" || !expr.trim()) {
|
|
480
|
+
throw new Error(SqlBuilderError.SELECT_RAW_NEED_NON_EMPTY(expr));
|
|
313
481
|
}
|
|
482
|
+
this._select.push(expr);
|
|
314
483
|
return this;
|
|
315
484
|
}
|
|
316
485
|
|
|
@@ -319,26 +488,61 @@ export class SqlBuilder {
|
|
|
319
488
|
*/
|
|
320
489
|
from(table: string): this {
|
|
321
490
|
if (typeof table !== "string" || !table.trim()) {
|
|
322
|
-
throw new Error(
|
|
491
|
+
throw new Error(SqlBuilderError.FROM_NEED_NON_EMPTY(table));
|
|
323
492
|
}
|
|
324
493
|
this._from = this._escapeTable(table.trim());
|
|
325
494
|
return this;
|
|
326
495
|
}
|
|
327
496
|
|
|
497
|
+
/**
|
|
498
|
+
* FROM 原始表达式(不做转义)
|
|
499
|
+
*/
|
|
500
|
+
fromRaw(tableExpr: string): this {
|
|
501
|
+
if (typeof tableExpr !== "string" || !tableExpr.trim()) {
|
|
502
|
+
throw new Error(SqlBuilderError.FROM_RAW_NEED_NON_EMPTY(tableExpr));
|
|
503
|
+
}
|
|
504
|
+
this._from = tableExpr;
|
|
505
|
+
return this;
|
|
506
|
+
}
|
|
507
|
+
|
|
328
508
|
/**
|
|
329
509
|
* WHERE 条件
|
|
330
510
|
*/
|
|
331
|
-
where(condition: WhereConditions
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
511
|
+
where(condition: WhereConditions): this;
|
|
512
|
+
where(field: string, value: SqlValue): this;
|
|
513
|
+
where(conditionOrField: WhereConditions | string, value?: SqlValue): this {
|
|
514
|
+
if (typeof conditionOrField === "object" && conditionOrField !== null) {
|
|
515
|
+
this._processWhereConditions(conditionOrField);
|
|
516
|
+
return this;
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
if (typeof conditionOrField === "string") {
|
|
520
|
+
if (value === undefined) {
|
|
521
|
+
throw new Error(SqlBuilderError.WHERE_VALUE_REQUIRED);
|
|
522
|
+
}
|
|
335
523
|
this._validateParam(value);
|
|
336
|
-
const escapedCondition = this._escapeField(
|
|
524
|
+
const escapedCondition = this._escapeField(conditionOrField);
|
|
337
525
|
this._where.push(`${escapedCondition} = ?`);
|
|
338
526
|
this._params.push(value);
|
|
339
|
-
|
|
340
|
-
this._where.push(condition);
|
|
527
|
+
return this;
|
|
341
528
|
}
|
|
529
|
+
|
|
530
|
+
return this;
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
/**
|
|
534
|
+
* WHERE 原始片段(不做转义),可附带参数。
|
|
535
|
+
*/
|
|
536
|
+
whereRaw(sql: string, params?: SqlValue[]): this {
|
|
537
|
+
if (typeof sql !== "string" || !sql.trim()) {
|
|
538
|
+
throw new Error(SqlBuilderError.WHERE_RAW_NEED_NON_EMPTY(sql));
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
this._where.push(sql);
|
|
542
|
+
if (params && params.length > 0) {
|
|
543
|
+
this._params.push(...params);
|
|
544
|
+
}
|
|
545
|
+
|
|
342
546
|
return this;
|
|
343
547
|
}
|
|
344
548
|
|
|
@@ -347,7 +551,7 @@ export class SqlBuilder {
|
|
|
347
551
|
*/
|
|
348
552
|
leftJoin(table: string, on: string): this {
|
|
349
553
|
if (typeof table !== "string" || typeof on !== "string") {
|
|
350
|
-
throw new Error(
|
|
554
|
+
throw new Error(SqlBuilderError.JOIN_NEED_STRING(table, on));
|
|
351
555
|
}
|
|
352
556
|
const escapedTable = this._escapeTable(table);
|
|
353
557
|
this._joins.push(`LEFT JOIN ${escapedTable} ON ${on}`);
|
|
@@ -359,7 +563,7 @@ export class SqlBuilder {
|
|
|
359
563
|
*/
|
|
360
564
|
rightJoin(table: string, on: string): this {
|
|
361
565
|
if (typeof table !== "string" || typeof on !== "string") {
|
|
362
|
-
throw new Error(
|
|
566
|
+
throw new Error(SqlBuilderError.JOIN_NEED_STRING(table, on));
|
|
363
567
|
}
|
|
364
568
|
const escapedTable = this._escapeTable(table);
|
|
365
569
|
this._joins.push(`RIGHT JOIN ${escapedTable} ON ${on}`);
|
|
@@ -371,7 +575,7 @@ export class SqlBuilder {
|
|
|
371
575
|
*/
|
|
372
576
|
innerJoin(table: string, on: string): this {
|
|
373
577
|
if (typeof table !== "string" || typeof on !== "string") {
|
|
374
|
-
throw new Error(
|
|
578
|
+
throw new Error(SqlBuilderError.JOIN_NEED_STRING(table, on));
|
|
375
579
|
}
|
|
376
580
|
const escapedTable = this._escapeTable(table);
|
|
377
581
|
this._joins.push(`INNER JOIN ${escapedTable} ON ${on}`);
|
|
@@ -384,12 +588,12 @@ export class SqlBuilder {
|
|
|
384
588
|
*/
|
|
385
589
|
orderBy(fields: string[]): this {
|
|
386
590
|
if (!Array.isArray(fields)) {
|
|
387
|
-
throw new Error(
|
|
591
|
+
throw new Error(SqlBuilderError.ORDER_BY_NEED_ARRAY);
|
|
388
592
|
}
|
|
389
593
|
|
|
390
594
|
fields.forEach((item) => {
|
|
391
595
|
if (typeof item !== "string" || !item.includes("#")) {
|
|
392
|
-
throw new Error(
|
|
596
|
+
throw new Error(SqlBuilderError.ORDER_BY_ITEM_NEED_HASH(item));
|
|
393
597
|
}
|
|
394
598
|
|
|
395
599
|
const [fieldName, direction] = item.split("#");
|
|
@@ -397,11 +601,11 @@ export class SqlBuilder {
|
|
|
397
601
|
const cleanDir = direction.trim().toUpperCase() as OrderDirection;
|
|
398
602
|
|
|
399
603
|
if (!cleanField) {
|
|
400
|
-
throw new Error(
|
|
604
|
+
throw new Error(SqlBuilderError.ORDER_BY_FIELD_EMPTY(item));
|
|
401
605
|
}
|
|
402
606
|
|
|
403
607
|
if (!["ASC", "DESC"].includes(cleanDir)) {
|
|
404
|
-
throw new Error(
|
|
608
|
+
throw new Error(SqlBuilderError.ORDER_BY_DIR_INVALID(cleanDir));
|
|
405
609
|
}
|
|
406
610
|
|
|
407
611
|
const escapedField = this._escapeField(cleanField);
|
|
@@ -439,12 +643,12 @@ export class SqlBuilder {
|
|
|
439
643
|
*/
|
|
440
644
|
limit(count: number, offset?: number): this {
|
|
441
645
|
if (typeof count !== "number" || count < 0) {
|
|
442
|
-
throw new Error(
|
|
646
|
+
throw new Error(SqlBuilderError.LIMIT_MUST_NON_NEGATIVE(count));
|
|
443
647
|
}
|
|
444
648
|
this._limit = Math.floor(count);
|
|
445
649
|
if (offset !== undefined && offset !== null) {
|
|
446
650
|
if (typeof offset !== "number" || offset < 0) {
|
|
447
|
-
throw new Error(
|
|
651
|
+
throw new Error(SqlBuilderError.OFFSET_MUST_NON_NEGATIVE(offset));
|
|
448
652
|
}
|
|
449
653
|
this._offset = Math.floor(offset);
|
|
450
654
|
}
|
|
@@ -456,7 +660,7 @@ export class SqlBuilder {
|
|
|
456
660
|
*/
|
|
457
661
|
offset(count: number): this {
|
|
458
662
|
if (typeof count !== "number" || count < 0) {
|
|
459
|
-
throw new Error(
|
|
663
|
+
throw new Error(SqlBuilderError.OFFSET_COUNT_MUST_NON_NEGATIVE(count));
|
|
460
664
|
}
|
|
461
665
|
this._offset = Math.floor(count);
|
|
462
666
|
return this;
|
|
@@ -471,7 +675,7 @@ export class SqlBuilder {
|
|
|
471
675
|
sql += this._select.length > 0 ? this._select.join(", ") : "*";
|
|
472
676
|
|
|
473
677
|
if (!this._from) {
|
|
474
|
-
throw new Error(
|
|
678
|
+
throw new Error(SqlBuilderError.FROM_REQUIRED);
|
|
475
679
|
}
|
|
476
680
|
sql += ` FROM ${this._from}`;
|
|
477
681
|
|
|
@@ -510,37 +714,40 @@ export class SqlBuilder {
|
|
|
510
714
|
*/
|
|
511
715
|
toInsertSql(table: string, data: InsertData): SqlQuery {
|
|
512
716
|
if (!table || typeof table !== "string") {
|
|
513
|
-
throw new Error(
|
|
717
|
+
throw new Error(SqlBuilderError.INSERT_NEED_TABLE(table));
|
|
514
718
|
}
|
|
515
719
|
|
|
516
720
|
if (!data || typeof data !== "object") {
|
|
517
|
-
throw new Error(
|
|
721
|
+
throw new Error(SqlBuilderError.INSERT_NEED_DATA(table, data));
|
|
518
722
|
}
|
|
519
723
|
|
|
520
724
|
const escapedTable = this._escapeTable(table);
|
|
521
725
|
|
|
522
726
|
if (Array.isArray(data)) {
|
|
523
|
-
|
|
524
|
-
throw new Error(`插入数据不能为空 (table: ${table})`);
|
|
525
|
-
}
|
|
526
|
-
|
|
527
|
-
const fields = Object.keys(data[0]);
|
|
528
|
-
if (fields.length === 0) {
|
|
529
|
-
throw new Error(`插入数据必须至少有一个字段 (table: ${table})`);
|
|
530
|
-
}
|
|
727
|
+
const fields = SqlCheck.assertBatchInsertRowsConsistent(data as Array<Record<string, unknown>>, { table: table });
|
|
531
728
|
|
|
532
729
|
const escapedFields = fields.map((field) => this._escapeField(field));
|
|
533
730
|
const placeholders = fields.map(() => "?").join(", ");
|
|
534
731
|
const values = data.map(() => `(${placeholders})`).join(", ");
|
|
535
732
|
|
|
536
733
|
const sql = `INSERT INTO ${escapedTable} (${escapedFields.join(", ")}) VALUES ${values}`;
|
|
537
|
-
const params =
|
|
734
|
+
const params: SqlValue[] = [];
|
|
735
|
+
for (let i = 0; i < data.length; i++) {
|
|
736
|
+
const row = data[i] as Record<string, SqlValue>;
|
|
737
|
+
for (const field of fields) {
|
|
738
|
+
params.push(row[field]);
|
|
739
|
+
}
|
|
740
|
+
}
|
|
538
741
|
|
|
539
742
|
return { sql, params };
|
|
540
743
|
} else {
|
|
541
744
|
const fields = Object.keys(data);
|
|
542
745
|
if (fields.length === 0) {
|
|
543
|
-
throw new Error(
|
|
746
|
+
throw new Error(SqlBuilderError.INSERT_NEED_AT_LEAST_ONE_FIELD(table));
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
for (const field of fields) {
|
|
750
|
+
this._validateParam((data as any)[field]);
|
|
544
751
|
}
|
|
545
752
|
|
|
546
753
|
const escapedFields = fields.map((field) => this._escapeField(field));
|
|
@@ -557,16 +764,16 @@ export class SqlBuilder {
|
|
|
557
764
|
*/
|
|
558
765
|
toUpdateSql(table: string, data: UpdateData): SqlQuery {
|
|
559
766
|
if (!table || typeof table !== "string") {
|
|
560
|
-
throw new Error(
|
|
767
|
+
throw new Error(SqlBuilderError.UPDATE_NEED_TABLE);
|
|
561
768
|
}
|
|
562
769
|
|
|
563
770
|
if (!data || typeof data !== "object" || Array.isArray(data)) {
|
|
564
|
-
throw new Error(
|
|
771
|
+
throw new Error(SqlBuilderError.UPDATE_NEED_OBJECT);
|
|
565
772
|
}
|
|
566
773
|
|
|
567
774
|
const fields = Object.keys(data);
|
|
568
775
|
if (fields.length === 0) {
|
|
569
|
-
throw new Error(
|
|
776
|
+
throw new Error(SqlBuilderError.UPDATE_NEED_AT_LEAST_ONE_FIELD);
|
|
570
777
|
}
|
|
571
778
|
|
|
572
779
|
const escapedTable = this._escapeTable(table);
|
|
@@ -578,7 +785,7 @@ export class SqlBuilder {
|
|
|
578
785
|
if (this._where.length > 0) {
|
|
579
786
|
sql += " WHERE " + this._where.join(" AND ");
|
|
580
787
|
} else {
|
|
581
|
-
throw new Error(
|
|
788
|
+
throw new Error(SqlBuilderError.UPDATE_NEED_WHERE);
|
|
582
789
|
}
|
|
583
790
|
|
|
584
791
|
return { sql, params };
|
|
@@ -589,7 +796,7 @@ export class SqlBuilder {
|
|
|
589
796
|
*/
|
|
590
797
|
toDeleteSql(table: string): SqlQuery {
|
|
591
798
|
if (!table || typeof table !== "string") {
|
|
592
|
-
throw new Error(
|
|
799
|
+
throw new Error(SqlBuilderError.DELETE_NEED_TABLE);
|
|
593
800
|
}
|
|
594
801
|
|
|
595
802
|
const escapedTable = this._escapeTable(table);
|
|
@@ -598,7 +805,7 @@ export class SqlBuilder {
|
|
|
598
805
|
if (this._where.length > 0) {
|
|
599
806
|
sql += " WHERE " + this._where.join(" AND ");
|
|
600
807
|
} else {
|
|
601
|
-
throw new Error(
|
|
808
|
+
throw new Error(SqlBuilderError.DELETE_NEED_WHERE);
|
|
602
809
|
}
|
|
603
810
|
|
|
604
811
|
return { sql, params: [...this._params] };
|
|
@@ -611,7 +818,7 @@ export class SqlBuilder {
|
|
|
611
818
|
let sql = "SELECT COUNT(*) as total";
|
|
612
819
|
|
|
613
820
|
if (!this._from) {
|
|
614
|
-
throw new Error(
|
|
821
|
+
throw new Error(SqlBuilderError.COUNT_NEED_FROM);
|
|
615
822
|
}
|
|
616
823
|
sql += ` FROM ${this._from}`;
|
|
617
824
|
|
|
@@ -625,6 +832,99 @@ export class SqlBuilder {
|
|
|
625
832
|
|
|
626
833
|
return { sql, params: [...this._params] };
|
|
627
834
|
}
|
|
835
|
+
|
|
836
|
+
static toDeleteInSql(options: { table: string; idField: string; ids: SqlValue[]; quoteIdent: (identifier: string) => string }): SqlQuery {
|
|
837
|
+
if (typeof options.table !== "string" || !options.table.trim()) {
|
|
838
|
+
throw new Error(SqlBuilderError.TO_DELETE_IN_NEED_TABLE(options.table));
|
|
839
|
+
}
|
|
840
|
+
if (typeof options.idField !== "string" || !options.idField.trim()) {
|
|
841
|
+
throw new Error(SqlBuilderError.TO_DELETE_IN_NEED_ID_FIELD(options.idField));
|
|
842
|
+
}
|
|
843
|
+
if (!Array.isArray(options.ids)) {
|
|
844
|
+
throw new Error(SqlBuilderError.TO_DELETE_IN_NEED_IDS);
|
|
845
|
+
}
|
|
846
|
+
if (options.ids.length === 0) {
|
|
847
|
+
return { sql: "", params: [] };
|
|
848
|
+
}
|
|
849
|
+
|
|
850
|
+
const placeholders = options.ids.map(() => "?").join(",");
|
|
851
|
+
const sql = `DELETE FROM ${options.quoteIdent(options.table)} WHERE ${options.quoteIdent(options.idField)} IN (${placeholders})`;
|
|
852
|
+
return { sql: sql, params: [...options.ids] };
|
|
853
|
+
}
|
|
854
|
+
|
|
855
|
+
static toUpdateCaseByIdSql(options: {
|
|
856
|
+
table: string;
|
|
857
|
+
idField: string;
|
|
858
|
+
rows: Array<{ id: SqlValue; data: Record<string, SqlValue> }>;
|
|
859
|
+
fields: string[];
|
|
860
|
+
quoteIdent: (identifier: string) => string;
|
|
861
|
+
updatedAtField: string;
|
|
862
|
+
updatedAtValue: SqlValue;
|
|
863
|
+
stateField?: string;
|
|
864
|
+
stateGtZero?: boolean;
|
|
865
|
+
}): SqlQuery {
|
|
866
|
+
if (typeof options.table !== "string" || !options.table.trim()) {
|
|
867
|
+
throw new Error(SqlBuilderError.TO_UPDATE_CASE_NEED_TABLE(options.table));
|
|
868
|
+
}
|
|
869
|
+
if (typeof options.idField !== "string" || !options.idField.trim()) {
|
|
870
|
+
throw new Error(SqlBuilderError.TO_UPDATE_CASE_NEED_ID_FIELD(options.idField));
|
|
871
|
+
}
|
|
872
|
+
if (!Array.isArray(options.rows)) {
|
|
873
|
+
throw new Error(SqlBuilderError.TO_UPDATE_CASE_NEED_ROWS);
|
|
874
|
+
}
|
|
875
|
+
if (options.rows.length === 0) {
|
|
876
|
+
return { sql: "", params: [] };
|
|
877
|
+
}
|
|
878
|
+
if (!Array.isArray(options.fields)) {
|
|
879
|
+
throw new Error(SqlBuilderError.TO_UPDATE_CASE_NEED_FIELDS);
|
|
880
|
+
}
|
|
881
|
+
if (options.fields.length === 0) {
|
|
882
|
+
return { sql: "", params: [] };
|
|
883
|
+
}
|
|
884
|
+
|
|
885
|
+
const ids: SqlValue[] = options.rows.map((r) => r.id);
|
|
886
|
+
const placeholders = ids.map(() => "?").join(",");
|
|
887
|
+
|
|
888
|
+
const setSqlList: string[] = [];
|
|
889
|
+
const args: SqlValue[] = [];
|
|
890
|
+
|
|
891
|
+
const quotedId = options.quoteIdent(options.idField);
|
|
892
|
+
|
|
893
|
+
for (const field of options.fields) {
|
|
894
|
+
const whenList: string[] = [];
|
|
895
|
+
|
|
896
|
+
for (const row of options.rows) {
|
|
897
|
+
if (!(field in row.data)) {
|
|
898
|
+
continue;
|
|
899
|
+
}
|
|
900
|
+
|
|
901
|
+
whenList.push("WHEN ? THEN ?");
|
|
902
|
+
args.push(row.id);
|
|
903
|
+
args.push(row.data[field]);
|
|
904
|
+
}
|
|
905
|
+
|
|
906
|
+
if (whenList.length === 0) {
|
|
907
|
+
continue;
|
|
908
|
+
}
|
|
909
|
+
|
|
910
|
+
const quotedField = options.quoteIdent(field);
|
|
911
|
+
setSqlList.push(`${quotedField} = CASE ${quotedId} ${whenList.join(" ")} ELSE ${quotedField} END`);
|
|
912
|
+
}
|
|
913
|
+
|
|
914
|
+
setSqlList.push(`${options.quoteIdent(options.updatedAtField)} = ?`);
|
|
915
|
+
args.push(options.updatedAtValue);
|
|
916
|
+
|
|
917
|
+
for (const id of ids) {
|
|
918
|
+
args.push(id);
|
|
919
|
+
}
|
|
920
|
+
|
|
921
|
+
let sql = `UPDATE ${options.quoteIdent(options.table)} SET ${setSqlList.join(", ")} WHERE ${quotedId} IN (${placeholders})`;
|
|
922
|
+
if (options.stateGtZero && options.stateField) {
|
|
923
|
+
sql += ` AND ${options.quoteIdent(options.stateField)} > 0`;
|
|
924
|
+
}
|
|
925
|
+
|
|
926
|
+
return { sql: sql, params: args };
|
|
927
|
+
}
|
|
628
928
|
}
|
|
629
929
|
|
|
630
930
|
/**
|