befly 3.15.1 → 3.15.3

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.
@@ -1,350 +1,240 @@
1
1
  /**
2
- * syncTable 命令 - 同步数据库表结构(单文件版)
2
+ * syncTable 命令 - 同步数据库表结构(MySQL 8+ only / 单文件版)
3
3
  *
4
4
  * 说明:
5
5
  * - 历史上该能力拆分在 packages/core/sync/syncTable/* 多个模块中
6
6
  * - 现在按项目要求,将所有实现合并到本文件(目录 packages/core/sync/syncTable/ 已删除)
7
+ * - core 仅支持 MySQL 8.0+
7
8
  */
8
- import { CacheKeys } from "../lib/cacheKeys";
9
- import { getDialectByName, getSyncTableColumnsInfoQuery, getSyncTableIndexesQuery } from "../lib/dbDialect";
10
9
  import { Logger } from "../lib/logger";
11
10
  import { normalizeFieldDefinition } from "../utils/normalizeFieldDefinition";
11
+ import { escapeComment, normalizeColumnDefaultValue } from "../utils/sqlUtil";
12
12
  import { snakeCase } from "../utils/util";
13
- function createRuntimeForIO(dbDialect, db, dbName = "") {
14
- return {
15
- dbDialect: dbDialect,
16
- db: db,
17
- dbName: dbName
18
- };
19
- }
20
- function buildRuntimeIoError(operation, tableName, error) {
21
- const errMsg = String(error?.message || error);
22
- const outErr = new Error(`同步表:读取元信息失败,操作=${operation},表=${tableName},错误=${errMsg}`);
23
- if (error?.sqlInfo)
24
- outErr.sqlInfo = error.sqlInfo;
25
- return outErr;
26
- }
27
13
  /**
28
- * 数据库同步命令入口(函数模式)
14
+ * 数据库同步命令入口(class 模式)
29
15
  */
