befly 3.9.39 → 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.
Files changed (141) hide show
  1. package/README.md +39 -8
  2. package/befly.config.ts +19 -2
  3. package/checks/checkApi.ts +79 -77
  4. package/checks/checkHook.ts +48 -0
  5. package/checks/checkMenu.ts +168 -0
  6. package/checks/checkPlugin.ts +48 -0
  7. package/checks/checkTable.ts +137 -183
  8. package/docs/README.md +1 -1
  9. package/docs/api/api.md +1 -1
  10. package/docs/guide/quickstart.md +16 -9
  11. package/docs/hooks/hook.md +2 -2
  12. package/docs/hooks/rateLimit.md +1 -1
  13. package/docs/infra/redis.md +7 -7
  14. package/docs/plugins/plugin.md +23 -21
  15. package/docs/quickstart.md +16 -9
  16. package/docs/reference/addon.md +12 -1
  17. package/docs/reference/config.md +13 -30
  18. package/docs/reference/sync.md +62 -193
  19. package/docs/reference/table.md +27 -29
  20. package/hooks/auth.ts +3 -4
  21. package/hooks/cors.ts +4 -6
  22. package/hooks/parser.ts +3 -4
  23. package/hooks/permission.ts +3 -4
  24. package/hooks/validator.ts +4 -5
  25. package/lib/cacheHelper.ts +89 -153
  26. package/lib/cacheKeys.ts +1 -1
  27. package/lib/connect.ts +9 -13
  28. package/lib/dbDialect.ts +285 -0
  29. package/lib/dbHelper.ts +179 -507
  30. package/lib/dbUtils.ts +450 -0
  31. package/lib/logger.ts +41 -5
  32. package/lib/redisHelper.ts +1 -0
  33. package/lib/sqlBuilder.ts +358 -58
  34. package/lib/sqlCheck.ts +136 -0
  35. package/lib/validator.ts +1 -1
  36. package/loader/loadApis.ts +23 -126
  37. package/loader/loadHooks.ts +31 -46
  38. package/loader/loadPlugins.ts +37 -52
  39. package/main.ts +58 -19
  40. package/package.json +24 -25
  41. package/paths.ts +14 -14
  42. package/plugins/cache.ts +12 -6
  43. package/plugins/cipher.ts +2 -2
  44. package/plugins/config.ts +6 -8
  45. package/plugins/db.ts +14 -19
  46. package/plugins/jwt.ts +6 -7
  47. package/plugins/logger.ts +7 -9
  48. package/plugins/redis.ts +8 -10
  49. package/plugins/tool.ts +3 -4
  50. package/router/api.ts +3 -2
  51. package/router/static.ts +7 -5
  52. package/sync/syncApi.ts +80 -235
  53. package/sync/syncCache.ts +16 -0
  54. package/sync/syncDev.ts +167 -202
  55. package/sync/syncMenu.ts +230 -444
  56. package/sync/syncTable.ts +1247 -0
  57. package/tests/_mocks/mockSqliteDb.ts +204 -0
  58. package/tests/addonHelper-cache.test.ts +32 -0
  59. package/tests/apiHandler-routePath-only.test.ts +32 -0
  60. package/tests/cacheHelper.test.ts +16 -51
  61. package/tests/checkApi-routePath-strict.test.ts +166 -0
  62. package/tests/checkMenu.test.ts +346 -0
  63. package/tests/checkTable-smoke.test.ts +157 -0
  64. package/tests/dbDialect-cache.test.ts +23 -0
  65. package/tests/dbDialect.test.ts +46 -0
  66. package/tests/dbHelper-advanced.test.ts +1 -1
  67. package/tests/dbHelper-all-array-types.test.ts +15 -15
  68. package/tests/dbHelper-batch-write.test.ts +90 -0
  69. package/tests/dbHelper-columns.test.ts +36 -54
  70. package/tests/dbHelper-execute.test.ts +26 -26
  71. package/tests/dbHelper-joins.test.ts +85 -176
  72. package/tests/fixtures/scanFilesAddon/node_modules/@befly-addon/demo/apis/sub/b.ts +3 -0
  73. package/tests/fixtures/scanFilesApis/a.ts +3 -0
  74. package/tests/fixtures/scanFilesApis/sub/b.ts +3 -0
  75. package/tests/loadPlugins-order-smoke.test.ts +75 -0
  76. package/tests/logger.test.ts +6 -6
  77. package/tests/redisHelper.test.ts +6 -1
  78. package/tests/scanFiles-routePath.test.ts +46 -0
  79. package/tests/smoke-sql.test.ts +24 -0
  80. package/tests/sqlBuilder-advanced.test.ts +18 -5
  81. package/tests/sqlBuilder.test.ts +24 -0
  82. package/tests/sync-init-guard.test.ts +105 -0
  83. package/tests/syncApi-insBatch-fields-consistent.test.ts +61 -0
  84. package/tests/syncApi-obsolete-records.test.ts +69 -0
  85. package/tests/syncApi-type-compat.test.ts +72 -0
  86. package/tests/syncDev-permissions.test.ts +81 -0
  87. package/tests/syncMenu-disableMenus-hard-delete.test.ts +88 -0
  88. package/tests/syncMenu-duplicate-path.test.ts +122 -0
  89. package/tests/syncMenu-obsolete-records.test.ts +161 -0
  90. package/tests/syncMenu-parentPath-from-tree.test.ts +75 -0
  91. package/tests/syncMenu-paths.test.ts +0 -9
  92. package/tests/{syncDb-apply.test.ts → syncTable-apply.test.ts} +14 -24
  93. package/tests/{syncDb-array-number.test.ts → syncTable-array-number.test.ts} +31 -31
  94. package/tests/syncTable-constants.test.ts +101 -0
  95. package/tests/syncTable-db-integration.test.ts +237 -0
  96. package/tests/{syncDb-ddl.test.ts → syncTable-ddl.test.ts} +67 -53
  97. package/tests/{syncDb-helpers.test.ts → syncTable-helpers.test.ts} +12 -26
  98. package/tests/syncTable-schema.test.ts +99 -0
  99. package/tests/syncTable-testkit.test.ts +25 -0
  100. package/tests/syncTable-types.test.ts +122 -0
  101. package/tests/tableRef-and-deserialize.test.ts +67 -0
  102. package/tsconfig.json +1 -1
  103. package/types/api.d.ts +1 -1
  104. package/types/befly.d.ts +13 -12
  105. package/types/cache.d.ts +2 -2
  106. package/types/context.d.ts +1 -1
  107. package/types/database.d.ts +0 -5
  108. package/types/hook.d.ts +1 -10
  109. package/types/plugin.d.ts +2 -96
  110. package/types/sync.d.ts +19 -25
  111. package/utils/convertBigIntFields.ts +38 -0
  112. package/utils/disableMenusGlob.ts +85 -0
  113. package/utils/importDefault.ts +21 -0
  114. package/utils/isDirentDirectory.ts +23 -0
  115. package/utils/loadMenuConfigs.ts +145 -0
  116. package/utils/processFields.ts +25 -0
  117. package/utils/scanAddons.ts +72 -0
  118. package/utils/scanFiles.ts +129 -21
  119. package/utils/scanSources.ts +64 -0
  120. package/utils/sortModules.ts +137 -0
  121. package/checks/checkApp.ts +0 -55
  122. package/hooks/rateLimit.ts +0 -276
  123. package/sync/syncAll.ts +0 -35
  124. package/sync/syncDb/apply.ts +0 -192
  125. package/sync/syncDb/constants.ts +0 -119
  126. package/sync/syncDb/ddl.ts +0 -251
  127. package/sync/syncDb/helpers.ts +0 -84
  128. package/sync/syncDb/schema.ts +0 -202
  129. package/sync/syncDb/sqlite.ts +0 -48
  130. package/sync/syncDb/table.ts +0 -207
  131. package/sync/syncDb/tableCreate.ts +0 -163
  132. package/sync/syncDb/types.ts +0 -132
  133. package/sync/syncDb/version.ts +0 -69
  134. package/sync/syncDb.ts +0 -168
  135. package/tests/rateLimit-hook.test.ts +0 -477
  136. package/tests/syncDb-constants.test.ts +0 -130
  137. package/tests/syncDb-schema.test.ts +0 -179
  138. package/tests/syncDb-types.test.ts +0 -139
  139. package/utils/addonHelper.ts +0 -90
  140. package/utils/modules.ts +0 -98
  141. 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
