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.
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 +3 -4
  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
@@ -1,119 +0,0 @@
1
- /**
2
- * syncDb 常量定义模块
3
- *
4
- * 包含:
5
- * - 数据库类型判断
6
- * - 版本要求
7
- * - 数据类型映射
8
- * - 系统字段定义
9
- */
10
-
11
- /**
12
- * 数据库版本要求
13
- */
14
- export const DB_VERSION_REQUIREMENTS = {
15
- MYSQL_MIN_MAJOR: 8,
16
- POSTGRES_MIN_MAJOR: 17,
17
- SQLITE_MIN_VERSION: "3.50.0",
18
- SQLITE_MIN_VERSION_NUM: 35000 // 3 * 10000 + 50 * 100
19
- } as const;
20
-
21
- /**
22
- * 需要创建索引的系统字段
23
- */
24
- export const SYSTEM_INDEX_FIELDS = ["created_at", "updated_at", "state"] as const;
25
-
26
- /**
27
- * 字段变更类型的中文标签映射
28
- */
29
- export const CHANGE_TYPE_LABELS = {
30
- length: "长度",
31
- datatype: "类型",
32
- comment: "注释",
33
- default: "默认值",
34
- nullable: "可空约束",
35
- unique: "唯一约束"
36
- } as const;
37
-
38
- /**
39
- * MySQL 表配置
40
- *
41
- * 固定配置说明:
42
- * - ENGINE: InnoDB(支持事务、外键)
43
- * - CHARSET: utf8mb4(完整 Unicode 支持,包括 Emoji)
44
- * - COLLATE: utf8mb4_0900_ai_ci(MySQL 8.0 推荐,不区分重音和大小写)
45
- */
46
- export const MYSQL_TABLE_CONFIG = {
47
- ENGINE: "InnoDB",
48
- CHARSET: "utf8mb4",
49
- COLLATE: "utf8mb4_0900_ai_ci"
50
- } as const;
51
-
52
- // 是否为计划模式(仅输出 SQL 不执行)
53
- export const IS_PLAN = process.argv.includes("--plan");
54
-
55
- // 数据库类型(运行时设置,默认 mysql)
56
- let _dbType: string = "mysql";
57
-
58
- /**
59
- * 设置数据库类型(由 syncDbCommand 调用)
60
- * @param dbType - 数据库类型(mysql/postgresql/postgres/sqlite)
61
- */
62
- export function setDbType(dbType: string): void {
63
- _dbType = (dbType || "mysql").toLowerCase();
64
- }
65
-
66
- /**
67
- * 获取当前数据库类型
68
- */
69
- export function getDbType(): string {
70
- return _dbType;
71
- }
72
-
73
- // 数据库类型判断(getter 函数,运行时动态计算)
74
- export function isMySQL(): boolean {
75
- return _dbType === "mysql";
76
- }
77
-
78
- export function isPG(): boolean {
79
- return _dbType === "postgresql" || _dbType === "postgres";
80
- }
81
-
82
- export function isSQLite(): boolean {
83
- return _dbType === "sqlite";
84
- }
85
-
86
- // 兼容旧代码的静态别名(通过 getter 实现动态获取)
87
- export const DB_TYPE = {
88
- get current(): string {
89
- return _dbType;
90
- },
91
- get IS_MYSQL(): boolean {
92
- return isMySQL();
93
- },
94
- get IS_PG(): boolean {
95
- return isPG();
96
- },
97
- get IS_SQLITE(): boolean {
98
- return isSQLite();
99
- }
100
- };
101
-
102
- /**
103
- * 获取字段类型映射(根据当前数据库类型)
104
- */
105
- export function getTypeMapping(): Record<string, string> {
106
- const isSqlite = isSQLite();
107
- const isPg = isPG();
108
- const isMysql = isMySQL();
109
-
110
- return {
111
- number: isSqlite ? "INTEGER" : isPg ? "BIGINT" : "BIGINT",
112
- string: isSqlite ? "TEXT" : isPg ? "character varying" : "VARCHAR",
113
- text: isMysql ? "MEDIUMTEXT" : "TEXT",
114
- array_string: isSqlite ? "TEXT" : isPg ? "character varying" : "VARCHAR",
115
- array_text: isMysql ? "MEDIUMTEXT" : "TEXT",
116
- array_number_string: isSqlite ? "TEXT" : isPg ? "character varying" : "VARCHAR",
117
- array_number_text: isMysql ? "MEDIUMTEXT" : "TEXT"
118
- };
119
- }
@@ -1,251 +0,0 @@
1
- /**
2
- * syncDb DDL 构建模块
3
- *
4
- * 包含:
5
- * - 构建索引 SQL
6
- * - 生成 DDL 子句(添加/修改列)
7
- * - 安全执行 DDL(MySQL 降级策略)
8
- * - 构建系统列和业务列定义
9
- */
10
-
11
- import type { FieldDefinition } from "../../types/validate.js";
12
- import type { SQL } from "bun";
13
-
14
- import { snakeCase } from "es-toolkit/string";
15
-
16
- import { isMySQL, isPG } from "./constants.js";
17
- import { quoteIdentifier, escapeComment } from "./helpers.js";
18
- import { resolveDefaultValue, generateDefaultSql, getSqlType } from "./types.js";
19
-
20
- /**
21
- * 构建索引操作 SQL(统一使用在线策略)
22
- *
23
- * @param tableName - 表名
24
- * @param indexName - 索引名
25
- * @param fieldName - 字段名
26
- * @param action - 操作类型(create/drop)
27
- * @returns SQL 语句
28
- */
29
- export function buildIndexSQL(tableName: string, indexName: string, fieldName: string, action: "create" | "drop"): string {
30
- const tableQuoted = quoteIdentifier(tableName);
31
- const indexQuoted = quoteIdentifier(indexName);
32
- const fieldQuoted = quoteIdentifier(fieldName);
33
-
34
- if (isMySQL()) {
35
- const parts = [];
36
- if (action === "create") {
37
- parts.push(`ADD INDEX ${indexQuoted} (${fieldQuoted})`);
38
- } else {
39
- parts.push(`DROP INDEX ${indexQuoted}`);
40
- }
41
- // 始终使用在线算法
42
- parts.push("ALGORITHM=INPLACE");
43
- parts.push("LOCK=NONE");
44
- return `ALTER TABLE ${tableQuoted} ${parts.join(", ")}`;
45
- }
46
-
47
- if (isPG()) {
48
- if (action === "create") {
49
- // 始终使用 CONCURRENTLY
50
- return `CREATE INDEX CONCURRENTLY IF NOT EXISTS ${indexQuoted} ON ${tableQuoted}(${fieldQuoted})`;
51
- }
52
- return `DROP INDEX CONCURRENTLY IF EXISTS ${indexQuoted}`;
53
- }
54
-
55
- // SQLite
56
- if (action === "create") {
57
- return `CREATE INDEX IF NOT EXISTS ${indexQuoted} ON ${tableQuoted}(${fieldQuoted})`;
58
- }
59
- return `DROP INDEX IF EXISTS ${indexQuoted}`;
60
- }
61
-
62
- /**
63
- * 获取单个系统字段的列定义(用于 ADD COLUMN 或 CREATE TABLE)
64
- *
65
- * @param fieldName - 系统字段名(id, created_at, updated_at, deleted_at, state)
66
- * @returns 列定义字符串,如果不是系统字段则返回 null
67
- */
68
- export function getSystemColumnDef(fieldName: string): string | null {
69
- const mysqlDefs: Record<string, string> = {
70
- id: '`id` BIGINT UNSIGNED PRIMARY KEY AUTO_INCREMENT COMMENT "主键ID"',
71
- created_at: '`created_at` BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT "创建时间"',
72
- updated_at: '`updated_at` BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT "更新时间"',
73
- deleted_at: '`deleted_at` BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT "删除时间"',
74
- state: '`state` BIGINT UNSIGNED NOT NULL DEFAULT 1 COMMENT "状态字段"'
75
- };
76
- const pgDefs: Record<string, string> = {
77
- id: '"id" INTEGER PRIMARY KEY',
78
- created_at: '"created_at" INTEGER NOT NULL DEFAULT 0',
79
- updated_at: '"updated_at" INTEGER NOT NULL DEFAULT 0',
80
- deleted_at: '"deleted_at" INTEGER NOT NULL DEFAULT 0',
81
- state: '"state" INTEGER NOT NULL DEFAULT 1'
82
- };
83
-
84
- const defs = isMySQL() ? mysqlDefs : pgDefs;
85
- return defs[fieldName] || null;
86
- }
87
-
88
- /**
89
- * 构建系统字段列定义
90
- *
91
- * @returns 系统字段的列定义数组
92
- */
93
- export function buildSystemColumnDefs(): string[] {
94
- return [getSystemColumnDef("id")!, getSystemColumnDef("created_at")!, getSystemColumnDef("updated_at")!, getSystemColumnDef("deleted_at")!, getSystemColumnDef("state")!];
95
- }
96
-
97
- /**
98
- * 构建业务字段列定义
99
- *
100
- * @param fields - 字段定义对象
101
- * @returns 业务字段的列定义数组
102
- */
103
- export function buildBusinessColumnDefs(fields: Record<string, FieldDefinition>): string[] {
104
- const colDefs: string[] = [];
105
-
106
- for (const [fieldKey, fieldDef] of Object.entries(fields)) {
107
- // 转换字段名为下划线格式
108
- const dbFieldName = snakeCase(fieldKey);
109
-
110
- const sqlType = getSqlType(fieldDef.type, fieldDef.max, fieldDef.unsigned);
111
-
112
- // 使用公共函数处理默认值
113
- const actualDefault = resolveDefaultValue(fieldDef.default, fieldDef.type);
114
- const defaultSql = generateDefaultSql(actualDefault, fieldDef.type);
115
-
116
- // 构建约束
117
- const uniqueSql = fieldDef.unique ? " UNIQUE" : "";
118
- const nullableSql = fieldDef.nullable ? " NULL" : " NOT NULL";
119
-
120
- if (isMySQL()) {
121
- colDefs.push(`\`${dbFieldName}\` ${sqlType}${uniqueSql}${nullableSql}${defaultSql} COMMENT "${escapeComment(fieldDef.name)}"`);
122
- } else {
123
- colDefs.push(`"${dbFieldName}" ${sqlType}${uniqueSql}${nullableSql}${defaultSql}`);
124
- }
125
- }
126
-
127
- return colDefs;
128
- }
129
-
130
- /**
131
- * 生成字段 DDL 子句(不含 ALTER TABLE 前缀)
132
- *
133
- * @param fieldKey - 字段键名
134
- * @param fieldDef - 字段定义对象
135
- * @param isAdd - 是否为添加字段(true)还是修改字段(false)
136
- * @returns DDL 子句
137
- */
138
- export function generateDDLClause(fieldKey: string, fieldDef: FieldDefinition, isAdd: boolean = false): string {
139
- // 转换字段名为下划线格式
140
- const dbFieldName = snakeCase(fieldKey);
141
-
142
- const sqlType = getSqlType(fieldDef.type, fieldDef.max, fieldDef.unsigned);
143
-
144
- // 使用公共函数处理默认值
145
- const actualDefault = resolveDefaultValue(fieldDef.default, fieldDef.type);
146
- const defaultSql = generateDefaultSql(actualDefault, fieldDef.type);
147
-
148
- // 构建约束
149
- const uniqueSql = fieldDef.unique ? " UNIQUE" : "";
150
- const nullableSql = fieldDef.nullable ? " NULL" : " NOT NULL";
151
-
152
- if (isMySQL()) {
153
- return `${isAdd ? "ADD COLUMN" : "MODIFY COLUMN"} \`${dbFieldName}\` ${sqlType}${uniqueSql}${nullableSql}${defaultSql} COMMENT "${escapeComment(fieldDef.name)}"`;
154
- }
155
- if (isPG()) {
156
- if (isAdd) return `ADD COLUMN IF NOT EXISTS "${dbFieldName}" ${sqlType}${uniqueSql}${nullableSql}${defaultSql}`;
157
- // PG 修改:类型与非空可分条执行,生成 TYPE 改变;非空另由上层统一控制
158
- return `ALTER COLUMN "${dbFieldName}" TYPE ${sqlType}`;
159
- }
160
- // SQLite 仅支持 ADD COLUMN(>=3.50.0:支持 IF NOT EXISTS)
161
- if (isAdd) return `ADD COLUMN IF NOT EXISTS "${dbFieldName}" ${sqlType}${uniqueSql}${nullableSql}${defaultSql}`;
162
- return "";
163
- }
164
-
165
- /**
166
- * 安全执行 DDL 语句(MySQL 降级策略)
167
- *
168
- * 执行 DDL 时按以下顺序尝试:
169
- * 1. ALGORITHM=INSTANT (最快,无表锁)
170
- * 2. ALGORITHM=INPLACE (在线 DDL)
171
- * 3. 传统 DDL (可能需要表锁)
172
- *
173
- * @param sql - SQL 客户端实例
174
- * @param stmt - DDL 语句
175
- * @returns 是否执行成功
176
- * @throws {Error} 如果所有尝试都失败
177
- */
178
- export async function executeDDLSafely(sql: SQL, stmt: string): Promise<boolean> {
179
- try {
180
- await sql.unsafe(stmt);
181
- return true;
182
- } catch (error: any) {
183
- // MySQL 专用降级路径
184
- if (stmt.includes("ALGORITHM=INSTANT")) {
185
- const inplaceSql = stmt.replace(/ALGORITHM=INSTANT/g, "ALGORITHM=INPLACE");
186
- try {
187
- await sql.unsafe(inplaceSql);
188
- return true;
189
- } catch {
190
- // 最后尝试传统DDL:移除 ALGORITHM/LOCK 附加子句后执行
191
- const traditionSql = stmt
192
- .replace(/,\s*ALGORITHM=INPLACE/g, "")
193
- .replace(/,\s*ALGORITHM=INSTANT/g, "")
194
- .replace(/,\s*LOCK=(NONE|SHARED|EXCLUSIVE)/g, "");
195
- await sql.unsafe(traditionSql);
196
- return true;
197
- }
198
- } else {
199
- throw error;
200
- }
201
- }
202
- }
203
-
204
- /**
205
- * 判断是否为兼容的类型变更(宽化型变更,无数据丢失风险)
206
- *
207
- * 允许的变更:
208
- * - MySQL: INT -> BIGINT, TINYINT -> INT/BIGINT, etc.
209
- * - MySQL: VARCHAR -> TEXT/MEDIUMTEXT
210
- * - PG: INTEGER -> BIGINT
211
- * - PG: VARCHAR -> TEXT
212
- *
213
- * @param currentType - 当前数据库中的类型
214
- * @param newType - 目标类型
215
- * @returns 是否为兼容变更
216
- */
217
- export function isCompatibleTypeChange(currentType: string, newType: string): boolean {
218
- const c = String(currentType || "").toLowerCase();
219
- const n = String(newType || "").toLowerCase();
220
-
221
- // 相同类型不算变更
222
- if (c === n) return false;
223
-
224
- // 提取基础类型(去掉 unsigned、长度等修饰)
225
- const extractBaseType = (t: string): string => {
226
- // 移除 unsigned 和括号内容
227
- return t
228
- .replace(/\s*unsigned/gi, "")
229
- .replace(/\([^)]*\)/g, "")
230
- .trim();
231
- };
232
-
233
- const cBase = extractBaseType(c);
234
- const nBase = extractBaseType(n);
235
-
236
- // MySQL/通用 整数类型宽化(小 -> 大)
237
- const intTypes = ["tinyint", "smallint", "mediumint", "int", "integer", "bigint"];
238
- const cIntIdx = intTypes.indexOf(cBase);
239
- const nIntIdx = intTypes.indexOf(nBase);
240
- if (cIntIdx !== -1 && nIntIdx !== -1 && nIntIdx > cIntIdx) {
241
- return true;
242
- }
243
-
244
- // 字符串类型宽化
245
- // MySQL: varchar -> text/mediumtext/longtext
246
- if (cBase === "varchar" && (nBase === "text" || nBase === "mediumtext" || nBase === "longtext")) return true;
247
- // PG: character varying -> text
248
- if (cBase === "character varying" && nBase === "text") return true;
249
-
250
- return false;
251
- }
@@ -1,84 +0,0 @@
1
- /**
2
- * syncDb 辅助工具模块
3
- *
4
- * 包含:
5
- * - 标识符引用(反引号/双引号转义)
6
- * - 日志输出格式化
7
- * - 字段默认值应用
8
- */
9
-
10
- import { Logger } from "../../lib/logger.js";
11
- import { isMySQL, isPG } from "./constants.js";
12
-
13
- /**
14
- * 根据数据库类型引用标识符
15
- *
16
- * @param identifier - 标识符(表名、列名等)
17
- * @returns 引用后的标识符
18
- *
19
- * @example
20
- * quoteIdentifier('user_table')
21
- * // MySQL: `user_table`
22
- * // PostgreSQL: "user_table"
23
- * // SQLite: user_table
24
- */
25
- export function quoteIdentifier(identifier: string): string {
26
- if (isMySQL()) return `\`${identifier}\``;
27
- if (isPG()) return `"${identifier}"`;
28
- return identifier; // SQLite 无需引用
29
- }
30
-
31
- /**
32
- * 转义 SQL 注释中的双引号
33
- *
34
- * @param str - 注释字符串
35
- * @returns 转义后的字符串
36
- *
37
- * @example
38
- * escapeComment('用户名称') // => '用户名称'
39
- * escapeComment('用户"昵称"') // => '用户\\"昵称\\"'
40
- */
41
- export function escapeComment(str: string): string {
42
- return String(str).replace(/"/g, '\\"');
43
- }
44
-
45
- /**
46
- * 记录字段变更信息(紧凑格式)
47
- *
48
- * @param tableName - 表名
49
- * @param fieldName - 字段名
50
- * @param changeType - 变更类型(length/datatype/comment/default)
51
- * @param oldValue - 旧值
52
- * @param newValue - 新值
53
- * @param changeLabel - 变更类型的中文标签
54
- */
55
- export function logFieldChange(tableName: string, fieldName: string, changeType: string, oldValue: any, newValue: any, changeLabel: string): void {
56
- Logger.debug(` ~ 修改 ${fieldName} ${changeLabel}: ${oldValue} -> ${newValue}`);
57
- }
58
-
59
- /**
60
- * 格式化字段列表为可读字符串
61
- *
62
- * @param fields - 字段名数组
63
- * @returns 格式化的字符串(逗号分隔)
64
- */
65
- export function formatFieldList(fields: string[]): string {
66
- return fields.map((f) => quoteIdentifier(f)).join(", ");
67
- }
68
-
69
- /**
70
- * 为字段定义应用默认值
71
- *
72
- * @param fieldDef - 字段定义对象
73
- */
74
- export function applyFieldDefaults(fieldDef: any): void {
75
- fieldDef.detail = fieldDef.detail ?? "";
76
- fieldDef.min = fieldDef.min ?? 0;
77
- fieldDef.max = fieldDef.max ?? (fieldDef.type === "number" ? Number.MAX_SAFE_INTEGER : 100);
78
- fieldDef.default = fieldDef.default ?? null;
79
- fieldDef.index = fieldDef.index ?? false;
80
- fieldDef.unique = fieldDef.unique ?? false;
81
- fieldDef.nullable = fieldDef.nullable ?? false;
82
- fieldDef.unsigned = fieldDef.unsigned ?? true;
83
- fieldDef.regexp = fieldDef.regexp ?? null;
84
- }
@@ -1,202 +0,0 @@
1
- /**
2
- * syncDb 表结构查询模块
3
- *
4
- * 包含:
5
- * - 判断表是否存在
6
- * - 获取表的列信息
7
- * - 获取表的索引信息
8
- */
9
-
10
- import type { ColumnInfo, IndexInfo } from "../../types/sync.js";
11
- import type { SQL } from "bun";
12
-
13
- import { isMySQL, isPG, isSQLite } from "./constants.js";
14
-
15
- /**
16
- * 判断表是否存在(返回布尔值)
17
- *
18
- * @param sql - SQL 客户端实例
19
- * @param tableName - 表名
20
- * @param dbName - 数据库名称
21
- * @returns 表是否存在
22
- */
23
- export async function tableExists(sql: SQL, tableName: string, dbName: string): Promise<boolean> {
24
- if (!sql) throw new Error("SQL 客户端未初始化");
25
-
26
- try {
27
- if (isMySQL()) {
28
- const res = await sql`SELECT COUNT(*) AS count FROM information_schema.TABLES WHERE TABLE_SCHEMA = ${dbName} AND TABLE_NAME = ${tableName}`;
29
- return (res[0]?.count || 0) > 0;
30
- }
31
-
32
- if (isPG()) {
33
- const res = await sql`SELECT COUNT(*)::int AS count FROM information_schema.tables WHERE table_schema = 'public' AND table_name = ${tableName}`;
34
- return (res[0]?.count || 0) > 0;
35
- }
36
-
37
- if (isSQLite()) {
38
- const res = await sql`SELECT name FROM sqlite_master WHERE type='table' AND name = ${tableName}`;
39
- return res.length > 0;
40
- }
41
-
42
- return false;
43
- } catch (error: any) {
44
- throw new Error(`查询表是否存在失败 [${tableName}]: ${error.message}`);
45
- }
46
- }
47
-
48
- /**
49
- * 获取表的现有列信息(按方言)
50
- *
51
- * 查询数据库元数据,获取表的所有列信息,包括:
52
- * - 列名
53
- * - 数据类型
54
- * - 字符最大长度
55
- * - 是否可为空
56
- * - 默认值
57
- * - 列注释(MySQL/PG)
58
- *
59
- * @param sql - SQL 客户端实例
60
- * @param tableName - 表名
61
- * @param dbName - 数据库名称
62
- * @returns 列信息对象,键为列名,值为列详情
63
- */
64
- export async function getTableColumns(sql: SQL, tableName: string, dbName: string): Promise<{ [key: string]: ColumnInfo }> {
65
- const columns: { [key: string]: ColumnInfo } = {};
66
-
67
- try {
68
- if (isMySQL()) {
69
- const result = await sql`
70
- SELECT COLUMN_NAME, DATA_TYPE, CHARACTER_MAXIMUM_LENGTH, IS_NULLABLE, COLUMN_DEFAULT, COLUMN_COMMENT, COLUMN_TYPE
71
- FROM information_schema.COLUMNS
72
- WHERE TABLE_SCHEMA = ${dbName} AND TABLE_NAME = ${tableName}
73
- ORDER BY ORDINAL_POSITION
74
- `;
75
- for (const row of result) {
76
- // MySQL 的 COLUMN_DEFAULT 已经是解析后的实际值,无需处理:
77
- // - 空字符串 DEFAULT '': 返回 '' (空字符串)
78
- // - 字符串 DEFAULT 'admin': 返回 admin (无引号)
79
- // - 单引号 DEFAULT '''': 返回 ' (单引号字符)
80
- // - 数字 DEFAULT 0: 返回 0
81
- // - NULL: 返回 null
82
- const defaultValue = row.COLUMN_DEFAULT;
83
-
84
- columns[row.COLUMN_NAME] = {
85
- type: row.DATA_TYPE,
86
- columnType: row.COLUMN_TYPE,
87
- length: row.CHARACTER_MAXIMUM_LENGTH,
88
- max: row.CHARACTER_MAXIMUM_LENGTH,
89
- nullable: row.IS_NULLABLE === "YES",
90
- defaultValue: defaultValue,
91
- comment: row.COLUMN_COMMENT
92
- };
93
- }
94
- } else if (isPG()) {
95
- const result = await sql`
96
- SELECT column_name, data_type, character_maximum_length, is_nullable, column_default
97
- FROM information_schema.columns
98
- WHERE table_schema = 'public' AND table_name = ${tableName}
99
- ORDER BY ordinal_position
100
- `;
101
- // 获取列注释
102
- const comments = await sql`
103
- SELECT a.attname AS column_name, col_description(c.oid, a.attnum) AS column_comment
104
- FROM pg_class c
105
- JOIN pg_attribute a ON a.attrelid = c.oid
106
- JOIN pg_namespace n ON n.oid = c.relnamespace
107
- WHERE c.relkind = 'r' AND n.nspname = 'public' AND c.relname = ${tableName} AND a.attnum > 0
108
- `;
109
- const commentMap: { [key: string]: string } = {};
110
- for (const r of comments) commentMap[r.column_name] = r.column_comment;
111
-
112
- for (const row of result) {
113
- columns[row.column_name] = {
114
- type: row.data_type,
115
- columnType: row.data_type,
116
- length: row.character_maximum_length,
117
- max: row.character_maximum_length,
118
- nullable: String(row.is_nullable).toUpperCase() === "YES",
119
- defaultValue: row.column_default,
120
- comment: commentMap[row.column_name] ?? null
121
- };
122
- }
123
- } else if (isSQLite()) {
124
- const result = await sql.unsafe(`PRAGMA table_info(${tableName})`);
125
- for (const row of result) {
126
- let baseType = String(row.type || "").toUpperCase();
127
- let max = null;
128
- const m = /^(\w+)\s*\((\d+)\)/.exec(baseType);
129
- if (m) {
130
- baseType = m[1];
131
- max = Number(m[2]);
132
- }
133
- columns[row.name] = {
134
- type: baseType.toLowerCase(),
135
- columnType: baseType.toLowerCase(),
136
- length: max,
137
- max: max,
138
- nullable: row.notnull === 0,
139
- defaultValue: row.dflt_value,
140
- comment: null
141
- };
142
- }
143
- }
144
-
145
- return columns;
146
- } catch (error: any) {
147
- throw new Error(`获取表列信息失败 [${tableName}]: ${error.message}`);
148
- }
149
- }
150
-
151
- /**
152
- * 获取表的现有索引信息(单列索引)
153
- *
154
- * @param sql - SQL 客户端实例
155
- * @param tableName - 表名
156
- * @param dbName - 数据库名称
157
- * @returns 索引信息对象,键为索引名,值为列名数组
158
- */
159
- export async function getTableIndexes(sql: SQL, tableName: string, dbName: string): Promise<IndexInfo> {
160
- const indexes: IndexInfo = {};
161
-
162
- try {
163
- if (isMySQL()) {
164
- const result = await sql`
165
- SELECT INDEX_NAME, COLUMN_NAME
166
- FROM information_schema.STATISTICS
167
- WHERE TABLE_SCHEMA = ${dbName}
168
- AND TABLE_NAME = ${tableName}
169
- AND INDEX_NAME != 'PRIMARY'
170
- ORDER BY INDEX_NAME
171
- `;
172
- for (const row of result) {
173
- if (!indexes[row.INDEX_NAME]) indexes[row.INDEX_NAME] = [];
174
- indexes[row.INDEX_NAME].push(row.COLUMN_NAME);
175
- }
176
- } else if (isPG()) {
177
- const result = await sql`
178
- SELECT indexname, indexdef
179
- FROM pg_indexes
180
- WHERE schemaname = 'public' AND tablename = ${tableName}
181
- `;
182
- for (const row of result) {
183
- const m = /\(([^)]+)\)/.exec(row.indexdef);
184
- if (m) {
185
- const col = m[1].replace(/"/g, "").trim();
186
- indexes[row.indexname] = [col];
187
- }
188
- }
189
- } else if (isSQLite()) {
190
- const list = await sql.unsafe(`PRAGMA index_list(${tableName})`);
191
- for (const idx of list) {
192
- const info = await sql.unsafe(`PRAGMA index_info(${idx.name})`);
193
- const cols = info.map((r: any) => r.name);
194
- if (cols.length === 1) indexes[idx.name] = cols;
195
- }
196
- }
197
-
198
- return indexes;
199
- } catch (error: any) {
200
- throw new Error(`获取表索引信息失败 [${tableName}]: ${error.message}`);
201
- }
202
- }