30
- export const syncTable = (async (ctx, items) => {
31
- try {
32
- // 记录处理过的表名(用于清理缓存)
33
- const processedTables = [];
34
- if (!Array.isArray(items)) {
35
- throw new Error("同步表:请传入多个表定义组成的数组");
36
- }
16
+ export class SyncTable {
17
+ /**
18
+ * 数据库版本要求(MySQL only)
19
+ */
20
+ static DB_VERSION_REQUIREMENTS = {
21
+ MYSQL_MIN_MAJOR: 8
22
+ };
23
+ /**
24
+ * 字段变更类型的中文标签映射
25
+ */
26
+ static CHANGE_TYPE_LABELS = {
27
+ length: "长度",
28
+ datatype: "类型",
29
+ comment: "注释",
30
+ default: "默认值",
31
+ nullable: "可空约束",
32
+ unique: "唯一约束"
33
+ };
34
+ /**
35
+ * MySQL 表配置
36
+ */
37
+ static MYSQL_TABLE_CONFIG = {
38
+ ENGINE: "InnoDB",
39
+ CHARSET: "utf8mb4",
40
+ COLLATE: "utf8mb4_0900_ai_ci"
41
+ };
42
+ static SYSTEM_FIELDS = [
43
+ {
44
+ name: "id",
45
+ comment: "主键ID",
46
+ needsIndex: false,
47
+ mysqlDdl: "BIGINT UNSIGNED PRIMARY KEY AUTO_INCREMENT"
48
+ },
49
+ {
50
+ name: "created_at",
51
+ comment: "创建时间",
52
+ needsIndex: true,
53
+ mysqlDdl: "BIGINT UNSIGNED NOT NULL DEFAULT 0"
54
+ },
55
+ {
56
+ name: "updated_at",
57
+ comment: "更新时间",
58
+ needsIndex: true,
59
+ mysqlDdl: "BIGINT UNSIGNED NOT NULL DEFAULT 0"
60
+ },
61
+ {
62
+ name: "deleted_at",
63
+ comment: "删除时间",
64
+ needsIndex: false,
65
+ mysqlDdl: "BIGINT UNSIGNED NOT NULL DEFAULT 0"
66
+ },
67
+ {
68
+ name: "state",
69
+ comment: "状态字段",
70
+ needsIndex: true,
71
+ mysqlDdl: "BIGINT UNSIGNED NOT NULL DEFAULT 1"
72
+ }
73
+ ];
74
+ static SYSTEM_INDEX_FIELDS = ["created_at", "updated_at", "state"];
75
+ db;
76
+ dbName;
77
+ constructor(ctx) {
37
78
  if (!ctx?.db) {
38
79
  throw new Error("同步表:ctx.db 未初始化");
39
80
  }
40
- if (!ctx.redis) {
41
- throw new Error("同步表:ctx.redis 未初始化");
42
- }
43
81
  if (!ctx.config) {
44
82
  throw new Error("同步表:ctx.config 未初始化");
45
83
  }
46
- if (!ctx.config.db?.dialect) {
47
- throw new Error("同步表:ctx.config.db.dialect 缺失");
48
- }
49
- // DbDialect(按项目约定:正常启动时会先通过 checkConfig,因此这里直接使用配置值)
50
- const dbDialect = ctx.config.db.dialect;
51
- // 检查数据库版本(复用 ctx.db 的现有连接/事务)
52
- await ensureDbVersion(dbDialect, ctx.db);
53
- const databaseName = ctx.config.db?.database || "";
54
- const runtime = {
55
- dbDialect: dbDialect,
56
- db: ctx.db,
57
- dbName: databaseName
58
- };
59
- // 处理传入的 tables 数据(来自 scanSources)
60
- for (const item of items) {
61
- if (!item || item.type !== "table") {
62
- continue;
63
- }
64
- // 确定表名:
65
- // - addon 表:addon_{addonName}_{fileName}
66
- // - app/core 表:{fileName}
67
- const tableName = item.source === "addon" ? `addon_${snakeCase(item.addonName)}_${snakeCase(item.fileName)}` : snakeCase(item.fileName);
68
- const tableFields = item.content;
69
- // 为字段属性设置默认值:表定义来自 JSON/扫描结果,字段可能缺省。
70
- // 缺省会让 diff/DDL 生成出现 undefined vs null 等差异,导致错误的变更判断。
71
- for (const fieldDef of Object.values(tableFields)) {
72
- normalizeFieldDefinitionInPlace(fieldDef);
73
- }
74
- const existsTable = await tableExistsRuntime(runtime, tableName);
75
- if (existsTable) {
76
- await modifyTableRuntime(runtime, tableName, tableFields);
84
+ // 约束:database 相关配置完整性由 checkConfig 统一保证(syncTable 不做重复校验)。
85
+ this.dbName = String(ctx.config.db?.database || "");
86
+ this.db = ctx.db;
87
+ }
88
+ async run(items) {
89
+ try {
90
+ if (!Array.isArray(items)) {
91
+ throw new Error("同步表:请传入多个表定义组成的数组");
77
92
  }
78
- else {
79
- await createTable(runtime, tableName, tableFields);
93
+ await SyncTable.ensureDbVersion(this.db);
94
+ for (const item of items) {
95
+ if (!item || item.type !== "table") {
96
+ continue;
97
+ }
98
+ const tableName = item.source === "addon" ? `addon_${snakeCase(item.addonName)}_${snakeCase(item.fileName)}` : snakeCase(item.fileName);
99
+ const tableFields = item.content;
100
+ // 约束:表结构合法性由 checkTable 统一保证。
101
+ // SyncTable 只做同步执行(尽量避免重复校验逻辑)。
102
+ for (const fieldDef of Object.values(tableFields)) {
103
+ SyncTable.normalizeFieldDefinitionInPlace(fieldDef);
104
+ }
105
+ const existsTable = await SyncTable.tableExists(this.db, this.dbName, tableName);
106
+ if (existsTable) {
107
+ await SyncTable.modifyTable(this.db, this.dbName, tableName, tableFields);
108
+ }
109
+ else {
110
+ await SyncTable.createTable(this.db, tableName, tableFields);
111
+ }
80
112
  }
81
- // 记录处理过的表名(用于清理缓存)
82
- processedTables.push(tableName);
83
113
  }
84
- // 清理 Redis 缓存(如果有表被处理)
85
- if (processedTables.length > 0) {
86
- const cacheKeys = processedTables.map((tableName) => CacheKeys.tableColumns(tableName));
87
- await ctx.redis.delBatch(cacheKeys);
114
+ catch (error) {
115
+ Logger.error({ err: error, msg: "数据库同步失败" });
116
+ throw error;
88
117
  }
89
118
  }
90
- catch (error) {
91
- Logger.error({ err: error, msg: "数据库同步失败" });
92
- throw error;
93
- }
94
- });
95
- /* ========================================================================== */
96
- /* 版本/常量/运行时方言状态 */
97
- /* ========================================================================== */
98
- /**
99
- * 数据库版本要求
100
- */
101
- const DB_VERSION_REQUIREMENTS = {
102
- MYSQL_MIN_MAJOR: 8,
103
- POSTGRES_MIN_MAJOR: 17,
104
- SQLITE_MIN_VERSION: "3.50.0",
105
- SQLITE_MIN_VERSION_NUM: 35000 // 3 * 10000 + 50 * 100
106
- };
107
- /**
108
- * 字段变更类型的中文标签映射
109
- */
110
- const CHANGE_TYPE_LABELS = {
111
- length: "长度",
112
- datatype: "类型",
113
- comment: "注释",
114
- default: "默认值",
115
- nullable: "可空约束",
116
- unique: "唯一约束"
117
- };
118
- /**
119
- * MySQL 表配置
120
- */
121
- const MYSQL_TABLE_CONFIG = {
122
- ENGINE: "InnoDB",
123
- CHARSET: "utf8mb4",
124
- COLLATE: "utf8mb4_0900_ai_ci"
125
- };
126
- /**
127
- * 系统字段定义:三处会用到
128
- * - createTable:建表时追加系统字段列定义
129
- * - modifyTable:对已存在的表补齐缺失的系统字段
130
- * - SYSTEM_INDEX_FIELDS:从 needsIndex 派生默认系统索引集合
131
- */
132
- const SYSTEM_FIELDS = [
133
- {
134
- name: "id",
135
- comment: "主键ID",
136
- needsIndex: false,
137
- mysqlDdl: "BIGINT UNSIGNED PRIMARY KEY AUTO_INCREMENT",
138
- pgDdl: "BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY",
139
- sqliteDdl: "INTEGER PRIMARY KEY"
140
- },
141
- {
142
- name: "created_at",
143
- comment: "创建时间",
144
- needsIndex: true,
145
- mysqlDdl: "BIGINT UNSIGNED NOT NULL DEFAULT 0",
146
- pgDdl: "BIGINT NOT NULL DEFAULT 0",
147
- sqliteDdl: "INTEGER NOT NULL DEFAULT 0"
148
- },
149
- {
150
- name: "updated_at",
151
- comment: "更新时间",
152
- needsIndex: true,
153
- mysqlDdl: "BIGINT UNSIGNED NOT NULL DEFAULT 0",
154
- pgDdl: "BIGINT NOT NULL DEFAULT 0",
155
- sqliteDdl: "INTEGER NOT NULL DEFAULT 0"
156
- },
157
- {
158
- name: "deleted_at",
159
- comment: "删除时间",
160
- needsIndex: false,
161
- mysqlDdl: "BIGINT UNSIGNED NOT NULL DEFAULT 0",
162
- pgDdl: "BIGINT NOT NULL DEFAULT 0",
163
- sqliteDdl: "INTEGER NOT NULL DEFAULT 0"
164
- },
165
- {
166
- name: "state",
167
- comment: "状态字段",
168
- needsIndex: true,
169
- mysqlDdl: "BIGINT UNSIGNED NOT NULL DEFAULT 1",
170
- pgDdl: "BIGINT NOT NULL DEFAULT 1",
171
- sqliteDdl: "INTEGER NOT NULL DEFAULT 1"
119
+ /* ====================================================================== */
120
+ /* 内部工具(所有函数都挂在一个 class 上) */
121
+ /* ====================================================================== */
122
+ /* ---------------------------------------------------------------------- */
123
+ /* 错误封装(runtime IO 失败时补充上下文信息) */
124
+ /* ---------------------------------------------------------------------- */
125
+ static buildRuntimeIoError(operation, tableName, error) {
126
+ const errMsg = String(error?.message || error);
127
+ const outErr = new Error(`同步表:读取元信息失败,操作=${operation},表=${tableName},错误=${errMsg}`);
128
+ if (error?.sqlInfo)
129
+ outErr.sqlInfo = error.sqlInfo;
130
+ return outErr;
131
+ }
132
+ /* ---------------------------------------------------------------------- */
133
+ /* 类型映射(field type -> mysql type) */
134
+ /* ---------------------------------------------------------------------- */
135
+ static getTypeMapping() {
136
+ return {
137
+ number: "BIGINT",
138
+ string: "VARCHAR",
139
+ text: "MEDIUMTEXT",
140
+ array_string: "VARCHAR",
141
+ array_text: "MEDIUMTEXT",
142
+ array_number_string: "VARCHAR",
143
+ array_number_text: "MEDIUMTEXT"
144
+ };
172
145
  }
173
- ];
174
- /**
175
- * 需要创建索引的系统字段
176
- */
177
- const SYSTEM_INDEX_FIELDS = SYSTEM_FIELDS.filter((f) => f.needsIndex).map((f) => f.name);
178
- const SYSTEM_FIELD_META_MAP = {};
179
- for (const f of SYSTEM_FIELDS) {
180
- SYSTEM_FIELD_META_MAP[f.name] = f;
181
- }
182
- const SYNC_TABLE_TEST_KIT = {
183
- DB_VERSION_REQUIREMENTS: DB_VERSION_REQUIREMENTS,
184
- CHANGE_TYPE_LABELS: CHANGE_TYPE_LABELS,
185
- MYSQL_TABLE_CONFIG: MYSQL_TABLE_CONFIG,
186
- SYSTEM_INDEX_FIELDS: SYSTEM_INDEX_FIELDS,
187
- getTypeMapping: getTypeMapping,
188
- quoteIdentifier: quoteIdentifier,
189
- escapeComment: escapeComment,
190
- normalizeFieldDefinitionInPlace: normalizeFieldDefinitionInPlace,
191
- isStringOrArrayType: isStringOrArrayType,
192
- getSqlType: getSqlType,
193
- resolveDefaultValue: resolveDefaultValue,
194
- generateDefaultSql: generateDefaultSql,
195
- buildIndexSQL: buildIndexSQL,
196
- buildSystemColumnDefs: buildSystemColumnDefs,
197
- buildBusinessColumnDefs: buildBusinessColumnDefs,
198
- generateDDLClause: generateDDLClause,
199
- isCompatibleTypeChange: isCompatibleTypeChange,
200
- compareFieldDefinition: compareFieldDefinition,
201
- tableExistsRuntime: tableExistsRuntime,
202
- getTableColumnsRuntime: getTableColumnsRuntime,
203
- getTableIndexesRuntime: getTableIndexesRuntime,
204
- createRuntime: createRuntimeForIO
205
- };
206
- // 测试能力挂载(避免导出零散函数,同时确保运行时存在)
207
- syncTable.TestKit = SYNC_TABLE_TEST_KIT;
208
- // 防御性:避免运行时被误覆盖(只读),但仍保持可枚举/可访问。
209
- Object.defineProperty(syncTable, "TestKit", {
210
- value: SYNC_TABLE_TEST_KIT,
211
- writable: false,
212
- enumerable: true,
213
- configurable: false
214
- });
215
- /**
216
- * 获取字段类型映射(根据当前数据库类型)
217
- */
218
- function getTypeMapping(dbDialect) {
219
- return {
220
- number: dbDialect === "sqlite" ? "INTEGER" : dbDialect === "postgresql" ? "BIGINT" : "BIGINT",
221
- string: dbDialect === "sqlite" ? "TEXT" : dbDialect === "postgresql" ? "character varying" : "VARCHAR",
222
- text: dbDialect === "mysql" ? "MEDIUMTEXT" : "TEXT",
223
- array_string: dbDialect === "sqlite" ? "TEXT" : dbDialect === "postgresql" ? "character varying" : "VARCHAR",
224
- array_text: dbDialect === "mysql" ? "MEDIUMTEXT" : "TEXT",
225
- array_number_string: dbDialect === "sqlite" ? "TEXT" : dbDialect === "postgresql" ? "character varying" : "VARCHAR",
226
- array_number_text: dbDialect === "mysql" ? "MEDIUMTEXT" : "TEXT"
227
- };
228
- }
229
- /* ========================================================================== */
230
- /* 通用工具与 DDL 片段生成 */
231
- /* ========================================================================== */
232
- /**
233
- * 根据数据库类型引用标识符
234
- */
235
- function quoteIdentifier(dbDialect, identifier) {
236
- return getDialectByName(dbDialect).quoteIdent(identifier);
237
- }
238
- /**
239
- * 转义 SQL 注释中的双引号
240
- */
241
- function escapeComment(str) {
242
- return String(str).replace(/"/g, '\\"');
243
- }
244
- function normalizeColumnDefaultValue(value) {
245
- if (value === null)
246
- return null;
247
- if (typeof value === "string" || typeof value === "number" || typeof value === "boolean")
248
- return value;
249
- if (Array.isArray(value)) {
250
- const items = [];
251
- for (const v of value) {
252
- items.push(normalizeColumnDefaultValue(v));
146
+ /* ---------------------------------------------------------------------- */
147
+ /* 默认值 & 字段定义规范化(与 checkTable 的“合法性 gate”配合) */
148
+ /* ---------------------------------------------------------------------- */
149
+ static normalizeFieldDefinitionInPlace(fieldDef) {
150
+ const normalized = normalizeFieldDefinition(fieldDef);
151
+ fieldDef.detail = normalized.detail;
152
+ fieldDef.min = normalized.min;
153
+ fieldDef.max = normalized.max;
154
+ fieldDef.default = normalized.default;
155
+ fieldDef.index = normalized.index;
156
+ fieldDef.unique = normalized.unique;
157
+ fieldDef.nullable = normalized.nullable;
158
+ fieldDef.unsigned = normalized.unsigned;
159
+ fieldDef.regexp = normalized.regexp;
160
+ }
161
+ static isStringOrArrayType(fieldType) {
162
+ return fieldType === "string" || fieldType === "array_string" || fieldType === "array_number_string";
163
+ }
164
+ /**
165
+ * MySQL 标识符安全包裹:仅允许 [a-zA-Z_][a-zA-Z0-9_]*,并用反引号包裹。
166
+ *
167
+ * 说明:
168
+ * - 这是 syncTable 内部用于拼接 DDL 的安全阀
169
+ * - 若未来需要支持更复杂的标识符(如关键字/点号/反引号转义),应在此处统一扩展并补测试
170
+ */
171
+ static quoteIdentifier(identifier) {
172
+ if (typeof identifier !== "string") {
173
+ throw new Error(`quoteIdentifier 需要字符串类型标识符 (identifier: ${String(identifier)})`);
174
+ }
175
+ const trimmed = identifier.trim();
176
+ if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(trimmed)) {
177
+ throw new Error(`无效的 SQL 标识符: ${trimmed}`);
178
+ }
179
+ return `\`${trimmed}\``;
180
+ }
181
+ static getSqlType(fieldType, fieldMax, unsigned = false) {
182
+ const typeMapping = SyncTable.getTypeMapping();
183
+ if (SyncTable.isStringOrArrayType(fieldType)) {
184
+ // 约束由 checkTable 统一保证;这里仅做最小断言,避免生成明显无效的 SQL。
185
+ if (typeof fieldMax !== "number") {
186
+ throw new Error(`同步表:内部错误:${fieldType} 类型缺失 max(应由 checkTable 阻断)`);
187
+ }
188
+ return `${typeMapping[fieldType]}(${fieldMax})`;
253
189
  }
254
- return items;
255
- }
256
- return String(value);
257
- }
258
- // 注意:这里刻意不封装“logFieldChange/formatFieldList”之类的一次性工具函数,
259
- // 以减少抽象层级(按项目要求:能直写就直写)。
260
- /**
261
- * 为字段定义应用默认值(就地归一化)
262
- */
263
- function normalizeFieldDefinitionInPlace(fieldDef) {
264
- const normalized = normalizeFieldDefinition(fieldDef);
265
- fieldDef.detail = normalized.detail;
266
- fieldDef.min = normalized.min;
267
- fieldDef.max = normalized.max;
268
- fieldDef.default = normalized.default;
269
- fieldDef.index = normalized.index;
270
- fieldDef.unique = normalized.unique;
271
- fieldDef.nullable = normalized.nullable;
272
- fieldDef.unsigned = normalized.unsigned;
273
- fieldDef.regexp = normalized.regexp;
274
- }
275
- /**
276
- * 判断是否为字符串或数组类型(需要长度参数)
277
- */
278
- function isStringOrArrayType(fieldType) {
279
- return fieldType === "string" || fieldType === "array_string" || fieldType === "array_number_string";
280
- }
281
- /**
282
- * 获取 SQL 数据类型
283
- */
284
- function getSqlType(dbDialect, fieldType, fieldMax, unsigned = false) {
285
- const typeMapping = getTypeMapping(dbDialect);
286
- if (isStringOrArrayType(fieldType)) {
287
- return `${typeMapping[fieldType]}(${fieldMax})`;
288
- }
289
- const baseType = typeMapping[fieldType] || "TEXT";
290
- if (dbDialect === "mysql" && fieldType === "number" && unsigned) {
291
- return `${baseType} UNSIGNED`;
292
- }
293
- return baseType;
294
- }
295
- /**
296
- * 处理默认值:将 null 或 'null' 字符串转换为对应类型的默认值
297
- */
298
- function resolveDefaultValue(fieldDefault, fieldType) {
299
- if (fieldDefault !== null && fieldDefault !== "null") {
300
- return fieldDefault;
190
+ const baseType = typeMapping[fieldType] || "TEXT";
191
+ if (fieldType === "number" && unsigned) {
192
+ return `${baseType} UNSIGNED`;
193
+ }
194
+ return baseType;
301
195
  }
302
- switch (fieldType) {
303
- case "number":
304
- return 0;
305
- case "string":
306
- return "";
307
- case "array_string":
308
- case "array_number_string":
309
- return "[]";
310
- case "text":
311
- case "array_text":
312
- case "array_number_text":
313
- return "null";
314
- default:
196
+ static resolveDefaultValue(fieldDefault, fieldType) {
197
+ if (fieldDefault !== null && fieldDefault !== "null") {
315
198
  return fieldDefault;
316
- }
317
- }
318
- /**
319
- * 生成 SQL DEFAULT 子句
320
- */
321
- function generateDefaultSql(actualDefault, fieldType) {
322
- if (fieldType === "text" || fieldType === "array_text" || actualDefault === "null") {
323
- return "";
324
- }
325
- if (fieldType === "number" || fieldType === "string" || fieldType === "array_string" || fieldType === "array_number_string") {
326
- if (typeof actualDefault === "number" && !Number.isNaN(actualDefault)) {
327
- return ` DEFAULT ${actualDefault}`;
328
199
  }
329
- else {
330
- const escaped = String(actualDefault).replace(/'/g, "''");
331
- return ` DEFAULT '${escaped}'`;
200
+ switch (fieldType) {
201
+ case "number":
202
+ return 0;
203
+ case "string":
204
+ return "";
205
+ case "array_string":
206
+ case "array_number_string":
207
+ return "[]";
208
+ case "text":
209
+ case "array_text":
210
+ case "array_number_text":
211
+ return "null";
212
+ default:
213
+ return fieldDefault;
214
+ }
215
+ }
216
+ static generateDefaultSql(actualDefault, fieldType) {
217
+ if (fieldType === "text" || fieldType === "array_text" || actualDefault === "null") {
218
+ return "";
332
219
  }
220
+ if (fieldType === "number" || fieldType === "string" || fieldType === "array_string" || fieldType === "array_number_string") {
221
+ if (typeof actualDefault === "number" && !Number.isNaN(actualDefault)) {
222
+ return ` DEFAULT ${actualDefault}`;
223
+ }
224
+ else {
225
+ const escaped = String(actualDefault).replace(/'/g, "''");
226
+ return ` DEFAULT '${escaped}'`;
227
+ }
228
+ }
229
+ return "";
333
230
  }
334
- return "";
335
- }
336
- /**
337
- * 构建索引操作 SQL(统一使用在线策略)
338
- */
339
- function buildIndexSQL(dbDialect, tableName, indexName, fieldName, action) {
340
- // 说明(策略取舍):
341
- // - MySQL:通过 ALTER TABLE 在线添加/删除索引;配合 ALGORITHM/LOCK 以降低阻塞。
342
- // - PostgreSQL:CREATE/DROP INDEX CONCURRENTLY 尽量减少锁表(代价是执行更慢/有并发限制)。
343
- // - SQLite:DDL 能力有限,使用 IF NOT EXISTS/IF EXISTS 尽量做到幂等。
344
- const tableQuoted = quoteIdentifier(dbDialect, tableName);
345
- const indexQuoted = quoteIdentifier(dbDialect, indexName);
346
- const fieldQuoted = quoteIdentifier(dbDialect, fieldName);
347
- if (dbDialect === "mysql") {
231
+ /* ---------------------------------------------------------------------- */
232
+ /* DDL 片段生成(列/索引/ALTER clause) */
233
+ /* ---------------------------------------------------------------------- */
234
+ static buildIndexSQL(tableName, indexName, fieldName, action) {
235
+ const tableQuoted = SyncTable.quoteIdentifier(tableName);
236
+ const indexQuoted = SyncTable.quoteIdentifier(indexName);
237
+ const fieldQuoted = SyncTable.quoteIdentifier(fieldName);
348
238
  const parts = [];
349
239
  if (action === "create") {
350
240
  parts.push(`ADD INDEX ${indexQuoted} (${fieldQuoted})`);
@@ -354,230 +244,209 @@ function buildIndexSQL(dbDialect, tableName, indexName, fieldName, action) {
354
244
  }
355
245
  return `ALTER TABLE ${tableQuoted} ALGORITHM=INPLACE, LOCK=NONE, ${parts.join(", ")}`;
356
246
  }
357
- if (dbDialect === "postgresql") {
358
- if (action === "create") {
359
- return `CREATE INDEX CONCURRENTLY IF NOT EXISTS ${indexQuoted} ON ${tableQuoted}(${fieldQuoted})`;
247
+ static getSystemColumnDef(fieldName) {
248
+ let meta = null;
249
+ for (const f of SyncTable.SYSTEM_FIELDS) {
250
+ if (f.name === fieldName) {
251
+ meta = f;
252
+ break;
253
+ }
360
254
  }
361
- return `DROP INDEX CONCURRENTLY IF EXISTS ${indexQuoted}`;
362
- }
363
- if (action === "create") {
364
- return `CREATE INDEX IF NOT EXISTS ${indexQuoted} ON ${tableQuoted}(${fieldQuoted})`;
365
- }
366
- return `DROP INDEX IF EXISTS ${indexQuoted}`;
367
- }
368
- /**
369
- * 获取单个系统字段的列定义(用于 ADD COLUMN 或 CREATE TABLE)
370
- */
371
- function getSystemColumnDef(dbDialect, fieldName) {
372
- const meta = SYSTEM_FIELD_META_MAP[fieldName];
373
- if (!meta)
374
- return null;
375
- const colQuoted = quoteIdentifier(dbDialect, meta.name);
376
- if (dbDialect === "mysql") {
255
+ if (!meta)
256
+ return null;
257
+ const colQuoted = SyncTable.quoteIdentifier(meta.name);
377
258
  return `${colQuoted} ${meta.mysqlDdl} COMMENT "${escapeComment(meta.comment)}"`;
378
259
  }
379
- if (dbDialect === "postgresql") {
380
- return `${colQuoted} ${meta.pgDdl}`;
260
+ static buildSystemColumnDefs() {
261
+ const defs = [];
262
+ for (const f of SyncTable.SYSTEM_FIELDS) {
263
+ const d = SyncTable.getSystemColumnDef(f.name);
264
+ if (d)
265
+ defs.push(d);
266
+ }
267
+ return defs;
381
268
  }
382
- return `${colQuoted} ${meta.sqliteDdl}`;
383
- }
384
- /**
385
- * 构建系统字段列定义
386
- */
387
- function buildSystemColumnDefs(dbDialect) {
388
- const defs = [];
389
- for (const f of SYSTEM_FIELDS) {
390
- const d = getSystemColumnDef(dbDialect, f.name);
391
- if (d)
392
- defs.push(d);
269
+ static buildBusinessColumnDefs(fields) {
270
+ const colDefs = [];
271
+ for (const [fieldKey, fieldDef] of Object.entries(fields)) {
272
+ const normalized = normalizeFieldDefinition(fieldDef);
273
+ const dbFieldName = snakeCase(fieldKey);
274
+ const colQuoted = SyncTable.quoteIdentifier(dbFieldName);
275
+ const sqlType = SyncTable.getSqlType(normalized.type, normalized.max, normalized.unsigned);
276
+ const actualDefault = SyncTable.resolveDefaultValue(normalized.default, normalized.type);
277
+ const defaultSql = SyncTable.generateDefaultSql(actualDefault, normalized.type);
278
+ const uniqueSql = normalized.unique ? " UNIQUE" : "";
279
+ const nullableSql = normalized.nullable ? " NULL" : " NOT NULL";
280
+ colDefs.push(`${colQuoted} ${sqlType}${uniqueSql}${nullableSql}${defaultSql} COMMENT "${escapeComment(normalized.name)}"`);
281
+ }
282
+ return colDefs;
393
283
  }
394
- return defs;
395
- }
396
- /**
397
- * 构建业务字段列定义
398
- */
399
- function buildBusinessColumnDefs(dbDialect, fields) {
400
- const colDefs = [];
401
- for (const [fieldKey, fieldDef] of Object.entries(fields)) {
402
- const normalized = normalizeFieldDefinition(fieldDef);
284
+ static generateDDLClause(fieldKey, fieldDef, isAdd = false) {
403
285
  const dbFieldName = snakeCase(fieldKey);
404
- const colQuoted = quoteIdentifier(dbDialect, dbFieldName);
405
- const sqlType = getSqlType(dbDialect, normalized.type, normalized.max, normalized.unsigned);
406
- const actualDefault = resolveDefaultValue(normalized.default, normalized.type);
407
- const defaultSql = generateDefaultSql(actualDefault, normalized.type);
286
+ const colQuoted = SyncTable.quoteIdentifier(dbFieldName);
287
+ const normalized = normalizeFieldDefinition(fieldDef);
288
+ const sqlType = SyncTable.getSqlType(normalized.type, normalized.max, normalized.unsigned);
289
+ const actualDefault = SyncTable.resolveDefaultValue(normalized.default, normalized.type);
290
+ const defaultSql = SyncTable.generateDefaultSql(actualDefault, normalized.type);
408
291
  const uniqueSql = normalized.unique ? " UNIQUE" : "";
409
292
  const nullableSql = normalized.nullable ? " NULL" : " NOT NULL";
410
- if (dbDialect === "mysql") {
411
- colDefs.push(`${colQuoted} ${sqlType}${uniqueSql}${nullableSql}${defaultSql} COMMENT "${escapeComment(normalized.name)}"`);
412
- }
413
- else {
414
- colDefs.push(`${colQuoted} ${sqlType}${uniqueSql}${nullableSql}${defaultSql}`);
415
- }
416
- }
417
- return colDefs;
418
- }
419
- /**
420
- * 生成字段 DDL 子句(不含 ALTER TABLE 前缀)
421
- */
422
- function generateDDLClause(dbDialect, fieldKey, fieldDef, isAdd = false) {
423
- // 说明(策略取舍):
424
- // - MySQL:ADD/MODIFY 一条子句内可同时表达类型/可空/默认值/注释(同步成本低)。
425
- // - PostgreSQL:modify 场景这里仅生成 TYPE 变更;默认值/注释等由其他子句或 commentActions 处理。
426
- // - SQLite:不支持标准化的 MODIFY COLUMN,这里仅提供 ADD COLUMN;复杂变更通过 rebuildSqliteTable 完成。
427
- const dbFieldName = snakeCase(fieldKey);
428
- const colQuoted = quoteIdentifier(dbDialect, dbFieldName);
429
- const normalized = normalizeFieldDefinition(fieldDef);
430
- const sqlType = getSqlType(dbDialect, normalized.type, normalized.max, normalized.unsigned);
431
- const actualDefault = resolveDefaultValue(normalized.default, normalized.type);
432
- const defaultSql = generateDefaultSql(actualDefault, normalized.type);
433
- const uniqueSql = normalized.unique ? " UNIQUE" : "";
434
- const nullableSql = normalized.nullable ? " NULL" : " NOT NULL";
435
- if (dbDialect === "mysql") {
436
293
  return `${isAdd ? "ADD COLUMN" : "MODIFY COLUMN"} ${colQuoted} ${sqlType}${uniqueSql}${nullableSql}${defaultSql} COMMENT "${escapeComment(normalized.name)}"`;
437
294
  }
438
- if (dbDialect === "postgresql") {
439
- if (isAdd)
440
- return `ADD COLUMN IF NOT EXISTS ${colQuoted} ${sqlType}${uniqueSql}${nullableSql}${defaultSql}`;
441
- return `ALTER COLUMN ${colQuoted} TYPE ${sqlType}`;
442
- }
443
- if (isAdd)
444
- return `ADD COLUMN IF NOT EXISTS ${colQuoted} ${sqlType}${uniqueSql}${nullableSql}${defaultSql}`;
445
- return "";
446
- }
447
- /**
448
- * 安全执行 DDL 语句(MySQL 降级策略)
449
- */
450
- async function executeDDLSafely(db, stmt) {
451
- // MySQL DDL 兼容性/可用性兜底:
452
- // - 优先执行原语句(通常含 ALGORITHM=INSTANT)。
453
- // - 若 INSTANT 不可用(版本/表结构限制),降级为 INPLACE 再试。
454
- // - 若仍失败,去掉 ALGORITHM/LOCK 提示字段,以最大兼容性执行传统 ALTER。
455
- try {
456
- await db.unsafe(stmt);
457
- return true;
458
- }
459
- catch (error) {
460
- if (stmt.includes("ALGORITHM=INSTANT")) {
461
- const inplaceSql = stmt.replace(/ALGORITHM=INSTANT/g, "ALGORITHM=INPLACE");
462
- try {
463
- await db.unsafe(inplaceSql);
464
- return true;
465
- }
466
- catch {
467
- let traditionSql = stmt;
468
- traditionSql = traditionSql.replace(/\bALGORITHM\s*=\s*(INPLACE|INSTANT)\b\s*,?\s*/g, "").replace(/\bLOCK\s*=\s*(NONE|SHARED|EXCLUSIVE)\b\s*,?\s*/g, "");
469
- traditionSql = traditionSql
470
- .replace(/,\s*,/g, ", ")
471
- .replace(/,\s*$/g, "")
472
- .replace(/\s{2,}/g, " ")
473
- .trim();
474
- await db.unsafe(traditionSql);
475
- return true;
476
- }
295
+ /* ---------------------------------------------------------------------- */
296
+ /* DDL 执行(失败时降级 algorithm/lock) */
297
+ /* ---------------------------------------------------------------------- */
298
+ static async executeDDLSafely(db, stmt) {
299
+ try {
300
+ await db.unsafe(stmt);
301
+ return true;
477
302
  }
478
- else {
303
+ catch (error) {
304
+ if (stmt.includes("ALGORITHM=INSTANT")) {
305
+ const inplaceSql = stmt.replace(/ALGORITHM=INSTANT/g, "ALGORITHM=INPLACE");
306
+ try {
307
+ await db.unsafe(inplaceSql);
308
+ return true;
309
+ }
310
+ catch {
311
+ let traditionSql = stmt;
312
+ traditionSql = traditionSql.replace(/\bALGORITHM\s*=\s*(INPLACE|INSTANT)\b\s*,?\s*/g, "").replace(/\bLOCK\s*=\s*(NONE|SHARED|EXCLUSIVE)\b\s*,?\s*/g, "");
313
+ traditionSql = traditionSql
314
+ .replace(/,\s*,/g, ", ")
315
+ .replace(/,\s*$/g, "")
316
+ .replace(/\s{2,}/g, " ")
317
+ .trim();
318
+ await db.unsafe(traditionSql);
319
+ return true;
320
+ }
321
+ }
479
322
  throw error;
480
323
  }
481
324
  }
482
- }
483
- /**
484
- * 判断是否为兼容的类型变更(宽化型变更,无数据丢失风险)
485
- */
486
- function isCompatibleTypeChange(currentType, newType) {
487
- // 说明:该函数用于“自动同步”里的安全阈值判断。
488
- // - 允许:宽化型变更(不收缩、不改变语义大类),例如:
489
- // - INT -> BIGINT(或 tinyint/smallint/mediumint -> 更宽的整型)
490
- // - VARCHAR -> TEXT/MEDIUMTEXT/LONGTEXT
491
- // - character varying -> text(PG 常见)
492
- // - 禁止:收缩型变更(BIGINT -> INT、TEXT -> VARCHAR)以及跨大类变更(需人工评估/迁移)。
493
- const c = String(currentType || "").toLowerCase();
494
- const n = String(newType || "").toLowerCase();
495
- if (c === n)
325
+ /* ---------------------------------------------------------------------- */
326
+ /* 对比/规则:判断字段是否需要变更,以及哪些变更是“兼容允许”的 */
327
+ /* ---------------------------------------------------------------------- */
328
+ static isCompatibleTypeChange(currentType, newType) {
329
+ const c = String(currentType || "").toLowerCase();
330
+ const n = String(newType || "").toLowerCase();
331
+ if (c === n)
332
+ return false;
333
+ const cBase = c
334
+ .replace(/\s*unsigned/gi, "")
335
+ .replace(/\([^)]*\)/g, "")
336
+ .trim();
337
+ const nBase = n
338
+ .replace(/\s*unsigned/gi, "")
339
+ .replace(/\([^)]*\)/g, "")
340
+ .trim();
341
+ const intTypes = ["tinyint", "smallint", "mediumint", "int", "integer", "bigint"];
342
+ const cIntIdx = intTypes.indexOf(cBase);
343
+ const nIntIdx = intTypes.indexOf(nBase);
344
+ if (cIntIdx !== -1 && nIntIdx !== -1 && nIntIdx > cIntIdx) {
345
+ return true;
346
+ }
347
+ if (cBase === "varchar" && (nBase === "text" || nBase === "mediumtext" || nBase === "longtext"))
348
+ return true;
496
349
  return false;
497
- const cBase = c
498
- .replace(/\s*unsigned/gi, "")
499
- .replace(/\([^)]*\)/g, "")
500
- .trim();
501
- const nBase = n
502
- .replace(/\s*unsigned/gi, "")
503
- .replace(/\([^)]*\)/g, "")
504
- .trim();
505
- const intTypes = ["tinyint", "smallint", "mediumint", "int", "integer", "bigint"];
506
- const cIntIdx = intTypes.indexOf(cBase);
507
- const nIntIdx = intTypes.indexOf(nBase);
508
- if (cIntIdx !== -1 && nIntIdx !== -1 && nIntIdx > cIntIdx) {
509
- return true;
510
- }
511
- if (cBase === "varchar" && (nBase === "text" || nBase === "mediumtext" || nBase === "longtext"))
512
- return true;
513
- if (cBase === "character varying" && nBase === "text")
514
- return true;
515
- return false;
516
- }
517
- /* ========================================================================== */
518
- /* runtime I/O(只读:读库/元信息查询)
519
- *
520
- * 说明:
521
- * - 本区块只负责“查询元信息”(表/列/索引/版本)。
522
- * - 写变更(DDL 执行)统一在下方 plan/apply 区块中完成。
523
- * - 对外不再保留 dbDialect/db/dbName 形式的 wrapper;统一使用 runtime 形态(更直写)。
524
- */
525
- /* ========================================================================== */
526
- // ---------------------------------------------------------------------------
527
- // 读:表是否存在
528
- // ---------------------------------------------------------------------------
529
- async function tableExistsRuntime(runtime, tableName) {
530
- try {
531
- // 统一交由方言层构造 SQL;syncTable 仅决定“要查哪个 schema/db”。
532
- // - MySQL:传 runtime.dbName(information_schema.table_schema)
533
- // - PostgreSQL:固定 public(项目约定)
534
- // - SQLite:忽略 schema
535
- const schema = runtime.dbDialect === "mysql" ? runtime.dbName : runtime.dbDialect === "postgresql" ? "public" : undefined;
536
- const q = getDialectByName(runtime.dbDialect).tableExistsQuery(tableName, schema);
537
- const res = await runtime.db.unsafe(q.sql, q.params);
538
- return (res.data?.[0]?.count || 0) > 0;
539
350
  }
540
- catch (error) {
541
- throw buildRuntimeIoError("检查表是否存在", tableName, error);
351
+ static compareFieldDefinition(existingColumn, fieldDef) {
352
+ const changes = [];
353
+ const normalized = normalizeFieldDefinition(fieldDef);
354
+ if (SyncTable.isStringOrArrayType(normalized.type)) {
355
+ const expectedMax = normalized.max;
356
+ if (expectedMax !== null && existingColumn.max !== expectedMax) {
357
+ changes.push({
358
+ type: "length",
359
+ current: existingColumn.max,
360
+ expected: expectedMax
361
+ });
362
+ }
363
+ }
364
+ const currentComment = existingColumn.comment === null || existingColumn.comment === undefined ? "" : String(existingColumn.comment);
365
+ if (currentComment !== normalized.name) {
366
+ changes.push({
367
+ type: "comment",
368
+ current: currentComment,
369
+ expected: normalized.name
370
+ });
371
+ }
372
+ const typeMapping = SyncTable.getTypeMapping();
373
+ const mapped = typeMapping[normalized.type];
374
+ if (typeof mapped !== "string") {
375
+ throw new Error(`同步表:未知字段类型映射(类型=${String(normalized.type)})`);
376
+ }
377
+ const expectedType = mapped.toLowerCase();
378
+ let rawType = "";
379
+ if (typeof existingColumn.type === "string" && existingColumn.type.trim() !== "") {
380
+ rawType = existingColumn.type;
381
+ }
382
+ else if (typeof existingColumn.columnType === "string" && existingColumn.columnType.trim() !== "") {
383
+ rawType = existingColumn.columnType;
384
+ }
385
+ else {
386
+ rawType = String(existingColumn.type ?? "");
387
+ }
388
+ const currentType = rawType
389
+ .toLowerCase()
390
+ .replace(/\s*unsigned/gi, "")
391
+ .replace(/\([^)]*\)/g, "")
392
+ .trim();
393
+ if (currentType !== expectedType) {
394
+ changes.push({
395
+ type: "datatype",
396
+ current: currentType,
397
+ expected: expectedType
398
+ });
399
+ }
400
+ const expectedNullable = normalized.nullable;
401
+ if (existingColumn.nullable !== expectedNullable) {
402
+ changes.push({
403
+ type: "nullable",
404
+ current: existingColumn.nullable,
405
+ expected: expectedNullable
406
+ });
407
+ }
408
+ const expectedDefault = SyncTable.resolveDefaultValue(normalized.default, normalized.type);
409
+ if (String(existingColumn.defaultValue) !== String(expectedDefault)) {
410
+ changes.push({
411
+ type: "default",
412
+ current: existingColumn.defaultValue,
413
+ expected: expectedDefault
414
+ });
415
+ }
416
+ return changes;
417
+ }
418
+ /* ---------------------------------------------------------------------- */
419
+ /* information_schema 查询:读取表/列/索引元信息 */
420
+ /* ---------------------------------------------------------------------- */
421
+ /**
422
+ * 只读查询 information_schema.TABLES,用于判断表是否存在。
423
+ * - 该方法不会执行 DDL,不会修改数据库结构
424
+ * - 失败时会包装错误信息(含 tableName / operation)以便排查
425
+ */
426
+ static async tableExists(db, dbName, tableName) {
427
+ try {
428
+ const res = await db.unsafe("SELECT COUNT(*) as count FROM information_schema.TABLES WHERE TABLE_SCHEMA = ? AND TABLE_NAME = ?", [dbName, tableName]);
429
+ return (res.data?.[0]?.count || 0) > 0;
430
+ }
431
+ catch (error) {
432
+ throw SyncTable.buildRuntimeIoError("检查表是否存在", tableName, error);
433
+ }
542
434
  }
543
- }
544
- // ---------------------------------------------------------------------------
545
- // 读:列信息
546
- // ---------------------------------------------------------------------------
547
- async function getTableColumnsRuntime(runtime, tableName) {
548
- // 返回的列数据示例
549
- // [{
550
- // is_system: {
551
- // type: "bigint",
552
- // columnType: "bigint unsigned",
553
- // length: null,
554
- // max: null,
555
- // nullable: false,
556
- // defaultValue: "0",
557
- // comment: '',
558
- // },
559
- // description: {
560
- // type: "varchar",
561
- // columnType: "varchar(500)",
562
- // length: 500,
563
- // max: 500,
564
- // nullable: false,
565
- // defaultValue: "",
566
- // comment: '',
567
- // }]
568
- const columns = {};
569
- try {
570
- // 方言差异说明:
571
- // - MySQL:information_schema.columns 最完整,包含 COLUMN_TYPE 与 COLUMN_COMMENT。
572
- // - PostgreSQL:information_schema.columns 给基础列信息;注释需额外从 pg_class/pg_attribute 获取。
573
- // - SQLite:PRAGMA table_info 仅提供 type/notnull/default 等有限信息,无列注释。
574
- if (runtime.dbDialect === "mysql") {
575
- const q = getSyncTableColumnsInfoQuery({ dialect: "mysql", table: tableName, dbName: runtime.dbName });
576
- const result = await runtime.db.unsafe(q.columns.sql, q.columns.params);
435
+ /**
436
+ * 只读查询 information_schema.COLUMNS,读取列元信息。
437
+ * - 该方法不会执行 DDL,不会修改数据库结构
438
+ * - 返回结构用于与字段定义做对比(compareFieldDefinition)
439
+ */
440
+ static async getTableColumns(db, dbName, tableName) {
441
+ const columns = {};
442
+ try {
443
+ const result = await db.unsafe("SELECT COLUMN_NAME, DATA_TYPE, CHARACTER_MAXIMUM_LENGTH, IS_NULLABLE, COLUMN_DEFAULT, COLUMN_COMMENT, COLUMN_TYPE FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = ? AND TABLE_NAME = ? ORDER BY ORDINAL_POSITION", [
444
+ dbName,
445
+ tableName
446
+ ]);
577
447
  for (const row of result.data) {
578
448
  const defaultValue = normalizeColumnDefaultValue(row.COLUMN_DEFAULT);
579
449
  columns[row.COLUMN_NAME] = {
580
- // 防御性:某些 driver/编码设置可能导致字符串字段不是 string(如 Buffer/number/null)。
581
450
  type: String(row.DATA_TYPE ?? ""),
582
451
  columnType: String(row.COLUMN_TYPE ?? ""),
583
452
  length: row.CHARACTER_MAXIMUM_LENGTH,
@@ -587,75 +456,21 @@ async function getTableColumnsRuntime(runtime, tableName) {
587
456
  comment: row.COLUMN_COMMENT === null ? null : String(row.COLUMN_COMMENT)
588
457
  };
589
458
  }
459
+ return columns;
590
460
  }
591
- else if (runtime.dbDialect === "postgresql") {
592
- const q = getSyncTableColumnsInfoQuery({ dialect: "postgresql", table: tableName, dbName: runtime.dbName });
593
- const result = await runtime.db.unsafe(q.columns.sql, q.columns.params);
594
- const comments = q.comments ? (await runtime.db.unsafe(q.comments.sql, q.comments.params)).data : [];
595
- const commentMap = {};
596
- for (const r of comments)
597
- commentMap[r.column_name] = r.column_comment;
598
- for (const row of result.data) {
599
- columns[row.column_name] = {
600
- type: String(row.data_type ?? ""),
601
- columnType: String(row.data_type ?? ""),
602
- length: row.character_maximum_length,
603
- max: row.character_maximum_length,
604
- nullable: String(row.is_nullable).toUpperCase() === "YES",
605
- defaultValue: normalizeColumnDefaultValue(row.column_default),
606
- comment: commentMap[row.column_name] ?? null
607
- };
608
- }
609
- }
610
- else if (runtime.dbDialect === "sqlite") {
611
- const q = getSyncTableColumnsInfoQuery({ dialect: "sqlite", table: tableName, dbName: runtime.dbName });
612
- const result = await runtime.db.unsafe(q.columns.sql, q.columns.params);
613
- for (const row of result.data) {
614
- let baseType = String(row.type || "").toUpperCase();
615
- let max = null;
616
- const m = /^(\w+)\s*\((\d+)\)/.exec(baseType);
617
- if (m && m[1] && m[2]) {
618
- baseType = m[1];
619
- max = Number(m[2]);
620
- }
621
- columns[row.name] = {
622
- type: String(baseType).toLowerCase(),
623
- columnType: String(baseType).toLowerCase(),
624
- length: max,
625
- max: max,
626
- nullable: row.notnull === 0,
627
- defaultValue: normalizeColumnDefaultValue(row.dflt_value),
628
- comment: null
629
- };
630
- }
461
+ catch (error) {
462
+ throw SyncTable.buildRuntimeIoError("读取列信息", tableName, error);
631
463
  }
632
- return columns;
633
- }
634
- catch (error) {
635
- throw buildRuntimeIoError("读取列信息", tableName, error);
636
464
  }
637
- }
638
- // ---------------------------------------------------------------------------
639
- // 读:索引信息(单列索引)
640
- // ---------------------------------------------------------------------------
641
- async function getTableIndexesRuntime(runtime, tableName) {
642
- // 索引返回示例
643
- // {
644
- // code: [ "code" ],
645
- // idx_created_at: [ "created_at" ],
646
- // idx_group: [ "group" ],
647
- // idx_state: [ "state" ],
648
- // idx_updated_at: [ "updated_at" ],
649
- // }
650
- const indexes = {};
651
- try {
652
- // 方言差异说明:
653
- // - MySQL:information_schema.statistics 直接给出 index -> column 映射。
654
- // - PostgreSQL:pg_indexes 只有 indexdef,需要从定义里解析列名(这里仅取单列索引)。
655
- // - SQLite:PRAGMA index_list + index_info;同样仅收集单列索引,避免多列索引误判。
656
- if (runtime.dbDialect === "mysql") {
657
- const q = getSyncTableIndexesQuery({ dialect: "mysql", table: tableName, dbName: runtime.dbName });
658
- const result = await runtime.db.unsafe(q.sql, q.params);
465
+ /**
466
+ * 只读查询 information_schema.STATISTICS,读取(非主键)索引元信息。
467
+ * - 该方法不会执行 DDL,不会修改数据库结构
468
+ * - 仅返回 PRIMARY 之外的索引(PRIMARY 会被同步逻辑视为系统约束)
469
+ */
470
+ static async getTableIndexes(db, dbName, tableName) {
471
+ const indexes = {};
472
+ try {
473
+ const result = await db.unsafe("SELECT INDEX_NAME, COLUMN_NAME FROM information_schema.STATISTICS WHERE TABLE_SCHEMA = ? AND TABLE_NAME = ? AND INDEX_NAME != 'PRIMARY' ORDER BY INDEX_NAME", [dbName, tableName]);
659
474
  for (const row of result.data) {
660
475
  const indexName = row.INDEX_NAME;
661
476
  const current = indexes[indexName];
@@ -666,44 +481,13 @@ async function getTableIndexesRuntime(runtime, tableName) {
666
481
  indexes[indexName] = [row.COLUMN_NAME];
667
482
  }
668
483
  }
484
+ return indexes;
669
485
  }
670
- else if (runtime.dbDialect === "postgresql") {
671
- const q = getSyncTableIndexesQuery({ dialect: "postgresql", table: tableName, dbName: runtime.dbName });
672
- const result = await runtime.db.unsafe(q.sql, q.params);
673
- for (const row of result.data) {
674
- const m = /\(([^)]+)\)/.exec(row.indexdef);
675
- if (m) {
676
- const colPart = m[1];
677
- const col = typeof colPart === "string" ? colPart.replace(/"/g, "").trim() : "";
678
- indexes[row.indexname] = [col];
679
- }
680
- }
681
- }
682
- else if (runtime.dbDialect === "sqlite") {
683
- const quotedTable = quoteIdentifier("sqlite", tableName);
684
- const list = await runtime.db.unsafe(`PRAGMA index_list(${quotedTable})`);
685
- for (const idx of list.data) {
686
- const quotedIndex = quoteIdentifier("sqlite", idx.name);
687
- const info = await runtime.db.unsafe(`PRAGMA index_info(${quotedIndex})`);
688
- const cols = info.data.map((r) => r.name);
689
- if (cols.length === 1)
690
- indexes[idx.name] = cols;
691
- }
486
+ catch (error) {
487
+ throw SyncTable.buildRuntimeIoError("读取索引信息", tableName, error);
692
488
  }
693
- return indexes;
694
- }
695
- catch (error) {
696
- throw buildRuntimeIoError("读取索引信息", tableName, error);
697
489
  }
698
- }
699
- // ---------------------------------------------------------------------------
700
- // 读:数据库版本
701
- // ---------------------------------------------------------------------------
702
- /**
703
- * 数据库版本检查(按方言)
704
- */
705
- async function ensureDbVersion(dbDialect, db) {
706
- if (dbDialect === "mysql") {
490
+ static async ensureDbVersion(db) {
707
491
  const r = await db.unsafe("SELECT VERSION() AS version");
708
492
  if (!r.data || r.data.length === 0 || !r.data[0]?.version) {
709
493
  throw new Error("同步表:无法获取 MySQL 版本信息");
@@ -711,416 +495,186 @@ async function ensureDbVersion(dbDialect, db) {
711
495
  const version = r.data[0].version;
712
496
  const majorPart = String(version).split(".")[0] || "0";
713
497
  const majorVersion = parseInt(majorPart, 10);
714
- if (!Number.isFinite(majorVersion) || majorVersion < DB_VERSION_REQUIREMENTS.MYSQL_MIN_MAJOR) {
715
- throw new Error(`同步表:仅支持 MySQL ${DB_VERSION_REQUIREMENTS.MYSQL_MIN_MAJOR}.0+(当前版本:${version})`);
716
- }
717
- return;
718
- }
719
- if (dbDialect === "postgresql") {
720
- const r = await db.unsafe("SELECT version() AS version");
721
- if (!r.data || r.data.length === 0 || !r.data[0]?.version) {
722
- throw new Error("同步表:无法获取 PostgreSQL 版本信息");
723
- }
724
- const versionText = r.data[0].version;
725
- const m = /PostgreSQL\s+(\d+)/i.exec(versionText);
726
- const majorText = m ? m[1] : undefined;
727
- const major = typeof majorText === "string" ? parseInt(majorText, 10) : NaN;
728
- if (!Number.isFinite(major) || major < DB_VERSION_REQUIREMENTS.POSTGRES_MIN_MAJOR) {
729
- throw new Error(`同步表:要求 PostgreSQL >= ${DB_VERSION_REQUIREMENTS.POSTGRES_MIN_MAJOR}(当前:${versionText})`);
730
- }
731
- return;
732
- }
733
- if (dbDialect === "sqlite") {
734
- const r = await db.unsafe("SELECT sqlite_version() AS version");
735
- if (!r.data || r.data.length === 0 || !r.data[0]?.version) {
736
- throw new Error("同步表:无法获取 SQLite 版本信息");
737
- }
738
- const version = r.data[0].version;
739
- const parts = String(version)
740
- .split(".")
741
- .map((v) => parseInt(v, 10) || 0);
742
- const maj = parts[0] ?? 0;
743
- const min = parts[1] ?? 0;
744
- const patch = parts[2] ?? 0;
745
- const vnum = maj * 10000 + min * 100 + patch;
746
- if (!Number.isFinite(vnum) || vnum < DB_VERSION_REQUIREMENTS.SQLITE_MIN_VERSION_NUM) {
747
- throw new Error(`同步表:要求 SQLite >= ${DB_VERSION_REQUIREMENTS.SQLITE_MIN_VERSION}(当前:${version})`);
748
- }
749
- return;
750
- }
751
- }
752
- /**
753
- * 比较字段定义变化
754
- */
755
- function compareFieldDefinition(dbDialect, existingColumn, fieldDef) {
756
- const changes = [];
757
- const normalized = normalizeFieldDefinition(fieldDef);
758
- // SQLite 元信息能力较弱:
759
- // - 列注释:sqlite 无 information_schema 注释,PRAGMA table_info 也不提供 comment
760
- // - 字符串长度:sqlite 类型系统宽松,长度/类型信息不稳定(易产生误报)
761
- // 因此在 sqlite 下跳过 comment/length 的 diff,仅保留更可靠的对比项。
762
- if (dbDialect !== "sqlite" && isStringOrArrayType(normalized.type)) {
763
- const expectedMax = normalized.max;
764
- if (expectedMax !== null && existingColumn.max !== expectedMax) {
765
- changes.push({
766
- type: "length",
767
- current: existingColumn.max,
768
- expected: expectedMax
769
- });
770
- }
771
- }
772
- if (dbDialect !== "sqlite") {
773
- const currentComment = existingColumn.comment === null || existingColumn.comment === undefined ? "" : String(existingColumn.comment);
774
- if (currentComment !== normalized.name) {
775
- changes.push({
776
- type: "comment",
777
- current: currentComment,
778
- expected: normalized.name
779
- });
780
- }
781
- }
782
- const typeMapping = getTypeMapping(dbDialect);
783
- const mapped = typeMapping[normalized.type];
784
- if (typeof mapped !== "string") {
785
- throw new Error(`同步表:未知字段类型映射(方言=${dbDialect},类型=${String(normalized.type)})`);
786
- }
787
- const expectedType = mapped.toLowerCase();
788
- // 防御性:理论上 ColumnInfo.type/columnType 都应为 string,但线上偶发出现 number/null/Buffer 等导致崩溃。
789
- // 同时:columnType 可能包含长度/unsigned(如 varchar(255), bigint unsigned),这里归一化为“基础类型”再比较。
790
- let rawType = "";
791
- if (typeof existingColumn.type === "string" && existingColumn.type.trim() !== "") {
792
- rawType = existingColumn.type;
793
- }
794
- else if (typeof existingColumn.columnType === "string" && existingColumn.columnType.trim() !== "") {
795
- rawType = existingColumn.columnType;
796
- }
797
- else {
798
- rawType = String(existingColumn.type ?? "");
799
- }
800
- const currentType = rawType
801
- .toLowerCase()
802
- .replace(/\s*unsigned/gi, "")
803
- .replace(/\([^)]*\)/g, "")
804
- .trim();
805
- if (currentType !== expectedType) {
806
- changes.push({
807
- type: "datatype",
808
- current: currentType,
809
- expected: expectedType
810
- });
811
- }
812
- const expectedNullable = normalized.nullable;
813
- if (existingColumn.nullable !== expectedNullable) {
814
- changes.push({
815
- type: "nullable",
816
- current: existingColumn.nullable,
817
- expected: expectedNullable
818
- });
819
- }
820
- const expectedDefault = resolveDefaultValue(normalized.default, normalized.type);
821
- if (String(existingColumn.defaultValue) !== String(expectedDefault)) {
822
- changes.push({
823
- type: "default",
824
- current: existingColumn.defaultValue,
825
- expected: expectedDefault
826
- });
827
- }
828
- return changes;
829
- }
830
- /* ========================================================================== */
831
- /* plan/apply & 建表/改表(核心同步逻辑) */
832
- /* ========================================================================== */
833
- /**
834
- * SQLite 重建表迁移(简化版)
835
- */
836
- async function rebuildSqliteTable(runtime, tableName, fields) {
837
- // 说明:SQLite ALTER TABLE 能力有限(尤其是修改列类型/默认值/约束)。
838
- // 策略:创建临时表 -> 复制“交集列”数据 -> 删除旧表 -> 临时表改名。
839
- // - 只复制 targetCols 与 existingCols 的交集,避免因新增列/删除列导致 INSERT 失败。
840
- // - 不做额外的数据转换/回填:保持迁移路径尽量“纯结构同步”。
841
- if (runtime.dbDialect !== "sqlite") {
842
- throw new Error(`同步表:SQLite 重建表仅支持 sqlite 方言(当前:${String(runtime.dbDialect)})`);
843
- }
844
- const quotedSourceTable = quoteIdentifier("sqlite", tableName);
845
- const info = await runtime.db.unsafe(`PRAGMA table_info(${quotedSourceTable})`);
846
- const existingCols = info.data.map((r) => r.name);
847
- const businessCols = Object.keys(fields).map((k) => snakeCase(k));
848
- const targetCols = ["id", "created_at", "updated_at", "deleted_at", "state", ...businessCols];
849
- const tmpTable = `${tableName}__tmp__${Date.now()}`;
850
- await createTable(runtime, tmpTable, fields);
851
- const commonCols = targetCols.filter((c) => existingCols.includes(c));
852
- if (commonCols.length > 0) {
853
- const colsSql = commonCols.map((c) => quoteIdentifier("sqlite", c)).join(", ");
854
- const quotedTmpTable = quoteIdentifier("sqlite", tmpTable);
855
- await runtime.db.unsafe(`INSERT INTO ${quotedTmpTable} (${colsSql}) SELECT ${colsSql} FROM ${quotedSourceTable}`);
856
- }
857
- await runtime.db.unsafe(`DROP TABLE ${quotedSourceTable}`);
858
- const quotedTmpTable = quoteIdentifier("sqlite", tmpTable);
859
- await runtime.db.unsafe(`ALTER TABLE ${quotedTmpTable} RENAME TO ${quotedSourceTable}`);
860
- }
861
- /**
862
- * 将表结构计划应用到数据库
863
- */
864
- async function applyTablePlan(runtime, tableName, fields, plan) {
865
- if (!plan || !plan.changed)
866
- return;
867
- // A) 结构变更(ADD/MODIFY):SQLite 走重建表;其余方言走 ALTER TABLE
868
- if (runtime.dbDialect === "sqlite") {
869
- if (plan.modifyClauses.length > 0 || plan.defaultClauses.length > 0) {
870
- await rebuildSqliteTable(runtime, tableName, fields);
871
- }
872
- else {
873
- for (const c of plan.addClauses) {
874
- const stmt = `ALTER TABLE ${quoteIdentifier(runtime.dbDialect, tableName)} ${c}`;
875
- await runtime.db.unsafe(stmt);
498
+ if (!Number.isFinite(majorVersion) || majorVersion < SyncTable.DB_VERSION_REQUIREMENTS.MYSQL_MIN_MAJOR) {
499
+ throw new Error(`同步表:仅支持 MySQL ${SyncTable.DB_VERSION_REQUIREMENTS.MYSQL_MIN_MAJOR}.0+(当前版本:${version})`);
500
+ }
501
+ }
502
+ /* ---------------------------------------------------------------------- */
503
+ /* 同步计划应用:先 drop index -> alter columns -> set defaults -> create index */
504
+ /* ---------------------------------------------------------------------- */
505
+ static async applyTablePlan(db, tableName, plan) {
506
+ if (!plan || !plan.changed)
507
+ return;
508
+ // drop 再 alter:避免“增大 VARCHAR 长度”时被现有索引 key length 限制卡住
509
+ const dropIndexActions = plan.indexActions.filter((a) => a.action === "drop");
510
+ const createIndexActions = plan.indexActions.filter((a) => a.action === "create");
511
+ for (const act of dropIndexActions) {
512
+ const stmt = SyncTable.buildIndexSQL(tableName, act.indexName, act.fieldName, act.action);
513
+ try {
514
+ await db.unsafe(stmt);
515
+ Logger.debug(`[索引变化] 删除索引 ${tableName}.${act.indexName} (${act.fieldName})`);
516
+ }
517
+ catch (error) {
518
+ Logger.error({ err: error, table: tableName, index: act.indexName, field: act.fieldName, msg: "删除索引失败" });
519
+ throw error;
876
520
  }
877
521
  }
878
- }
879
- else {
880
522
  const clauses = [...plan.addClauses, ...plan.modifyClauses];
881
523
  if (clauses.length > 0) {
882
- const tableQuoted = quoteIdentifier(runtime.dbDialect, tableName);
883
- const stmt = runtime.dbDialect === "mysql" ? `ALTER TABLE ${tableQuoted} ALGORITHM=INSTANT, LOCK=NONE, ${clauses.join(", ")}` : `ALTER TABLE ${tableQuoted} ${clauses.join(", ")}`;
884
- if (runtime.dbDialect === "mysql")
885
- await executeDDLSafely(runtime.db, stmt);
886
- else
887
- await runtime.db.unsafe(stmt);
524
+ const tableQuoted = SyncTable.quoteIdentifier(tableName);
525
+ const stmt = `ALTER TABLE ${tableQuoted} ALGORITHM=INSTANT, LOCK=NONE, ${clauses.join(", ")}`;
526
+ await SyncTable.executeDDLSafely(db, stmt);
888
527
  }
889
- }
890
- // B) 默认值变更:SQLite 不支持在线修改默认值(需要重建表),其余方言按子句执行
891
- if (plan.defaultClauses.length > 0) {
892
- if (runtime.dbDialect === "sqlite") {
893
- Logger.warn(`SQLite 不支持修改默认值,表 ${tableName} 的默认值变更已跳过`);
528
+ if (plan.defaultClauses.length > 0) {
529
+ const tableQuoted = SyncTable.quoteIdentifier(tableName);
530
+ const stmt = `ALTER TABLE ${tableQuoted} ALGORITHM=INSTANT, LOCK=NONE, ${plan.defaultClauses.join(", ")}`;
531
+ await SyncTable.executeDDLSafely(db, stmt);
894
532
  }
895
- else {
896
- const tableQuoted = quoteIdentifier(runtime.dbDialect, tableName);
897
- const stmt = runtime.dbDialect === "mysql" ? `ALTER TABLE ${tableQuoted} ALGORITHM=INSTANT, LOCK=NONE, ${plan.defaultClauses.join(", ")}` : `ALTER TABLE ${tableQuoted} ${plan.defaultClauses.join(", ")}`;
898
- if (runtime.dbDialect === "mysql")
899
- await executeDDLSafely(runtime.db, stmt);
900
- else
901
- await runtime.db.unsafe(stmt);
902
- }
903
- }
904
- // C) 索引动作:不同方言的 DDL 策略由 buildIndexSQL 统一生成
905
- for (const act of plan.indexActions) {
906
- const stmt = buildIndexSQL(runtime.dbDialect, tableName, act.indexName, act.fieldName, act.action);
907
- try {
908
- await runtime.db.unsafe(stmt);
909
- if (act.action === "create") {
533
+ for (const act of createIndexActions) {
534
+ const stmt = SyncTable.buildIndexSQL(tableName, act.indexName, act.fieldName, act.action);
535
+ try {
536
+ await db.unsafe(stmt);
910
537
  Logger.debug(`[索引变化] 新建索引 ${tableName}.${act.indexName} (${act.fieldName})`);
911
538
  }
912
- else {
913
- Logger.debug(`[索引变化] 删除索引 ${tableName}.${act.indexName} (${act.fieldName})`);
539
+ catch (error) {
540
+ Logger.error({ err: error, table: tableName, index: act.indexName, field: act.fieldName, msg: "创建索引失败" });
541
+ throw error;
914
542
  }
915
543
  }
916
- catch (error) {
917
- Logger.error({ err: error, table: tableName, index: act.indexName, field: act.fieldName, msg: `${act.action === "create" ? "创建" : "删除"}索引失败` });
918
- throw error;
919
- }
920
544
  }
921
- // D) PG 列注释:独立 SQL 执行(COMMENT ON COLUMN)
922
- if (runtime.dbDialect === "postgresql" && plan.commentActions && plan.commentActions.length > 0) {
923
- for (const stmt of plan.commentActions) {
924
- await runtime.db.unsafe(stmt);
925
- }
926
- }
927
- }
928
- /**
929
- * 创建表(包含系统字段和业务字段)
930
- */
931
- async function createTable(runtime, tableName, fields, systemIndexFields = SYSTEM_INDEX_FIELDS) {
932
- const colDefs = [...buildSystemColumnDefs(runtime.dbDialect), ...buildBusinessColumnDefs(runtime.dbDialect, fields)];
933
- const cols = colDefs.join(",\n ");
934
- const tableQuoted = quoteIdentifier(runtime.dbDialect, tableName);
935
- const { ENGINE, CHARSET, COLLATE } = MYSQL_TABLE_CONFIG;
936
- const createSQL = runtime.dbDialect === "mysql" ? `CREATE TABLE ${tableQuoted} (\n ${cols}\n ) ENGINE=${ENGINE} DEFAULT CHARSET=${CHARSET} COLLATE=${COLLATE}` : `CREATE TABLE ${tableQuoted} (\n ${cols}\n )`;
937
- await runtime.db.unsafe(createSQL);
938
- if (runtime.dbDialect === "postgresql") {
939
- for (const f of SYSTEM_FIELDS) {
940
- const escaped = String(f.comment).replace(/'/g, "''");
941
- const colQuoted = quoteIdentifier(runtime.dbDialect, f.name);
942
- const stmt = `COMMENT ON COLUMN ${tableQuoted}.${colQuoted} IS '${escaped}'`;
943
- await runtime.db.unsafe(stmt);
545
+ /* ---------------------------------------------------------------------- */
546
+ /* 建表 & 改表(核心同步流程,假设 checkTable 已保证合法性) */
547
+ /* ---------------------------------------------------------------------- */
548
+ static async createTable(db, tableName, fields, systemIndexFields = SyncTable.SYSTEM_INDEX_FIELDS) {
549
+ const colDefs = [...SyncTable.buildSystemColumnDefs(), ...SyncTable.buildBusinessColumnDefs(fields)];
550
+ const cols = colDefs.join(",\n ");
551
+ const tableQuoted = SyncTable.quoteIdentifier(tableName);
552
+ const { ENGINE, CHARSET, COLLATE } = SyncTable.MYSQL_TABLE_CONFIG;
553
+ const createSQL = `CREATE TABLE ${tableQuoted} (\n ${cols}\n ) ENGINE=${ENGINE} DEFAULT CHARSET=${CHARSET} COLLATE=${COLLATE}`;
554
+ await db.unsafe(createSQL);
555
+ const indexTasks = [];
556
+ const existingIndexes = {};
557
+ for (const sysField of systemIndexFields) {
558
+ const indexName = `idx_${sysField}`;
559
+ if (existingIndexes[indexName]) {
560
+ continue;
561
+ }
562
+ const stmt = SyncTable.buildIndexSQL(tableName, indexName, sysField, "create");
563
+ indexTasks.push(db.unsafe(stmt));
944
564
  }
945
565
  for (const [fieldKey, fieldDef] of Object.entries(fields)) {
946
566
  const dbFieldName = snakeCase(fieldKey);
947
- const colQuoted = quoteIdentifier(runtime.dbDialect, dbFieldName);
948
- const fieldName = fieldDef.name && fieldDef.name !== "null" ? String(fieldDef.name) : "";
949
- const escaped = fieldName.replace(/'/g, "''");
950
- const valueSql = fieldName ? `'${escaped}'` : "NULL";
951
- const stmt = `COMMENT ON COLUMN ${tableQuoted}.${colQuoted} IS ${valueSql}`;
952
- await runtime.db.unsafe(stmt);
953
- }
954
- }
955
- const indexTasks = [];
956
- let existingIndexes = {};
957
- if (runtime.dbDialect === "mysql") {
958
- existingIndexes = await getTableIndexesRuntime(runtime, tableName);
959
- }
960
- for (const sysField of systemIndexFields) {
961
- const indexName = `idx_${sysField}`;
962
- if (runtime.dbDialect === "mysql" && existingIndexes[indexName]) {
963
- continue;
964
- }
965
- const stmt = buildIndexSQL(runtime.dbDialect, tableName, indexName, sysField, "create");
966
- indexTasks.push(runtime.db.unsafe(stmt));
967
- }
968
- for (const [fieldKey, fieldDef] of Object.entries(fields)) {
969
- const dbFieldName = snakeCase(fieldKey);
970
- if (fieldDef.index === true && fieldDef.unique !== true) {
971
- const indexName = `idx_${dbFieldName}`;
972
- if (runtime.dbDialect === "mysql" && existingIndexes[indexName]) {
973
- continue;
567
+ if (fieldDef.index === true && fieldDef.unique !== true) {
568
+ const indexName = `idx_${dbFieldName}`;
569
+ if (existingIndexes[indexName]) {
570
+ continue;
571
+ }
572
+ const stmt = SyncTable.buildIndexSQL(tableName, indexName, dbFieldName, "create");
573
+ indexTasks.push(db.unsafe(stmt));
974
574
  }
975
- const stmt = buildIndexSQL(runtime.dbDialect, tableName, indexName, dbFieldName, "create");
976
- indexTasks.push(runtime.db.unsafe(stmt));
575
+ }
576
+ if (indexTasks.length > 0) {
577
+ await Promise.all(indexTasks);
977
578
  }
978
579
  }
979
- if (indexTasks.length > 0) {
980
- await Promise.all(indexTasks);
981
- }
982
- }
983
- async function modifyTableRuntime(runtime, tableName, fields) {
984
- // 1) 读取现有元信息(列/索引)
985
- const existingColumns = await getTableColumnsRuntime(runtime, tableName);
986
- const existingIndexes = await getTableIndexesRuntime(runtime, tableName);
987
- // 2) 规划变更(先 plan,后统一 apply)
988
- let changed = false;
989
- const addClauses = [];
990
- const modifyClauses = [];
991
- const defaultClauses = [];
992
- const indexActions = [];
993
- // 3) 对比业务字段:新增/变更(类型/长度/可空/默认值/注释)
994
- for (const [fieldKey, fieldDef] of Object.entries(fields)) {
995
- const dbFieldName = snakeCase(fieldKey);
996
- if (existingColumns[dbFieldName]) {
997
- const comparison = compareFieldDefinition(runtime.dbDialect, existingColumns[dbFieldName], fieldDef);
998
- if (comparison.length > 0) {
999
- for (const c of comparison) {
1000
- const changeLabel = CHANGE_TYPE_LABELS[c.type] || "未知";
1001
- Logger.debug(` ~ 修改 ${dbFieldName} ${changeLabel}: ${c.current} -> ${c.expected}`);
1002
- }
1003
- if (isStringOrArrayType(fieldDef.type) && existingColumns[dbFieldName].max && typeof fieldDef.max === "number") {
1004
- if (existingColumns[dbFieldName].max > fieldDef.max) {
1005
- Logger.warn(`[跳过危险变更] ${tableName}.${dbFieldName} 长度收缩 ${existingColumns[dbFieldName].max} -> ${fieldDef.max} 已被跳过(需手动处理)`);
1006
- }
1007
- }
1008
- const hasTypeChange = comparison.some((c) => c.type === "datatype");
1009
- const hasLengthChange = comparison.some((c) => c.type === "length");
1010
- const onlyDefaultChanged = comparison.every((c) => c.type === "default");
1011
- const defaultChanged = comparison.some((c) => c.type === "default");
1012
- if (hasTypeChange) {
1013
- const typeChange = comparison.find((c) => c.type === "datatype");
1014
- const currentType = String(typeChange?.current || "").toLowerCase();
1015
- const typeMapping = getTypeMapping(runtime.dbDialect);
1016
- const expectedType = typeMapping[fieldDef.type]?.toLowerCase() || "";
1017
- if (!isCompatibleTypeChange(currentType, expectedType)) {
1018
- const errorMsg = [`禁止字段类型变更: ${tableName}.${dbFieldName}`, `当前类型: ${typeChange?.current}`, `目标类型: ${typeChange?.expected}`, "说明: 仅允许宽化型变更(如 INT->BIGINT, VARCHAR->TEXT),其他类型变更需要手动处理"].join("\n");
1019
- throw new Error(errorMsg);
580
+ static async modifyTable(db, dbName, tableName, fields) {
581
+ const existingColumns = await SyncTable.getTableColumns(db, dbName, tableName);
582
+ const existingIndexes = await SyncTable.getTableIndexes(db, dbName, tableName);
583
+ let changed = false;
584
+ const addClauses = [];
585
+ const modifyClauses = [];
586
+ const defaultClauses = [];
587
+ const indexActions = [];
588
+ for (const [fieldKey, fieldDef] of Object.entries(fields)) {
589
+ const dbFieldName = snakeCase(fieldKey);
590
+ if (existingColumns[dbFieldName]) {
591
+ const comparison = SyncTable.compareFieldDefinition(existingColumns[dbFieldName], fieldDef);
592
+ if (comparison.length > 0) {
593
+ for (const c of comparison) {
594
+ const changeLabel = SyncTable.CHANGE_TYPE_LABELS[c.type] || "未知";
595
+ Logger.debug(` ~ 修改 ${dbFieldName} ${changeLabel}: ${c.current} -> ${c.expected}`);
1020
596
  }
1021
- Logger.debug(`[兼容类型变更] ${tableName}.${dbFieldName} ${currentType} -> ${expectedType}`);
1022
- }
1023
- if (defaultChanged) {
1024
- const actualDefault = resolveDefaultValue(fieldDef.default ?? null, fieldDef.type);
1025
- let v = null;
1026
- if (actualDefault !== "null") {
1027
- const defaultSql = generateDefaultSql(actualDefault, fieldDef.type);
1028
- v = defaultSql.trim().replace(/^DEFAULT\s+/, "");
597
+ const hasTypeChange = comparison.some((c) => c.type === "datatype");
598
+ const onlyDefaultChanged = comparison.every((c) => c.type === "default");
599
+ const defaultChanged = comparison.some((c) => c.type === "default");
600
+ if (hasTypeChange) {
601
+ const typeChange = comparison.find((c) => c.type === "datatype");
602
+ const currentType = String(typeChange?.current || "").toLowerCase();
603
+ const typeMapping = SyncTable.getTypeMapping();
604
+ const expectedType = typeMapping[fieldDef.type]?.toLowerCase() || "";
605
+ if (!SyncTable.isCompatibleTypeChange(currentType, expectedType)) {
606
+ const errorMsg = [`禁止字段类型变更: ${tableName}.${dbFieldName}`, `当前类型: ${typeChange?.current}`, `目标类型: ${typeChange?.expected}`, "说明: 仅允许宽化型变更(如 INT->BIGINT, VARCHAR->TEXT),其他类型变更需要手动处理"].join("\n");
607
+ throw new Error(errorMsg);
608
+ }
609
+ Logger.debug(`[兼容类型变更] ${tableName}.${dbFieldName} ${currentType} -> ${expectedType}`);
1029
610
  }
1030
- if (v !== null && v !== "") {
1031
- if (runtime.dbDialect === "postgresql") {
1032
- const colQuoted = quoteIdentifier(runtime.dbDialect, dbFieldName);
1033
- defaultClauses.push(`ALTER COLUMN ${colQuoted} SET DEFAULT ${v}`);
611
+ if (defaultChanged) {
612
+ const actualDefault = SyncTable.resolveDefaultValue(fieldDef.default ?? null, fieldDef.type);
613
+ let v = null;
614
+ if (actualDefault !== "null") {
615
+ const defaultSql = SyncTable.generateDefaultSql(actualDefault, fieldDef.type);
616
+ v = defaultSql.trim().replace(/^DEFAULT\s+/, "");
1034
617
  }
1035
- else if (runtime.dbDialect === "mysql" && onlyDefaultChanged) {
618
+ if (v !== null && v !== "" && onlyDefaultChanged) {
1036
619
  if (fieldDef.type !== "text") {
1037
- const colQuoted = quoteIdentifier(runtime.dbDialect, dbFieldName);
620
+ const colQuoted = SyncTable.quoteIdentifier(dbFieldName);
1038
621
  defaultClauses.push(`ALTER COLUMN ${colQuoted} SET DEFAULT ${v}`);
1039
622
  }
1040
623
  }
1041
624
  }
1042
- }
1043
- if (!onlyDefaultChanged) {
1044
- let skipModify = false;
1045
- if (hasLengthChange && isStringOrArrayType(fieldDef.type) && existingColumns[dbFieldName].max && typeof fieldDef.max === "number") {
1046
- const isShrink = existingColumns[dbFieldName].max > fieldDef.max;
1047
- if (isShrink)
1048
- skipModify = true;
625
+ if (!onlyDefaultChanged) {
626
+ modifyClauses.push(SyncTable.generateDDLClause(fieldKey, fieldDef, false));
1049
627
  }
1050
- if (!skipModify)
1051
- modifyClauses.push(generateDDLClause(runtime.dbDialect, fieldKey, fieldDef, false));
628
+ changed = true;
1052
629
  }
630
+ }
631
+ else {
632
+ addClauses.push(SyncTable.generateDDLClause(fieldKey, fieldDef, true));
1053
633
  changed = true;
1054
634
  }
1055
635
  }
1056
- else {
1057
- addClauses.push(generateDDLClause(runtime.dbDialect, fieldKey, fieldDef, true));
1058
- changed = true;
636
+ const systemFieldNames = SyncTable.SYSTEM_FIELDS.filter((f) => f.name !== "id").map((f) => f.name);
637
+ for (const sysFieldName of systemFieldNames) {
638
+ if (!existingColumns[sysFieldName]) {
639
+ const colDef = SyncTable.getSystemColumnDef(sysFieldName);
640
+ if (colDef) {
641
+ Logger.debug(` + 新增系统字段 ${sysFieldName}`);
642
+ addClauses.push(`ADD COLUMN ${colDef}`);
643
+ changed = true;
644
+ }
645
+ }
1059
646
  }
1060
- }
1061
- // 4) 补齐系统字段(除 id 外)
1062
- const systemFieldNames = SYSTEM_FIELDS.filter((f) => f.name !== "id").map((f) => f.name);
1063
- for (const sysFieldName of systemFieldNames) {
1064
- if (!existingColumns[sysFieldName]) {
1065
- const colDef = getSystemColumnDef(runtime.dbDialect, sysFieldName);
1066
- if (colDef) {
1067
- Logger.debug(` + 新增系统字段 ${sysFieldName}`);
1068
- addClauses.push(`ADD COLUMN ${colDef}`);
647
+ for (const sysField of SyncTable.SYSTEM_INDEX_FIELDS) {
648
+ const idxName = `idx_${sysField}`;
649
+ const fieldWillExist = existingColumns[sysField] || systemFieldNames.includes(sysField);
650
+ if (fieldWillExist && !existingIndexes[idxName]) {
651
+ indexActions.push({ action: "create", indexName: idxName, fieldName: sysField });
1069
652
  changed = true;
1070
653
  }
1071
654
  }
1072
- }
1073
- // 5) 索引动作:系统字段索引 + 业务字段单列索引
1074
- for (const sysField of SYSTEM_INDEX_FIELDS) {
1075
- const idxName = `idx_${sysField}`;
1076
- const fieldWillExist = existingColumns[sysField] || systemFieldNames.includes(sysField);
1077
- if (fieldWillExist && !existingIndexes[idxName]) {
1078
- indexActions.push({ action: "create", indexName: idxName, fieldName: sysField });
1079
- changed = true;
1080
- }
1081
- }
1082
- for (const [fieldKey, fieldDef] of Object.entries(fields)) {
1083
- const dbFieldName = snakeCase(fieldKey);
1084
- const indexName = `idx_${dbFieldName}`;
1085
- if (fieldDef.index && !fieldDef.unique && !existingIndexes[indexName]) {
1086
- indexActions.push({ action: "create", indexName: indexName, fieldName: dbFieldName });
1087
- changed = true;
1088
- }
1089
- else if (!fieldDef.index && existingIndexes[indexName] && existingIndexes[indexName].length === 1) {
1090
- indexActions.push({ action: "drop", indexName: indexName, fieldName: dbFieldName });
1091
- changed = true;
1092
- }
1093
- }
1094
- // 6) PG 注释动作(MySQL 在列定义里带 COMMENT;SQLite 无列注释)
1095
- const commentActions = [];
1096
- if (runtime.dbDialect === "postgresql") {
1097
- const tableQuoted = quoteIdentifier(runtime.dbDialect, tableName);
1098
655
  for (const [fieldKey, fieldDef] of Object.entries(fields)) {
1099
656
  const dbFieldName = snakeCase(fieldKey);
1100
- const colQuoted = quoteIdentifier(runtime.dbDialect, dbFieldName);
1101
- if (existingColumns[dbFieldName]) {
1102
- const curr = existingColumns[dbFieldName].comment || "";
1103
- const want = fieldDef.name && fieldDef.name !== "null" ? String(fieldDef.name) : "";
1104
- if (want !== curr) {
1105
- const escapedWant = want.replace(/'/g, "''");
1106
- commentActions.push(`COMMENT ON COLUMN ${tableQuoted}.${colQuoted} IS ${want ? `'${escapedWant}'` : "NULL"}`);
1107
- changed = true;
1108
- }
657
+ const indexName = `idx_${dbFieldName}`;
658
+ if (fieldDef.index && !fieldDef.unique && !existingIndexes[indexName]) {
659
+ indexActions.push({ action: "create", indexName: indexName, fieldName: dbFieldName });
660
+ changed = true;
661
+ }
662
+ else if (!fieldDef.index && existingIndexes[indexName] && existingIndexes[indexName].length === 1) {
663
+ indexActions.push({ action: "drop", indexName: indexName, fieldName: dbFieldName });
664
+ changed = true;
1109
665
  }
1110
666
  }
667
+ changed = addClauses.length > 0 || modifyClauses.length > 0 || defaultClauses.length > 0 || indexActions.length > 0;
668
+ const plan = {
669
+ changed: changed,
670
+ addClauses: addClauses,
671
+ modifyClauses: modifyClauses,
672
+ defaultClauses: defaultClauses,
673
+ indexActions: indexActions
674
+ };
675
+ if (plan.changed) {
676
+ await SyncTable.applyTablePlan(db, tableName, plan);
677
+ }
678
+ return plan;
1111
679
  }
1112
- // 7) 汇总计划并应用
1113
- changed = addClauses.length > 0 || modifyClauses.length > 0 || defaultClauses.length > 0 || indexActions.length > 0 || commentActions.length > 0;
1114
- const plan = {
1115
- changed: changed,
1116
- addClauses: addClauses,
1117
- modifyClauses: modifyClauses,
1118
- defaultClauses: defaultClauses,
1119
- indexActions: indexActions,
1120
- commentActions: commentActions
1121
- };
1122
- if (plan.changed) {
1123
- await applyTablePlan(runtime, tableName, fields, plan);
1124
- }
1125
- return plan;
1126
680
  }