- if (field === "*" || field.startsWith("`") || field.includes("(")) {
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 === "*" || part.startsWith("`")) {
168
+ if (part === "*" || this._isQuotedIdent(part)) {
70
169
  return part;
71
170
  }
72
- return `\`${part}\``;
171
+ SqlCheck.assertPairedQuotedIdentIfStartsWithQuote(part, "字段标识符");
172
+ return this._quoteIdent(part);
73
173
  })
74
174
  .join(".");
75
175
  }
76
176
 
77
177
  // 处理单个字段名
78
- return `\`${field}\``;
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
- if (table.startsWith("`")) {
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 (table.includes(" ")) {
97
- const parts = table.split(/\s+/);
98
- if (parts.length === 2) {
99
- const tableName = parts[0].trim();
100
- const alias = parts[1].trim();
101
- return `\`${tableName}\` ${alias}`;
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
- return table;
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
- return `\`${table}\``;
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
- if (value === undefined) {
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("SELECT 字段必须是字符串或数组");
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(`FROM 表名必须是非空字符串 (table: ${table})`);
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 | string, value?: SqlValue): this {
332
- if (typeof condition === "object" && condition !== null) {
333
- this._processWhereConditions(condition);
334
- } else if (value !== undefined && value !== null) {
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(condition as string);
524
+ const escapedCondition = this._escapeField(conditionOrField);
337
525
  this._where.push(`${escapedCondition} = ?`);
338
526
  this._params.push(value);
339
- } else if (typeof condition === "string") {
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(`JOIN 表名和条件必须是字符串 (table: ${table}, on: ${on})`);
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(`JOIN 表名和条件必须是字符串 (table: ${table}, on: ${on})`);
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(`JOIN 表名和条件必须是字符串 (table: ${table}, on: ${on})`);
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('orderBy 必须是字符串数组,格式为 "字段#方向"');
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(`orderBy 字段必须是 "字段#方向" 格式的字符串(例如:"name#ASC", "id#DESC") (item: ${item})`);
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(`orderBy 中字段名不能为空 (item: ${item})`);
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(`ORDER BY 方向必须是 ASC 或 DESC (direction: ${cleanDir})`);
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(`LIMIT 数量必须是非负数 (count: ${count})`);
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(`OFFSET 必须是非负数 (offset: ${offset})`);
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(`OFFSET 必须是非负数 (count: ${count})`);
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("FROM 表名是必需的");
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(`INSERT 需要表名 (table: ${table})`);
717
+ throw new Error(SqlBuilderError.INSERT_NEED_TABLE(table));
514
718
  }
515
719
 
516
720
  if (!data || typeof data !== "object") {
517
- throw new Error(`INSERT 需要数据 (table: ${table}, data: ${JSON.stringify(data)})`);
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
- if (data.length === 0) {
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 = data.flatMap((row) => fields.map((field) => row[field]));
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(`插入数据必须至少有一个字段 (table: ${table})`);
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("UPDATE 需要表名");
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("UPDATE 需要数据对象");
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("为安全起见,UPDATE 需要 WHERE 条件");
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("DELETE 需要表名");
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("为安全起见,DELETE 需要 WHERE 条件");
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("COUNT 需要 FROM 表名");
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
  /**