befly 3.8.19 → 3.8.20
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +7 -6
- package/bunfig.toml +1 -1
- package/lib/database.ts +28 -25
- package/lib/dbHelper.ts +3 -3
- package/lib/jwt.ts +90 -99
- package/lib/logger.ts +44 -23
- package/lib/redisHelper.ts +19 -22
- package/loader/loadApis.ts +69 -114
- package/loader/loadHooks.ts +65 -0
- package/loader/loadPlugins.ts +50 -219
- package/main.ts +106 -133
- package/package.json +15 -7
- package/paths.ts +20 -0
- package/plugins/cache.ts +1 -3
- package/plugins/db.ts +8 -11
- package/plugins/logger.ts +5 -3
- package/plugins/redis.ts +10 -14
- package/router/api.ts +60 -106
- package/router/root.ts +15 -12
- package/router/static.ts +54 -58
- package/sync/syncAll.ts +58 -0
- package/sync/syncApi.ts +264 -0
- package/sync/syncDb/apply.ts +194 -0
- package/sync/syncDb/constants.ts +76 -0
- package/sync/syncDb/ddl.ts +194 -0
- package/sync/syncDb/helpers.ts +200 -0
- package/sync/syncDb/index.ts +164 -0
- package/sync/syncDb/schema.ts +201 -0
- package/sync/syncDb/sqlite.ts +50 -0
- package/sync/syncDb/table.ts +321 -0
- package/sync/syncDb/tableCreate.ts +146 -0
- package/sync/syncDb/version.ts +72 -0
- package/sync/syncDb.ts +19 -0
- package/sync/syncDev.ts +206 -0
- package/sync/syncMenu.ts +331 -0
- package/tsconfig.json +2 -4
- package/types/api.d.ts +6 -0
- package/types/befly.d.ts +152 -28
- package/types/context.d.ts +29 -3
- package/types/hook.d.ts +35 -0
- package/types/index.ts +14 -1
- package/types/plugin.d.ts +6 -7
- package/types/sync.d.ts +403 -0
- package/check.ts +0 -378
- package/env.ts +0 -106
- package/lib/middleware.ts +0 -275
- package/types/env.ts +0 -65
- package/types/util.d.ts +0 -45
- package/util.ts +0 -257
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* syncDb 表结构查询模块
|
|
3
|
+
*
|
|
4
|
+
* 包含:
|
|
5
|
+
* - 判断表是否存在
|
|
6
|
+
* - 获取表的列信息
|
|
7
|
+
* - 获取表的索引信息
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { IS_MYSQL, IS_PG, IS_SQLITE } from './constants.js';
|
|
11
|
+
import type { ColumnInfo, IndexInfo } from '../../types.js';
|
|
12
|
+
import type { SQL } from 'bun';
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* 判断表是否存在(返回布尔值)
|
|
16
|
+
*
|
|
17
|
+
* @param sql - SQL 客户端实例
|
|
18
|
+
* @param tableName - 表名
|
|
19
|
+
* @param dbName - 数据库名称
|
|
20
|
+
* @returns 表是否存在
|
|
21
|
+
*/
|
|
22
|
+
export async function tableExists(sql: SQL, tableName: string, dbName?: string): Promise<boolean> {
|
|
23
|
+
if (!sql) throw new Error('SQL 客户端未初始化');
|
|
24
|
+
const database = dbName || process.env.DB_NAME;
|
|
25
|
+
|
|
26
|
+
try {
|
|
27
|
+
if (IS_MYSQL) {
|
|
28
|
+
const res = await sql`SELECT COUNT(*) AS count FROM information_schema.TABLES WHERE TABLE_SCHEMA = ${database} AND TABLE_NAME = ${tableName}`;
|
|
29
|
+
return (res[0]?.count || 0) > 0;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
if (IS_PG) {
|
|
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 (IS_SQLITE) {
|
|
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
|
+
const database = dbName || process.env.DB_NAME;
|
|
67
|
+
|
|
68
|
+
try {
|
|
69
|
+
if (IS_MYSQL) {
|
|
70
|
+
const result = await sql`
|
|
71
|
+
SELECT COLUMN_NAME, DATA_TYPE, CHARACTER_MAXIMUM_LENGTH, IS_NULLABLE, COLUMN_DEFAULT, COLUMN_COMMENT, COLUMN_TYPE
|
|
72
|
+
FROM information_schema.COLUMNS
|
|
73
|
+
WHERE TABLE_SCHEMA = ${database} AND TABLE_NAME = ${tableName}
|
|
74
|
+
ORDER BY ORDINAL_POSITION
|
|
75
|
+
`;
|
|
76
|
+
for (const row of result) {
|
|
77
|
+
// MySQL 的 COLUMN_DEFAULT 已经是解析后的实际值,无需处理:
|
|
78
|
+
// - 空字符串 DEFAULT '': 返回 '' (空字符串)
|
|
79
|
+
// - 字符串 DEFAULT 'admin': 返回 admin (无引号)
|
|
80
|
+
// - 单引号 DEFAULT '''': 返回 ' (单引号字符)
|
|
81
|
+
// - 数字 DEFAULT 0: 返回 0
|
|
82
|
+
// - NULL: 返回 null
|
|
83
|
+
const defaultValue = row.COLUMN_DEFAULT;
|
|
84
|
+
|
|
85
|
+
columns[row.COLUMN_NAME] = {
|
|
86
|
+
type: row.DATA_TYPE,
|
|
87
|
+
columnType: row.COLUMN_TYPE,
|
|
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 (IS_PG) {
|
|
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
|
+
max: row.character_maximum_length,
|
|
117
|
+
nullable: String(row.is_nullable).toUpperCase() === 'YES',
|
|
118
|
+
defaultValue: row.column_default,
|
|
119
|
+
comment: commentMap[row.column_name] ?? null
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
} else if (IS_SQLITE) {
|
|
123
|
+
const result = await sql.unsafe(`PRAGMA table_info(${tableName})`);
|
|
124
|
+
for (const row of result) {
|
|
125
|
+
let baseType = String(row.type || '').toUpperCase();
|
|
126
|
+
let max = null;
|
|
127
|
+
const m = /^(\w+)\s*\((\d+)\)/.exec(baseType);
|
|
128
|
+
if (m) {
|
|
129
|
+
baseType = m[1];
|
|
130
|
+
max = Number(m[2]);
|
|
131
|
+
}
|
|
132
|
+
columns[row.name] = {
|
|
133
|
+
type: baseType.toLowerCase(),
|
|
134
|
+
columnType: baseType.toLowerCase(),
|
|
135
|
+
max: max,
|
|
136
|
+
nullable: row.notnull === 0,
|
|
137
|
+
defaultValue: row.dflt_value,
|
|
138
|
+
comment: null
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
return columns;
|
|
144
|
+
} catch (error: any) {
|
|
145
|
+
throw new Error(`获取表列信息失败 [${tableName}]: ${error.message}`);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* 获取表的现有索引信息(单列索引)
|
|
151
|
+
*
|
|
152
|
+
* @param sql - SQL 客户端实例
|
|
153
|
+
* @param tableName - 表名
|
|
154
|
+
* @param dbName - 数据库名称
|
|
155
|
+
* @returns 索引信息对象,键为索引名,值为列名数组
|
|
156
|
+
*/
|
|
157
|
+
export async function getTableIndexes(sql: SQL, tableName: string, dbName?: string): Promise<IndexInfo> {
|
|
158
|
+
const indexes: IndexInfo = {};
|
|
159
|
+
const database = dbName || process.env.DB_NAME;
|
|
160
|
+
|
|
161
|
+
try {
|
|
162
|
+
if (IS_MYSQL) {
|
|
163
|
+
const result = await sql`
|
|
164
|
+
SELECT INDEX_NAME, COLUMN_NAME
|
|
165
|
+
FROM information_schema.STATISTICS
|
|
166
|
+
WHERE TABLE_SCHEMA = ${database}
|
|
167
|
+
AND TABLE_NAME = ${tableName}
|
|
168
|
+
AND INDEX_NAME != 'PRIMARY'
|
|
169
|
+
ORDER BY INDEX_NAME
|
|
170
|
+
`;
|
|
171
|
+
for (const row of result) {
|
|
172
|
+
if (!indexes[row.INDEX_NAME]) indexes[row.INDEX_NAME] = [];
|
|
173
|
+
indexes[row.INDEX_NAME].push(row.COLUMN_NAME);
|
|
174
|
+
}
|
|
175
|
+
} else if (IS_PG) {
|
|
176
|
+
const result = await sql`
|
|
177
|
+
SELECT indexname, indexdef
|
|
178
|
+
FROM pg_indexes
|
|
179
|
+
WHERE schemaname = 'public' AND tablename = ${tableName}
|
|
180
|
+
`;
|
|
181
|
+
for (const row of result) {
|
|
182
|
+
const m = /\(([^)]+)\)/.exec(row.indexdef);
|
|
183
|
+
if (m) {
|
|
184
|
+
const col = m[1].replace(/\"/g, '').replace(/"/g, '').trim();
|
|
185
|
+
indexes[row.indexname] = [col];
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
} else if (IS_SQLITE) {
|
|
189
|
+
const list = await sql.unsafe(`PRAGMA index_list(${tableName})`);
|
|
190
|
+
for (const idx of list) {
|
|
191
|
+
const info = await sql.unsafe(`PRAGMA index_info(${idx.name})`);
|
|
192
|
+
const cols = info.map((r) => r.name);
|
|
193
|
+
if (cols.length === 1) indexes[idx.name] = cols;
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
return indexes;
|
|
198
|
+
} catch (error: any) {
|
|
199
|
+
throw new Error(`获取表索引信息失败 [${tableName}]: ${error.message}`);
|
|
200
|
+
}
|
|
201
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* syncDb SQLite 特殊处理模块
|
|
3
|
+
*
|
|
4
|
+
* 包含:
|
|
5
|
+
* - SQLite 重建表迁移(处理列修改等不支持的操作)
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { Logger } from '../../lib/logger.js';
|
|
9
|
+
import { createTable } from './tableCreate.js';
|
|
10
|
+
import type { SQL } from 'bun';
|
|
11
|
+
|
|
12
|
+
// 是否为计划模式(从环境变量读取)
|
|
13
|
+
const IS_PLAN = process.argv.includes('--plan');
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* SQLite 重建表迁移(简化版)
|
|
17
|
+
*
|
|
18
|
+
* SQLite 不支持修改列类型等操作,需要通过重建表实现:
|
|
19
|
+
* 1. 创建临时表(新结构)
|
|
20
|
+
* 2. 拷贝数据(仅公共列)
|
|
21
|
+
* 3. 删除旧表
|
|
22
|
+
* 4. 重命名临时表
|
|
23
|
+
*
|
|
24
|
+
* 注意:仅处理新增/修改字段,不处理复杂约束与复合索引
|
|
25
|
+
*
|
|
26
|
+
* @param sql - SQL 客户端实例
|
|
27
|
+
* @param tableName - 表名
|
|
28
|
+
* @param fields - 字段定义对象
|
|
29
|
+
*/
|
|
30
|
+
export async function rebuildSqliteTable(sql: SQL, tableName: string, fields: Record<string, string>): Promise<void> {
|
|
31
|
+
// 1. 读取现有列顺序
|
|
32
|
+
const info = await sql.unsafe(`PRAGMA table_info(${tableName})`);
|
|
33
|
+
const existingCols = info.map((r) => r.name);
|
|
34
|
+
const targetCols = ['id', 'created_at', 'updated_at', 'deleted_at', 'state', ...Object.keys(fields)];
|
|
35
|
+
const tmpTable = `${tableName}__tmp__${Date.now()}`;
|
|
36
|
+
|
|
37
|
+
// 2. 创建新表(使用当前定义)
|
|
38
|
+
await createTable(sql, tmpTable, fields);
|
|
39
|
+
|
|
40
|
+
// 3. 拷贝数据(按交集列)
|
|
41
|
+
const commonCols = targetCols.filter((c) => existingCols.includes(c));
|
|
42
|
+
if (commonCols.length > 0) {
|
|
43
|
+
const colsSql = commonCols.map((c) => `"${c}"`).join(', ');
|
|
44
|
+
await sql.unsafe(`INSERT INTO "${tmpTable}" (${colsSql}) SELECT ${colsSql} FROM "${tableName}"`);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// 4. 删除旧表并重命名
|
|
48
|
+
await sql.unsafe(`DROP TABLE "${tableName}"`);
|
|
49
|
+
await sql.unsafe(`ALTER TABLE "${tmpTable}" RENAME TO "${tableName}"`);
|
|
50
|
+
}
|
|
@@ -0,0 +1,321 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* syncDb 表操作模块
|
|
3
|
+
*
|
|
4
|
+
* 包含:
|
|
5
|
+
* - 修改表结构
|
|
6
|
+
* - 对比字段变化
|
|
7
|
+
* - 应用变更计划
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { snakeCase } from 'es-toolkit/string';
|
|
11
|
+
import { Logger } from '../../lib/logger.js';
|
|
12
|
+
import { IS_MYSQL, IS_PG, IS_SQLITE, SYSTEM_INDEX_FIELDS, CHANGE_TYPE_LABELS, typeMapping } from './constants.js';
|
|
13
|
+
import { quoteIdentifier, logFieldChange, resolveDefaultValue, generateDefaultSql, isStringOrArrayType, getSqlType } from './helpers.js';
|
|
14
|
+
import { buildIndexSQL, generateDDLClause, isPgCompatibleTypeChange } from './ddl.js';
|
|
15
|
+
import { getTableColumns, getTableIndexes } from './schema.js';
|
|
16
|
+
import { compareFieldDefinition, applyTablePlan } from './apply.js';
|
|
17
|
+
import { createTable } from './tableCreate.js';
|
|
18
|
+
import type { TablePlan, ColumnInfo } from '../../types.js';
|
|
19
|
+
import type { SQL } from 'bun';
|
|
20
|
+
import type { FieldDefinition } from 'befly/types/common';
|
|
21
|
+
|
|
22
|
+
// 是否为计划模式(从环境变量读取)
|
|
23
|
+
const IS_PLAN = process.argv.includes('--plan');
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* 同步表结构(对比和应用变更)
|
|
27
|
+
*
|
|
28
|
+
* 主要逻辑:
|
|
29
|
+
* 1. 获取表的现有列和索引信息
|
|
30
|
+
* 2. 对比每个字段的定义变化
|
|
31
|
+
* 3. 生成变更计划(添加/修改/删除列,添加/删除索引)
|
|
32
|
+
* 4. 执行变更 SQL
|
|
33
|
+
*
|
|
34
|
+
* @param sql - SQL 客户端实例
|
|
35
|
+
* @param tableName - 表名
|
|
36
|
+
* @param tableDefinition - 表定义(JSON)
|
|
37
|
+
* @param force - 是否强制同步(删除多余字段)
|
|
38
|
+
* @param dbName - 数据库名称
|
|
39
|
+
*/
|
|
40
|
+
export async function modifyTable(sql: SQL, tableName: string, tableDefinition: TablePlan, force: boolean, dbName?: string): Promise<void> {
|
|
41
|
+
try {
|
|
42
|
+
// 1. 获取现有表结构
|
|
43
|
+
const currentColumns = await getTableColumns(sql, tableName, dbName);
|
|
44
|
+
const currentIndexes = await getTableIndexes(sql, tableName, dbName);
|
|
45
|
+
|
|
46
|
+
// 2. 对比字段变化
|
|
47
|
+
const changes: string[] = [];
|
|
48
|
+
const processedColumns = new Set<string>();
|
|
49
|
+
const processedIndexes = new Set<string>();
|
|
50
|
+
const newColumns: string[] = []; // 记录新增的列名,用于后续添加索引
|
|
51
|
+
|
|
52
|
+
// 遍历定义中的字段
|
|
53
|
+
for (const [fieldName, fieldDef] of Object.entries(tableDefinition)) {
|
|
54
|
+
const snakeFieldName = snakeCase(fieldName);
|
|
55
|
+
processedColumns.add(snakeFieldName);
|
|
56
|
+
|
|
57
|
+
// 检查字段是否存在
|
|
58
|
+
if (!currentColumns[snakeFieldName]) {
|
|
59
|
+
// 新增字段
|
|
60
|
+
changes.push(generateDDLClause('ADD_COLUMN', tableName, snakeFieldName, fieldDef));
|
|
61
|
+
logFieldChange(tableName, snakeFieldName, 'add', '新增字段');
|
|
62
|
+
newColumns.push(snakeFieldName);
|
|
63
|
+
} else {
|
|
64
|
+
// 修改字段
|
|
65
|
+
const currentDef = currentColumns[snakeFieldName];
|
|
66
|
+
const diff = compareFieldDefinition(fieldDef, currentDef);
|
|
67
|
+
if (diff) {
|
|
68
|
+
changes.push(generateDDLClause('MODIFY_COLUMN', tableName, snakeFieldName, fieldDef));
|
|
69
|
+
logFieldChange(tableName, snakeFieldName, 'modify', `修改字段: ${diff}`);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// 检查多余字段(仅在 force 模式下删除)
|
|
75
|
+
if (force) {
|
|
76
|
+
for (const colName of Object.keys(currentColumns)) {
|
|
77
|
+
if (!processedColumns.has(colName)) {
|
|
78
|
+
changes.push(generateDDLClause('DROP_COLUMN', tableName, colName));
|
|
79
|
+
logFieldChange(tableName, colName, 'drop', '删除多余字段');
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// 3. 对比索引变化
|
|
85
|
+
// 自动为 _id, _at 结尾的字段添加索引
|
|
86
|
+
// 以及 unique=true 的字段
|
|
87
|
+
const expectedIndexes: { [key: string]: string[] } = {};
|
|
88
|
+
|
|
89
|
+
for (const [fieldName, fieldDef] of Object.entries(tableDefinition)) {
|
|
90
|
+
const snakeFieldName = snakeCase(fieldName);
|
|
91
|
+
|
|
92
|
+
// 唯一索引
|
|
93
|
+
if (fieldDef.unique) {
|
|
94
|
+
const indexName = `uk_${tableName}_${snakeFieldName}`;
|
|
95
|
+
expectedIndexes[indexName] = [snakeFieldName];
|
|
96
|
+
}
|
|
97
|
+
// 普通索引 (index=true 或 _id/_at 结尾)
|
|
98
|
+
else if (fieldDef.index || snakeFieldName.endsWith('_id') || snakeFieldName.endsWith('_at')) {
|
|
99
|
+
// 排除主键 id
|
|
100
|
+
if (snakeFieldName === 'id') continue;
|
|
101
|
+
|
|
102
|
+
// 排除大文本类型
|
|
103
|
+
if (['text', 'longtext', 'json'].includes(fieldDef.type || '')) continue;
|
|
104
|
+
|
|
105
|
+
const indexName = `idx_${tableName}_${snakeFieldName}`;
|
|
106
|
+
expectedIndexes[indexName] = [snakeFieldName];
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// 检查新增/修改索引
|
|
111
|
+
for (const [indexName, columns] of Object.entries(expectedIndexes)) {
|
|
112
|
+
processedIndexes.add(indexName);
|
|
113
|
+
|
|
114
|
+
const currentIndex = currentIndexes[indexName];
|
|
115
|
+
if (!currentIndex) {
|
|
116
|
+
// 新增索引
|
|
117
|
+
changes.push(buildIndexSQL('ADD', tableName, indexName, columns, indexName.startsWith('uk_')));
|
|
118
|
+
logFieldChange(tableName, indexName, 'add_index', `新增索引 (${columns.join(',')})`);
|
|
119
|
+
} else {
|
|
120
|
+
// 索引存在,检查是否一致
|
|
121
|
+
const isSame = currentIndex.length === columns.length && currentIndex.every((col, i) => col === columns[i]);
|
|
122
|
+
|
|
123
|
+
if (!isSame) {
|
|
124
|
+
// 修改索引(先删后加)
|
|
125
|
+
changes.push(buildIndexSQL('DROP', tableName, indexName));
|
|
126
|
+
changes.push(buildIndexSQL('ADD', tableName, indexName, columns, indexName.startsWith('uk_')));
|
|
127
|
+
logFieldChange(tableName, indexName, 'modify_index', `修改索引 (${columns.join(',')})`);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// 检查多余索引(仅在 force 模式下删除)
|
|
133
|
+
if (force) {
|
|
134
|
+
for (const indexName of Object.keys(currentIndexes)) {
|
|
135
|
+
// 跳过系统索引
|
|
136
|
+
if (SYSTEM_INDEX_FIELDS.includes(indexName)) continue;
|
|
137
|
+
|
|
138
|
+
if (!processedIndexes.has(indexName)) {
|
|
139
|
+
changes.push(buildIndexSQL('DROP', tableName, indexName));
|
|
140
|
+
logFieldChange(tableName, indexName, 'drop_index', '删除多余索引');
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// 4. 执行变更
|
|
146
|
+
if (changes.length > 0) {
|
|
147
|
+
if (IS_PLAN) {
|
|
148
|
+
// 计划模式:只输出 SQL
|
|
149
|
+
Logger.info(`[PLAN] 表 ${tableName} 变更 SQL:`);
|
|
150
|
+
for (const sqlStr of changes) {
|
|
151
|
+
console.log(sqlStr + ';');
|
|
152
|
+
}
|
|
153
|
+
} else {
|
|
154
|
+
// 执行模式
|
|
155
|
+
Logger.info(`正在同步表 ${tableName} 结构...`);
|
|
156
|
+
for (const sqlStr of changes) {
|
|
157
|
+
try {
|
|
158
|
+
await sql.unsafe(sqlStr);
|
|
159
|
+
} catch (error: any) {
|
|
160
|
+
Logger.warn(`执行 SQL 失败: ${sqlStr} - ${error.message}`);
|
|
161
|
+
// 继续执行后续变更
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
} catch (error: any) {
|
|
167
|
+
throw new Error(`同步表结构失败 [${tableName}]: ${error.message}`);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
export async function modifyTable(sql: SQL, tableName: string, fields: Record<string, FieldDefinition>, force: boolean = false): Promise<TablePlan> {
|
|
171
|
+
const existingColumns = await getTableColumns(sql, tableName);
|
|
172
|
+
const existingIndexes = await getTableIndexes(sql, tableName);
|
|
173
|
+
let changed = false;
|
|
174
|
+
|
|
175
|
+
const addClauses = [];
|
|
176
|
+
const modifyClauses = [];
|
|
177
|
+
const defaultClauses = [];
|
|
178
|
+
const indexActions = [];
|
|
179
|
+
|
|
180
|
+
for (const [fieldKey, fieldDef] of Object.entries(fields)) {
|
|
181
|
+
// 转换字段名为下划线格式
|
|
182
|
+
const dbFieldName = snakeCase(fieldKey);
|
|
183
|
+
|
|
184
|
+
if (existingColumns[dbFieldName]) {
|
|
185
|
+
const comparison = compareFieldDefinition(existingColumns[dbFieldName], fieldDef, dbFieldName);
|
|
186
|
+
if (comparison.length > 0) {
|
|
187
|
+
for (const c of comparison) {
|
|
188
|
+
// 使用统一的日志格式函数和常量标签
|
|
189
|
+
const changeLabel = CHANGE_TYPE_LABELS[c.type] || '未知';
|
|
190
|
+
logFieldChange(tableName, dbFieldName, c.type, c.current, c.expected, changeLabel);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
if (isStringOrArrayType(fieldDef.type) && existingColumns[dbFieldName].max) {
|
|
194
|
+
if (existingColumns[dbFieldName].max > fieldDef.max) {
|
|
195
|
+
if (force) {
|
|
196
|
+
Logger.warn(`[强制执行] ${tableName}.${dbFieldName} 长度收缩 ${existingColumns[dbFieldName].max} -> ${fieldDef.max}`);
|
|
197
|
+
} else {
|
|
198
|
+
Logger.warn(`[跳过危险变更] ${tableName}.${dbFieldName} 长度收缩 ${existingColumns[dbFieldName].max} -> ${fieldDef.max} 已被跳过(使用 --force 强制执行)`);
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
const hasTypeChange = comparison.some((c) => c.type === 'datatype');
|
|
204
|
+
const hasLengthChange = comparison.some((c) => c.type === 'length');
|
|
205
|
+
const onlyDefaultChanged = comparison.every((c) => c.type === 'default');
|
|
206
|
+
const defaultChanged = comparison.some((c) => c.type === 'default');
|
|
207
|
+
|
|
208
|
+
// 严格限制:除 string/array 互转外,禁止任何字段类型变更
|
|
209
|
+
if (hasTypeChange) {
|
|
210
|
+
const typeChange = comparison.find((c) => c.type === 'datatype');
|
|
211
|
+
const errorMsg = [`禁止字段类型变更: ${tableName}.${dbFieldName}`, `当前类型: ${typeChange?.current}`, `目标类型: ${typeChange?.expected}`, `说明: 仅允许 string<->array 互相切换,其他类型变更需要手动处理`].join('\n');
|
|
212
|
+
throw new Error(errorMsg);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// 默认值变化处理
|
|
216
|
+
if (defaultChanged) {
|
|
217
|
+
// 使用公共函数处理默认值
|
|
218
|
+
const actualDefault = resolveDefaultValue(fieldDef.default, fieldDef.type);
|
|
219
|
+
|
|
220
|
+
// 生成 SQL DEFAULT 值(不包含前导空格,因为要用于 ALTER COLUMN)
|
|
221
|
+
let v: string | null = null;
|
|
222
|
+
if (actualDefault !== 'null') {
|
|
223
|
+
const defaultSql = generateDefaultSql(actualDefault, fieldDef.type);
|
|
224
|
+
// 移除前导空格 ' DEFAULT ' -> 'DEFAULT '
|
|
225
|
+
v = defaultSql.trim().replace(/^DEFAULT\s+/, '');
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
if (v !== null && v !== '') {
|
|
229
|
+
if (IS_PG) {
|
|
230
|
+
defaultClauses.push(`ALTER COLUMN "${dbFieldName}" SET DEFAULT ${v}`);
|
|
231
|
+
} else if (IS_MYSQL && onlyDefaultChanged) {
|
|
232
|
+
// MySQL 的 TEXT/BLOB 不允许 DEFAULT,跳过 text 类型
|
|
233
|
+
if (fieldDef.type !== 'text') {
|
|
234
|
+
defaultClauses.push(`ALTER COLUMN \`${dbFieldName}\` SET DEFAULT ${v}`);
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// 若不仅仅是默认值变化,继续生成修改子句
|
|
241
|
+
if (!onlyDefaultChanged) {
|
|
242
|
+
let skipModify = false;
|
|
243
|
+
if (hasLengthChange && isStringOrArrayType(fieldDef.type) && existingColumns[dbFieldName].max) {
|
|
244
|
+
const isShrink = existingColumns[dbFieldName].max > fieldDef.max;
|
|
245
|
+
if (isShrink && !force) skipModify = true;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
if (hasTypeChange) {
|
|
249
|
+
if (IS_PG && isPgCompatibleTypeChange(existingColumns[dbFieldName].type, typeMapping[fieldDef.type].toLowerCase())) {
|
|
250
|
+
Logger.debug(`[PG兼容类型变更] ${tableName}.${dbFieldName} ${existingColumns[dbFieldName].type} -> ${typeMapping[fieldDef.type].toLowerCase()} 允许执行`);
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
if (!skipModify) modifyClauses.push(generateDDLClause(fieldKey, fieldDef, false));
|
|
255
|
+
}
|
|
256
|
+
changed = true;
|
|
257
|
+
}
|
|
258
|
+
} else {
|
|
259
|
+
const lenPart = isStringOrArrayType(fieldDef.type) ? ` 长度:${parseInt(String(fieldDef.max))}` : '';
|
|
260
|
+
Logger.debug(` + 新增字段 ${dbFieldName} (${fieldDef.type}${lenPart})`);
|
|
261
|
+
addClauses.push(generateDDLClause(fieldKey, fieldDef, true));
|
|
262
|
+
changed = true;
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
// 检查系统字段索引
|
|
267
|
+
for (const sysField of ['created_at', 'updated_at', 'state']) {
|
|
268
|
+
const idxName = `idx_${sysField}`;
|
|
269
|
+
if (!existingIndexes[idxName]) {
|
|
270
|
+
indexActions.push({ action: 'create', indexName: idxName, fieldName: sysField });
|
|
271
|
+
changed = true;
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
// 检查业务字段索引
|
|
276
|
+
for (const [fieldKey, fieldDef] of Object.entries(fields)) {
|
|
277
|
+
// 转换字段名为下划线格式
|
|
278
|
+
const dbFieldName = snakeCase(fieldKey);
|
|
279
|
+
|
|
280
|
+
const indexName = `idx_${dbFieldName}`;
|
|
281
|
+
if (fieldDef.index && !existingIndexes[indexName]) {
|
|
282
|
+
indexActions.push({ action: 'create', indexName: indexName, fieldName: dbFieldName });
|
|
283
|
+
changed = true;
|
|
284
|
+
} else if (!fieldDef.index && existingIndexes[indexName] && existingIndexes[indexName].length === 1) {
|
|
285
|
+
indexActions.push({ action: 'drop', indexName: indexName, fieldName: dbFieldName });
|
|
286
|
+
changed = true;
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
// PG 列注释处理
|
|
291
|
+
const commentActions = [];
|
|
292
|
+
if (IS_PG) {
|
|
293
|
+
for (const [fieldKey, fieldDef] of Object.entries(fields)) {
|
|
294
|
+
// 转换字段名为下划线格式
|
|
295
|
+
const dbFieldName = snakeCase(fieldKey);
|
|
296
|
+
|
|
297
|
+
if (existingColumns[dbFieldName]) {
|
|
298
|
+
const curr = existingColumns[dbFieldName].comment || '';
|
|
299
|
+
const want = fieldDef.name && fieldDef.name !== 'null' ? String(fieldDef.name) : '';
|
|
300
|
+
if (want !== curr) {
|
|
301
|
+
// 防止 SQL 注入:转义单引号
|
|
302
|
+
const escapedWant = want.replace(/'/g, "''");
|
|
303
|
+
commentActions.push(`COMMENT ON COLUMN "${tableName}"."${dbFieldName}" IS ${want ? `'${escapedWant}'` : 'NULL'}`);
|
|
304
|
+
changed = true;
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
// 仅当存在实际动作时才认为有变更(避免仅日志的收缩跳过被计为修改)
|
|
311
|
+
changed = addClauses.length > 0 || modifyClauses.length > 0 || defaultClauses.length > 0 || indexActions.length > 0 || commentActions.length > 0;
|
|
312
|
+
|
|
313
|
+
const plan = { changed, addClauses, modifyClauses, defaultClauses, indexActions, commentActions };
|
|
314
|
+
|
|
315
|
+
// 将计划应用(包含 --plan 情况下仅输出)
|
|
316
|
+
if (plan.changed) {
|
|
317
|
+
await applyTablePlan(sql, tableName, fields, plan);
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
return plan;
|
|
321
|
+
}
|