befly 2.3.1 → 2.3.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.
- package/bin/befly.js +109 -0
- package/checks/table.js +79 -84
- package/config/env.js +10 -8
- package/package.json +7 -14
- package/plugins/db.js +2 -2
- package/scripts/syncDb.js +585 -433
- package/scripts/syncDev.js +96 -0
- package/tables/common.json +10 -10
- package/tables/tool.json +3 -3
- package/utils/index.js +88 -131
- package/utils/sqlManager.js +1 -1
- package/utils/validate.js +7 -9
package/scripts/syncDb.js
CHANGED
|
@@ -1,295 +1,427 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* 数据库表结构同步脚本 -
|
|
2
|
+
* 数据库表结构同步脚本 - 支持 sqlite / mysql / postgresql
|
|
3
|
+
* 注意:MySQL 提供更完整的在线 ALTER 能力;SQLite/PG 的修改能力有差异,部分操作将跳过或分解。
|
|
3
4
|
*/
|
|
4
5
|
|
|
5
6
|
import path from 'node:path';
|
|
6
7
|
import { Env } from '../config/env.js';
|
|
7
8
|
import { Logger } from '../utils/logger.js';
|
|
8
|
-
import {
|
|
9
|
+
import { createSqlClient, toSnakeTableName, isType, parseRule } from '../utils/index.js';
|
|
9
10
|
import { __dirtables, getProjectDir } from '../system.js';
|
|
10
11
|
import { checkTable } from '../checks/table.js';
|
|
11
12
|
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
string: 'VARCHAR',
|
|
15
|
-
text: 'MEDIUMTEXT',
|
|
16
|
-
array: 'VARCHAR'
|
|
17
|
-
};
|
|
13
|
+
// 顶部管理数据库客户端(按需求使用 Bun SQL 模板,不使用 exec 辅助)
|
|
14
|
+
let sql = null;
|
|
18
15
|
|
|
19
|
-
//
|
|
20
|
-
const
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
const s = String(val).toLowerCase();
|
|
25
|
-
return s === 'true' || s === 'on' || s === 'yes';
|
|
26
|
-
};
|
|
27
|
-
|
|
28
|
-
// 命令行参数
|
|
16
|
+
// 方言与类型映射
|
|
17
|
+
const DB = (Env.DB_TYPE || 'mysql').toLowerCase();
|
|
18
|
+
const IS_MYSQL = DB === 'mysql';
|
|
19
|
+
const IS_PG = DB === 'postgresql' || DB === 'postgres';
|
|
20
|
+
const IS_SQLITE = DB === 'sqlite'; // 命令行参数
|
|
29
21
|
const ARGV = Array.isArray(process.argv) ? process.argv : [];
|
|
30
|
-
const
|
|
31
|
-
DRY_RUN: ARGV.includes('--dry-run')
|
|
32
|
-
};
|
|
22
|
+
const IS_PLAN = ARGV.includes('--plan');
|
|
33
23
|
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
ALLOW_TYPE_CHANGE: getFlag(Env.SYNC_ALLOW_TYPE_CHANGE, 0) // 允许类型变更
|
|
24
|
+
// 字段类型映射(按方言)
|
|
25
|
+
const typeMapping = {
|
|
26
|
+
number: IS_SQLITE ? 'INTEGER' : 'BIGINT',
|
|
27
|
+
string: IS_SQLITE ? 'TEXT' : IS_PG ? 'character varying' : 'VARCHAR',
|
|
28
|
+
text: IS_MYSQL ? 'MEDIUMTEXT' : 'TEXT',
|
|
29
|
+
array: IS_SQLITE ? 'TEXT' : IS_PG ? 'character varying' : 'VARCHAR'
|
|
41
30
|
};
|
|
42
31
|
|
|
43
|
-
//
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
default:
|
|
59
|
-
return null;
|
|
60
|
-
}
|
|
32
|
+
// 全局统计
|
|
33
|
+
const globalCount = {
|
|
34
|
+
// 表级
|
|
35
|
+
processedTables: 0,
|
|
36
|
+
createdTables: 0,
|
|
37
|
+
modifiedTables: 0,
|
|
38
|
+
// 字段与索引级
|
|
39
|
+
addFields: 0,
|
|
40
|
+
typeChanges: 0,
|
|
41
|
+
maxChanges: 0, // 映射为长度变化
|
|
42
|
+
minChanges: 0, // 最小值不参与 DDL,比对保留为0
|
|
43
|
+
defaultChanges: 0,
|
|
44
|
+
nameChanges: 0, // 字段显示名(注释)变更
|
|
45
|
+
indexCreate: 0,
|
|
46
|
+
indexDrop: 0
|
|
61
47
|
};
|
|
62
48
|
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
const
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
49
|
+
// PG 兼容类型变更识别:无需数据重写的宽化型变更
|
|
50
|
+
const isPgCompatibleTypeChange = (currentType, newType) => {
|
|
51
|
+
const c = String(currentType || '').toLowerCase();
|
|
52
|
+
const n = String(newType || '').toLowerCase();
|
|
53
|
+
// varchar -> text 视为宽化
|
|
54
|
+
if (c === 'character varying' && n === 'text') return true;
|
|
55
|
+
// text -> character varying 非宽化(可能截断),不兼容
|
|
56
|
+
return false;
|
|
57
|
+
};
|
|
71
58
|
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
59
|
+
// 数据库版本检查(按方言)
|
|
60
|
+
const ensureDbVersion = async () => {
|
|
61
|
+
if (!sql) throw new Error('SQL 客户端未初始化');
|
|
62
|
+
if (IS_MYSQL) {
|
|
63
|
+
const r = await sql`SELECT VERSION() AS version`;
|
|
64
|
+
const version = r[0].version;
|
|
65
|
+
const majorVersion = parseInt(String(version).split('.')[0], 10);
|
|
66
|
+
if (!Number.isFinite(majorVersion) || majorVersion < 8) {
|
|
67
|
+
throw new Error(`此脚本仅支持 MySQL 8.0+,当前版本: ${version}`);
|
|
68
|
+
}
|
|
69
|
+
Logger.info(`MySQL 版本: ${version}`);
|
|
70
|
+
return;
|
|
76
71
|
}
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
columnDef += ` DEFAULT ${expectedDefault}`;
|
|
86
|
-
} else {
|
|
87
|
-
columnDef += ` DEFAULT \"${String(expectedDefault).replace(/\"/g, '\\"')}\"`;
|
|
72
|
+
if (IS_PG) {
|
|
73
|
+
const r = await sql`SELECT version() AS version`;
|
|
74
|
+
const versionText = r[0].version;
|
|
75
|
+
Logger.info(`PostgreSQL 版本: ${versionText}`);
|
|
76
|
+
const m = /PostgreSQL\s+(\d+)/i.exec(versionText);
|
|
77
|
+
const major = m ? parseInt(m[1], 10) : NaN;
|
|
78
|
+
if (!Number.isFinite(major) || major < 17) {
|
|
79
|
+
throw new Error(`此脚本要求 PostgreSQL >= 17,当前: ${versionText}`);
|
|
88
80
|
}
|
|
81
|
+
return;
|
|
89
82
|
}
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
83
|
+
if (IS_SQLITE) {
|
|
84
|
+
const r = await sql`SELECT sqlite_version() AS version`;
|
|
85
|
+
const version = r[0].version;
|
|
86
|
+
Logger.info(`SQLite 版本: ${version}`);
|
|
87
|
+
// 强制最低版本:SQLite ≥ 3.50.0
|
|
88
|
+
const [maj, min, patch] = String(version)
|
|
89
|
+
.split('.')
|
|
90
|
+
.map((v) => parseInt(v, 10) || 0);
|
|
91
|
+
const vnum = maj * 10000 + min * 100 + patch; // 3.50.0 -> 35000
|
|
92
|
+
if (!Number.isFinite(vnum) || vnum < 35000) {
|
|
93
|
+
throw new Error(`此脚本要求 SQLite >= 3.50.0,当前: ${version}`);
|
|
94
|
+
}
|
|
95
|
+
return;
|
|
95
96
|
}
|
|
96
|
-
|
|
97
|
-
return columnDef;
|
|
98
97
|
};
|
|
99
98
|
|
|
100
|
-
//
|
|
101
|
-
const
|
|
102
|
-
if (
|
|
103
|
-
|
|
99
|
+
// 判断表是否存在(返回布尔值)
|
|
100
|
+
const tableExists = async (tableName) => {
|
|
101
|
+
if (!sql) throw new Error('SQL 客户端未初始化');
|
|
102
|
+
if (IS_MYSQL) {
|
|
103
|
+
const res = await sql`SELECT COUNT(*) AS count FROM information_schema.TABLES WHERE TABLE_SCHEMA = ${Env.DB_NAME} AND TABLE_NAME = ${tableName}`;
|
|
104
|
+
return (res[0]?.count || 0) > 0;
|
|
105
|
+
}
|
|
106
|
+
if (IS_PG) {
|
|
107
|
+
const res = await sql`SELECT COUNT(*)::int AS count FROM information_schema.tables WHERE table_schema = 'public' AND table_name = ${tableName}`;
|
|
108
|
+
return (res[0]?.count || 0) > 0;
|
|
104
109
|
}
|
|
105
|
-
|
|
110
|
+
if (IS_SQLITE) {
|
|
111
|
+
const res = await sql`SELECT name FROM sqlite_master WHERE type='table' AND name = ${tableName}`;
|
|
112
|
+
return res.length > 0;
|
|
113
|
+
}
|
|
114
|
+
return false;
|
|
106
115
|
};
|
|
107
116
|
|
|
108
|
-
//
|
|
109
|
-
const getTableColumns = async (
|
|
110
|
-
const result = await exec(
|
|
111
|
-
client,
|
|
112
|
-
`SELECT COLUMN_NAME, DATA_TYPE, CHARACTER_MAXIMUM_LENGTH, IS_NULLABLE, COLUMN_DEFAULT, COLUMN_COMMENT, COLUMN_TYPE
|
|
113
|
-
FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = ? AND TABLE_NAME = ? ORDER BY ORDINAL_POSITION`,
|
|
114
|
-
[Env.MYSQL_DB || 'test', tableName]
|
|
115
|
-
);
|
|
116
|
-
|
|
117
|
+
// 获取表的现有列信息(按方言)
|
|
118
|
+
const getTableColumns = async (tableName) => {
|
|
117
119
|
const columns = {};
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
120
|
+
if (IS_MYSQL) {
|
|
121
|
+
const result = await sql`
|
|
122
|
+
SELECT COLUMN_NAME, DATA_TYPE, CHARACTER_MAXIMUM_LENGTH, IS_NULLABLE, COLUMN_DEFAULT, COLUMN_COMMENT, COLUMN_TYPE
|
|
123
|
+
FROM information_schema.COLUMNS
|
|
124
|
+
WHERE TABLE_SCHEMA = ${Env.DB_NAME} AND TABLE_NAME = ${tableName}
|
|
125
|
+
ORDER BY ORDINAL_POSITION
|
|
126
|
+
`;
|
|
127
|
+
for (const row of result) {
|
|
128
|
+
columns[row.COLUMN_NAME] = {
|
|
129
|
+
type: row.DATA_TYPE,
|
|
130
|
+
columnType: row.COLUMN_TYPE,
|
|
131
|
+
length: row.CHARACTER_MAXIMUM_LENGTH,
|
|
132
|
+
nullable: row.IS_NULLABLE === 'YES',
|
|
133
|
+
defaultValue: row.COLUMN_DEFAULT,
|
|
134
|
+
comment: row.COLUMN_COMMENT
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
} else if (IS_PG) {
|
|
138
|
+
const result = await sql`SELECT column_name, data_type, character_maximum_length, is_nullable, column_default
|
|
139
|
+
FROM information_schema.columns
|
|
140
|
+
WHERE table_schema = 'public' AND table_name = ${tableName}
|
|
141
|
+
ORDER BY ordinal_position`;
|
|
142
|
+
// 获取列注释
|
|
143
|
+
const comments = await sql`SELECT a.attname AS column_name, col_description(c.oid, a.attnum) AS column_comment
|
|
144
|
+
FROM pg_class c
|
|
145
|
+
JOIN pg_attribute a ON a.attrelid = c.oid
|
|
146
|
+
JOIN pg_namespace n ON n.oid = c.relnamespace
|
|
147
|
+
WHERE c.relkind = 'r' AND n.nspname = 'public' AND c.relname = ${tableName} AND a.attnum > 0`;
|
|
148
|
+
const commentMap = {};
|
|
149
|
+
for (const r of comments) commentMap[r.column_name] = r.column_comment;
|
|
150
|
+
for (const row of result) {
|
|
151
|
+
columns[row.column_name] = {
|
|
152
|
+
type: row.data_type,
|
|
153
|
+
columnType: row.data_type,
|
|
154
|
+
length: row.character_maximum_length,
|
|
155
|
+
nullable: String(row.is_nullable).toUpperCase() === 'YES',
|
|
156
|
+
defaultValue: row.column_default,
|
|
157
|
+
comment: commentMap[row.column_name] ?? null
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
} else if (IS_SQLITE) {
|
|
161
|
+
const result = await sql`PRAGMA table_info(${sql(tableName)})`;
|
|
162
|
+
for (const row of result) {
|
|
163
|
+
let baseType = String(row.type || '').toUpperCase();
|
|
164
|
+
let length = null;
|
|
165
|
+
const m = /^(\w+)\s*\((\d+)\)/.exec(baseType);
|
|
166
|
+
if (m) {
|
|
167
|
+
baseType = m[1];
|
|
168
|
+
length = Number(m[2]);
|
|
169
|
+
}
|
|
170
|
+
columns[row.name] = {
|
|
171
|
+
type: baseType.toLowerCase(),
|
|
172
|
+
columnType: baseType.toLowerCase(),
|
|
173
|
+
length: length,
|
|
174
|
+
nullable: row.notnull === 0,
|
|
175
|
+
defaultValue: row.dflt_value,
|
|
176
|
+
comment: null
|
|
177
|
+
};
|
|
178
|
+
}
|
|
179
|
+
}
|
|
128
180
|
return columns;
|
|
129
181
|
};
|
|
130
182
|
|
|
131
|
-
//
|
|
132
|
-
const getTableIndexes = async (
|
|
133
|
-
const result = await exec(
|
|
134
|
-
client,
|
|
135
|
-
`SELECT INDEX_NAME, COLUMN_NAME FROM information_schema.STATISTICS
|
|
136
|
-
WHERE TABLE_SCHEMA = ? AND TABLE_NAME = ? AND INDEX_NAME != 'PRIMARY' ORDER BY INDEX_NAME`,
|
|
137
|
-
[Env.MYSQL_DB || 'test', tableName]
|
|
138
|
-
);
|
|
139
|
-
|
|
183
|
+
// 获取表的现有索引信息(单列索引)
|
|
184
|
+
const getTableIndexes = async (tableName) => {
|
|
140
185
|
const indexes = {};
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
186
|
+
if (IS_MYSQL) {
|
|
187
|
+
const result = await sql`
|
|
188
|
+
SELECT INDEX_NAME, COLUMN_NAME
|
|
189
|
+
FROM information_schema.STATISTICS
|
|
190
|
+
WHERE TABLE_SCHEMA = ${Env.DB_NAME}
|
|
191
|
+
AND TABLE_NAME = ${tableName}
|
|
192
|
+
AND INDEX_NAME != 'PRIMARY'
|
|
193
|
+
ORDER BY INDEX_NAME
|
|
194
|
+
`;
|
|
195
|
+
for (const row of result) {
|
|
196
|
+
if (!indexes[row.INDEX_NAME]) indexes[row.INDEX_NAME] = [];
|
|
197
|
+
indexes[row.INDEX_NAME].push(row.COLUMN_NAME);
|
|
198
|
+
}
|
|
199
|
+
} else if (IS_PG) {
|
|
200
|
+
const result = await sql`SELECT indexname, indexdef FROM pg_indexes WHERE schemaname = 'public' AND tablename = ${tableName}`;
|
|
201
|
+
for (const row of result) {
|
|
202
|
+
const m = /\(([^)]+)\)/.exec(row.indexdef);
|
|
203
|
+
if (m) {
|
|
204
|
+
const col = m[1].replace(/\"/g, '').replace(/"/g, '').trim();
|
|
205
|
+
indexes[row.indexname] = [col];
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
} else if (IS_SQLITE) {
|
|
209
|
+
const list = await sql`PRAGMA index_list(${sql(tableName)})`;
|
|
210
|
+
for (const idx of list) {
|
|
211
|
+
const info = await sql`PRAGMA index_info(${sql(idx.name)})`;
|
|
212
|
+
const cols = info.map((r) => r.name);
|
|
213
|
+
if (cols.length === 1) indexes[idx.name] = cols;
|
|
214
|
+
}
|
|
215
|
+
}
|
|
145
216
|
return indexes;
|
|
146
217
|
};
|
|
147
218
|
|
|
148
|
-
// 构建索引操作 SQL
|
|
219
|
+
// 构建索引操作 SQL(统一使用在线策略)
|
|
149
220
|
const buildIndexSQL = (tableName, indexName, fieldName, action) => {
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
parts.push(`ADD INDEX \`${indexName}\` (\`${fieldName}\`)`);
|
|
153
|
-
|
|
154
|
-
parts.push(`DROP INDEX \`${indexName}\``);
|
|
155
|
-
}
|
|
156
|
-
if (FLAGS.ONLINE_INDEX) {
|
|
221
|
+
if (IS_MYSQL) {
|
|
222
|
+
const parts = [];
|
|
223
|
+
action === 'create' ? parts.push(`ADD INDEX \`${indexName}\` (\`${fieldName}\`)`) : parts.push(`DROP INDEX \`${indexName}\``);
|
|
224
|
+
// 始终使用在线算法
|
|
157
225
|
parts.push('ALGORITHM=INPLACE');
|
|
158
226
|
parts.push('LOCK=NONE');
|
|
227
|
+
return `ALTER TABLE \`${tableName}\` ${parts.join(', ')}`;
|
|
228
|
+
}
|
|
229
|
+
if (IS_PG) {
|
|
230
|
+
if (action === 'create') {
|
|
231
|
+
// 始终使用 CONCURRENTLY
|
|
232
|
+
return `CREATE INDEX CONCURRENTLY IF NOT EXISTS "${indexName}" ON "${tableName}"("${fieldName}")`;
|
|
233
|
+
}
|
|
234
|
+
return `DROP INDEX CONCURRENTLY IF EXISTS "${indexName}"`;
|
|
159
235
|
}
|
|
160
|
-
|
|
236
|
+
// SQLite
|
|
237
|
+
if (action === 'create') return `CREATE INDEX IF NOT EXISTS "${indexName}" ON "${tableName}"("${fieldName}")`;
|
|
238
|
+
return `DROP INDEX IF EXISTS "${indexName}"`;
|
|
161
239
|
};
|
|
162
240
|
|
|
163
|
-
//
|
|
164
|
-
const createTable = async (
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
'`
|
|
171
|
-
'`
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
//
|
|
176
|
-
'INDEX `idx_created_at` (`created_at`)',
|
|
177
|
-
'INDEX `idx_updated_at` (`updated_at`)',
|
|
178
|
-
'INDEX `idx_state` (`state`)'
|
|
179
|
-
];
|
|
180
|
-
|
|
181
|
-
// 添加自定义字段和索引
|
|
182
|
-
for (const [fieldName, rule] of Object.entries(fields)) {
|
|
183
|
-
columns.push(getColumnDefinition(fieldName, rule));
|
|
184
|
-
|
|
185
|
-
// 使用第6个属性判断是否设置索引
|
|
186
|
-
const ruleParts = parseFieldRule(rule);
|
|
187
|
-
const fieldHasIndex = ruleParts[5]; // 第6个属性
|
|
188
|
-
if (fieldHasIndex === '1') {
|
|
189
|
-
indexes.push(`INDEX \`idx_${fieldName}\` (\`${fieldName}\`)`);
|
|
190
|
-
}
|
|
191
|
-
}
|
|
192
|
-
|
|
193
|
-
const createTableSQL = `
|
|
194
|
-
CREATE TABLE \`${tableName}\` (
|
|
195
|
-
${columns.join(',\n ')},
|
|
196
|
-
${indexes.join(',\n ')}
|
|
197
|
-
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_as_cs
|
|
198
|
-
`;
|
|
199
|
-
|
|
200
|
-
if (FLAGS.DRY_RUN) {
|
|
201
|
-
Logger.info(`[计划] ${createTableSQL.replace(/\n+/g, ' ')}`);
|
|
241
|
+
// 创建表(尽量精简但保持既有行为)
|
|
242
|
+
const createTable = async (tableName, fields) => {
|
|
243
|
+
// 统一列定义数组:包含系统字段与业务字段
|
|
244
|
+
const colDefs = [];
|
|
245
|
+
|
|
246
|
+
// 1) 固定字段
|
|
247
|
+
if (IS_MYSQL) {
|
|
248
|
+
colDefs.push('`id` BIGINT PRIMARY KEY COMMENT "主键ID"');
|
|
249
|
+
colDefs.push('`created_at` BIGINT NOT NULL DEFAULT 0 COMMENT "创建时间"');
|
|
250
|
+
colDefs.push('`updated_at` BIGINT NOT NULL DEFAULT 0 COMMENT "更新时间"');
|
|
251
|
+
colDefs.push('`deleted_at` BIGINT NOT NULL DEFAULT 0 COMMENT "删除时间"');
|
|
252
|
+
colDefs.push('`state` BIGINT NOT NULL DEFAULT 0 COMMENT "状态字段"');
|
|
202
253
|
} else {
|
|
203
|
-
|
|
254
|
+
colDefs.push('"id" INTEGER PRIMARY KEY');
|
|
255
|
+
colDefs.push('"created_at" INTEGER NOT NULL DEFAULT 0');
|
|
256
|
+
colDefs.push('"updated_at" INTEGER NOT NULL DEFAULT 0');
|
|
257
|
+
colDefs.push('"deleted_at" INTEGER NOT NULL DEFAULT 0');
|
|
258
|
+
colDefs.push('"state" INTEGER NOT NULL DEFAULT 0');
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
// 2) 业务字段
|
|
262
|
+
for (const [fieldKey, fieldRule] of Object.entries(fields)) {
|
|
263
|
+
const [fieldName, fieldType, fieldMin, fieldMax, fieldDefault, fieldIndex, fieldRegx] = parseRule(fieldRule);
|
|
264
|
+
const sqlType = ['string', 'array'].includes(fieldType) ? `${typeMapping[fieldType]}(${fieldMax})` : typeMapping[fieldType];
|
|
265
|
+
const defaultSql = ['number', 'string', 'array'].includes(fieldType) ? (isType(fieldDefault, 'number') ? ` DEFAULT ${fieldDefault}` : ` DEFAULT '${fieldDefault}'`) : '';
|
|
266
|
+
if (IS_MYSQL) {
|
|
267
|
+
colDefs.push(`\`${fieldKey}\` ${sqlType} NOT NULL${defaultSql} COMMENT "${String(fieldName).replace(/"/g, '\\"')}"`);
|
|
268
|
+
} else {
|
|
269
|
+
colDefs.push(`"${fieldKey}" ${sqlType} NOT NULL${defaultSql}`);
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
// 3) CREATE TABLE 语句
|
|
274
|
+
const cols = colDefs.join(',\n ');
|
|
275
|
+
let createSQL;
|
|
276
|
+
if (IS_MYSQL) {
|
|
277
|
+
createSQL = `CREATE TABLE \`${tableName}\` (\n ${cols}\n ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_as_cs`;
|
|
278
|
+
} else {
|
|
279
|
+
createSQL = `CREATE TABLE "${tableName}" (\n ${cols}\n )`;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
if (IS_PLAN) {
|
|
283
|
+
Logger.info(`[计划] ${createSQL.replace(/\n+/g, ' ')}`);
|
|
284
|
+
} else {
|
|
285
|
+
await sql.unsafe(createSQL);
|
|
204
286
|
Logger.info(`[新建表] ${tableName}`);
|
|
205
287
|
}
|
|
288
|
+
|
|
289
|
+
// 4) PG: 列注释(SQLite 不支持;MySQL 已在列定义中)
|
|
290
|
+
if (IS_PG) {
|
|
291
|
+
const commentPairs = [
|
|
292
|
+
['id', '主键ID'],
|
|
293
|
+
['created_at', '创建时间'],
|
|
294
|
+
['updated_at', '更新时间'],
|
|
295
|
+
['deleted_at', '删除时间'],
|
|
296
|
+
['state', '状态字段']
|
|
297
|
+
];
|
|
298
|
+
for (const [name, cmt] of commentPairs) {
|
|
299
|
+
const stmt = `COMMENT ON COLUMN "${tableName}"."${name}" IS '${cmt}'`;
|
|
300
|
+
if (IS_PLAN) {
|
|
301
|
+
Logger.info(`[计划] ${stmt}`);
|
|
302
|
+
} else {
|
|
303
|
+
await sql.unsafe(stmt);
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
for (const [fieldKey, fieldRule] of Object.entries(fields)) {
|
|
307
|
+
const [fieldName, fieldType, fieldMin, fieldMax, fieldDefault, fieldIndex, fieldRegx] = parseRule(fieldRule);
|
|
308
|
+
const stmt = `COMMENT ON COLUMN "${tableName}"."${fieldKey}" IS '${fieldName}'`;
|
|
309
|
+
if (IS_PLAN) {
|
|
310
|
+
Logger.info(`[计划] ${stmt}`);
|
|
311
|
+
} else {
|
|
312
|
+
await sql.unsafe(stmt);
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
// 5) 索引:系统字段 + 业务字段(按规则)
|
|
318
|
+
for (const sysField of ['created_at', 'updated_at', 'state']) {
|
|
319
|
+
const stmt = buildIndexSQL(tableName, `idx_${sysField}`, sysField, 'create');
|
|
320
|
+
if (IS_PLAN) {
|
|
321
|
+
Logger.info(`[计划] ${stmt}`);
|
|
322
|
+
} else {
|
|
323
|
+
await sql.unsafe(stmt);
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
for (const [fieldKey, fieldRule] of Object.entries(fields)) {
|
|
327
|
+
const [fieldName, fieldType, fieldMin, fieldMax, fieldDefault, fieldIndex, fieldRegx] = parseRule(fieldRule);
|
|
328
|
+
if (fieldIndex === 1) {
|
|
329
|
+
const stmt = buildIndexSQL(tableName, `idx_${fieldKey}`, fieldKey, 'create');
|
|
330
|
+
if (IS_PLAN) {
|
|
331
|
+
Logger.info(`[计划] ${stmt}`);
|
|
332
|
+
} else {
|
|
333
|
+
await sql.unsafe(stmt);
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
}
|
|
206
337
|
};
|
|
207
338
|
|
|
208
339
|
// 比较字段定义变化
|
|
209
|
-
const compareFieldDefinition = (existingColumn, newRule,
|
|
210
|
-
const
|
|
211
|
-
const [fieldDisplayName, fieldType, fieldMin, fieldMaxLength, fieldDefaultValue] = ruleParts;
|
|
340
|
+
const compareFieldDefinition = (existingColumn, newRule, colName) => {
|
|
341
|
+
const [fieldName, fieldType, fieldMin, fieldMax, fieldDefault, fieldIndex, fieldRegx] = parseRule(newRule);
|
|
212
342
|
const changes = [];
|
|
213
343
|
|
|
214
|
-
// 检查长度变化(string和array类型)
|
|
215
|
-
if (fieldType === 'string' || fieldType === 'array') {
|
|
216
|
-
if (
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
344
|
+
// 检查长度变化(string和array类型) - SQLite 不比较长度
|
|
345
|
+
if (!IS_SQLITE && (fieldType === 'string' || fieldType === 'array')) {
|
|
346
|
+
if (existingColumn.length !== fieldMax) {
|
|
347
|
+
changes.push({
|
|
348
|
+
type: 'length',
|
|
349
|
+
current: existingColumn.length,
|
|
350
|
+
new: fieldMax
|
|
351
|
+
});
|
|
222
352
|
}
|
|
223
353
|
}
|
|
224
354
|
|
|
225
|
-
//
|
|
226
|
-
if (
|
|
355
|
+
// 检查注释变化(MySQL/PG 支持列注释)
|
|
356
|
+
if (!IS_SQLITE) {
|
|
227
357
|
const currentComment = existingColumn.comment || '';
|
|
228
|
-
if (currentComment !==
|
|
229
|
-
changes.push({
|
|
358
|
+
if (currentComment !== fieldName) {
|
|
359
|
+
changes.push({
|
|
360
|
+
type: 'comment',
|
|
361
|
+
current: currentComment,
|
|
362
|
+
new: fieldName
|
|
363
|
+
});
|
|
230
364
|
}
|
|
231
365
|
}
|
|
232
366
|
|
|
233
|
-
//
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
if (existingColumn.type.toLowerCase() !== expectedDbType) {
|
|
242
|
-
changes.push({ type: 'datatype', current: existingColumn.type, new: expectedDbType });
|
|
367
|
+
// 检查数据类型变化(按方言)
|
|
368
|
+
if (existingColumn.type.toLowerCase() !== typeMapping[fieldType].toLowerCase()) {
|
|
369
|
+
changes.push({
|
|
370
|
+
type: 'datatype',
|
|
371
|
+
current: existingColumn.type,
|
|
372
|
+
new: typeMapping[fieldType].toLowerCase()
|
|
373
|
+
});
|
|
243
374
|
}
|
|
244
375
|
|
|
245
376
|
// 检查默认值变化(按照生成规则推导期望默认值)
|
|
246
|
-
|
|
247
|
-
const currDef = normalizeDefault(existingColumn.defaultValue);
|
|
248
|
-
const newDef = normalizeDefault(expectedDefault);
|
|
249
|
-
if (currDef !== newDef) {
|
|
250
|
-
changes.push({ type: 'default', current: existingColumn.defaultValue, new: expectedDefault });
|
|
251
|
-
}
|
|
252
|
-
|
|
253
|
-
// 检查可空性变化(统一期望 NOT NULL)
|
|
254
|
-
const expectedNullable = false; // 期望 NOT NULL
|
|
255
|
-
if (existingColumn.nullable !== expectedNullable) {
|
|
256
|
-
// existingColumn.nullable 为 true 表示可空
|
|
377
|
+
if (String(existingColumn.defaultValue) !== String(fieldDefault)) {
|
|
257
378
|
changes.push({
|
|
258
|
-
type: '
|
|
259
|
-
current: existingColumn.
|
|
260
|
-
new:
|
|
379
|
+
type: 'default',
|
|
380
|
+
current: existingColumn.defaultValue,
|
|
381
|
+
new: fieldDefault
|
|
261
382
|
});
|
|
262
383
|
}
|
|
263
384
|
|
|
264
|
-
return
|
|
385
|
+
return changes;
|
|
265
386
|
};
|
|
266
387
|
|
|
267
388
|
// 生成字段 DDL 子句(不含 ALTER TABLE 前缀)
|
|
268
|
-
const generateDDLClause = (
|
|
269
|
-
const
|
|
270
|
-
const
|
|
271
|
-
|
|
389
|
+
const generateDDLClause = (fieldKey, fieldRule, isAdd = false) => {
|
|
390
|
+
const [fieldName, fieldType, fieldMin, fieldMax, fieldDefault, fieldIndex, fieldRegx] = parseRule(fieldRule);
|
|
391
|
+
const sqlType = ['string', 'array'].includes(fieldType) ? `${typeMapping[fieldType]}(${fieldMax})` : typeMapping[fieldType];
|
|
392
|
+
const defaultSql = ['number', 'string', 'array'].includes(fieldType) ? (isType(fieldDefault, 'number') ? ` DEFAULT ${fieldDefault}` : ` DEFAULT '${fieldDefault}'`) : '';
|
|
393
|
+
if (IS_MYSQL) {
|
|
394
|
+
return `${isAdd ? 'ADD COLUMN' : 'MODIFY COLUMN'} \`${fieldKey}\` ${sqlType} NOT NULL${defaultSql} COMMENT "${String(fieldName).replace(/"/g, '\\"')}"`;
|
|
395
|
+
}
|
|
396
|
+
if (IS_PG) {
|
|
397
|
+
if (isAdd) return `ADD COLUMN IF NOT EXISTS "${fieldKey}" ${sqlType} NOT NULL${defaultSql}`;
|
|
398
|
+
// PG 修改:类型与非空可分条执行,生成 TYPE 改变;非空另由上层统一控制
|
|
399
|
+
return `ALTER COLUMN "${fieldKey}" TYPE ${sqlType}`;
|
|
400
|
+
}
|
|
401
|
+
// SQLite 仅支持 ADD COLUMN(>=3.50.0:支持 IF NOT EXISTS)
|
|
402
|
+
if (isAdd) return `ADD COLUMN IF NOT EXISTS "${fieldKey}" ${sqlType} NOT NULL${defaultSql}`;
|
|
403
|
+
return '';
|
|
272
404
|
};
|
|
273
405
|
|
|
274
406
|
// 安全执行DDL语句
|
|
275
|
-
const executeDDLSafely = async (
|
|
407
|
+
const executeDDLSafely = async (stmt) => {
|
|
276
408
|
try {
|
|
277
|
-
await
|
|
409
|
+
await sql.unsafe(stmt);
|
|
278
410
|
return true;
|
|
279
411
|
} catch (error) {
|
|
280
|
-
//
|
|
281
|
-
if (
|
|
282
|
-
const inplaceSql =
|
|
412
|
+
// MySQL 专用降级路径
|
|
413
|
+
if (stmt.includes('ALGORITHM=INSTANT')) {
|
|
414
|
+
const inplaceSql = stmt.replace(/ALGORITHM=INSTANT/g, 'ALGORITHM=INPLACE');
|
|
283
415
|
try {
|
|
284
|
-
await
|
|
416
|
+
await sql.unsafe(inplaceSql);
|
|
285
417
|
return true;
|
|
286
418
|
} catch (inplaceError) {
|
|
287
419
|
// 最后尝试传统DDL:移除 ALGORITHM/LOCK 附加子句后执行
|
|
288
|
-
const traditionSql =
|
|
420
|
+
const traditionSql = stmt
|
|
289
421
|
.replace(/,\s*ALGORITHM=INPLACE/g, '')
|
|
290
422
|
.replace(/,\s*ALGORITHM=INSTANT/g, '')
|
|
291
423
|
.replace(/,\s*LOCK=(NONE|SHARED|EXCLUSIVE)/g, '');
|
|
292
|
-
await
|
|
424
|
+
await sql.unsafe(traditionSql);
|
|
293
425
|
return true;
|
|
294
426
|
}
|
|
295
427
|
} else {
|
|
@@ -298,276 +430,296 @@ const executeDDLSafely = async (client, sql) => {
|
|
|
298
430
|
}
|
|
299
431
|
};
|
|
300
432
|
|
|
433
|
+
// SQLite 重建表迁移(简化版:仅处理新增/修改字段,不处理复杂约束与复合索引)
|
|
434
|
+
const rebuildSqliteTable = async (tableName, fields) => {
|
|
435
|
+
// 1. 读取现有列顺序
|
|
436
|
+
const info = await sql`PRAGMA table_info(${sql(tableName)})`;
|
|
437
|
+
const existingCols = info.map((r) => r.name);
|
|
438
|
+
const targetCols = ['id', 'created_at', 'updated_at', 'deleted_at', 'state', ...Object.keys(fields)];
|
|
439
|
+
const tmpTable = `${tableName}__tmp__${Date.now()}`;
|
|
440
|
+
|
|
441
|
+
// 2. 创建新表(使用当前定义)
|
|
442
|
+
await createTable(tmpTable, fields);
|
|
443
|
+
|
|
444
|
+
// 3. 拷贝数据(按交集列)
|
|
445
|
+
const commonCols = targetCols.filter((c) => existingCols.includes(c));
|
|
446
|
+
if (commonCols.length > 0) {
|
|
447
|
+
const colsSql = commonCols.map((c) => `"${c}"`).join(', ');
|
|
448
|
+
await sql.unsafe(`INSERT INTO "${tmpTable}" (${colsSql}) SELECT ${colsSql} FROM "${tableName}"`);
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
// 4. 删除旧表并重命名
|
|
452
|
+
await sql.unsafe(`DROP TABLE "${tableName}"`);
|
|
453
|
+
await sql.unsafe(`ALTER TABLE "${tmpTable}" RENAME TO "${tableName}"`);
|
|
454
|
+
};
|
|
455
|
+
|
|
456
|
+
// 将表结构计划应用到数据库(执行 DDL/索引/注释等)
|
|
457
|
+
const applyTablePlan = async (tableName, fields, plan) => {
|
|
458
|
+
if (!plan || !plan.changed) return;
|
|
459
|
+
|
|
460
|
+
// SQLite: 仅支持部分 ALTER;需要时走重建
|
|
461
|
+
if (IS_SQLITE) {
|
|
462
|
+
if (plan.modifyClauses.length > 0 || plan.defaultClauses.length > 0) {
|
|
463
|
+
if (IS_PLAN) Logger.info(`[计划] 重建表 ${tableName} 以应用列修改/默认值变化`);
|
|
464
|
+
else await rebuildSqliteTable(tableName, fields);
|
|
465
|
+
} else {
|
|
466
|
+
for (const c of plan.addClauses) {
|
|
467
|
+
const stmt = `ALTER TABLE "${tableName}" ${c}`;
|
|
468
|
+
if (IS_PLAN) Logger.info(`[计划] ${stmt}`);
|
|
469
|
+
else await sql.unsafe(stmt);
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
} else {
|
|
473
|
+
const clauses = [...plan.addClauses, ...plan.modifyClauses];
|
|
474
|
+
if (clauses.length > 0) {
|
|
475
|
+
const suffix = IS_MYSQL ? ', ALGORITHM=INSTANT, LOCK=NONE' : '';
|
|
476
|
+
const stmt = IS_MYSQL ? `ALTER TABLE \`${tableName}\` ${clauses.join(', ')}${suffix}` : `ALTER TABLE "${tableName}" ${clauses.join(', ')}`;
|
|
477
|
+
if (IS_PLAN) Logger.info(`[计划] ${stmt}`);
|
|
478
|
+
else if (IS_MYSQL) await executeDDLSafely(stmt);
|
|
479
|
+
else await sql.unsafe(stmt);
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
// 默认值专用 ALTER(SQLite 不支持)
|
|
484
|
+
if (plan.defaultClauses.length > 0) {
|
|
485
|
+
if (IS_SQLITE) {
|
|
486
|
+
Logger.warn(`SQLite 不支持修改默认值,表 ${tableName} 的默认值变更已跳过`);
|
|
487
|
+
} else {
|
|
488
|
+
const suffix = IS_MYSQL ? ', ALGORITHM=INSTANT, LOCK=NONE' : '';
|
|
489
|
+
const stmt = IS_MYSQL ? `ALTER TABLE \`${tableName}\` ${plan.defaultClauses.join(', ')}${suffix}` : `ALTER TABLE "${tableName}" ${plan.defaultClauses.join(', ')}`;
|
|
490
|
+
if (IS_PLAN) Logger.info(`[计划] ${stmt}`);
|
|
491
|
+
else if (IS_MYSQL) await executeDDLSafely(stmt);
|
|
492
|
+
else await sql.unsafe(stmt);
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
// 索引操作
|
|
497
|
+
for (const act of plan.indexActions) {
|
|
498
|
+
const stmt = buildIndexSQL(tableName, act.indexName, act.fieldName, act.action);
|
|
499
|
+
if (IS_PLAN) {
|
|
500
|
+
Logger.info(`[计划] ${stmt}`);
|
|
501
|
+
} else {
|
|
502
|
+
try {
|
|
503
|
+
await sql.unsafe(stmt);
|
|
504
|
+
if (act.action === 'create') {
|
|
505
|
+
Logger.info(`[索引变化] 新建索引 ${tableName}.${act.indexName} (${act.fieldName})`);
|
|
506
|
+
} else {
|
|
507
|
+
Logger.info(`[索引变化] 删除索引 ${tableName}.${act.indexName} (${act.fieldName})`);
|
|
508
|
+
}
|
|
509
|
+
} catch (error) {
|
|
510
|
+
Logger.error(`${act.action === 'create' ? '创建' : '删除'}索引失败: ${error.message}`);
|
|
511
|
+
throw error;
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
// PG 列注释
|
|
517
|
+
if (IS_PG && plan.commentActions && plan.commentActions.length > 0) {
|
|
518
|
+
for (const stmt of plan.commentActions) {
|
|
519
|
+
if (IS_PLAN) Logger.info(`[计划] ${stmt}`);
|
|
520
|
+
else await sql.unsafe(stmt);
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
// 计数
|
|
525
|
+
globalCount.modifiedTables++;
|
|
526
|
+
};
|
|
527
|
+
|
|
301
528
|
// 同步表结构
|
|
302
|
-
const
|
|
303
|
-
const existingColumns = await getTableColumns(
|
|
304
|
-
const existingIndexes = await getTableIndexes(
|
|
305
|
-
const systemFields = ['id', 'created_at', 'updated_at', 'deleted_at', 'state'];
|
|
529
|
+
const modifyTable = async (tableName, fields) => {
|
|
530
|
+
const existingColumns = await getTableColumns(tableName);
|
|
531
|
+
const existingIndexes = await getTableIndexes(tableName);
|
|
306
532
|
let changed = false;
|
|
307
533
|
|
|
308
534
|
const addClauses = [];
|
|
309
535
|
const modifyClauses = [];
|
|
310
536
|
const defaultClauses = [];
|
|
311
537
|
const indexActions = [];
|
|
312
|
-
|
|
313
|
-
const
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
const label = { length: '长度', datatype: '类型', comment: '注释', default: '默认值' }[c.type] || c.type;
|
|
332
|
-
Logger.info(`[字段变更] ${tableName}.${fieldName} ${label}: ${c.current ?? 'NULL'} -> ${c.new ?? 'NULL'}`);
|
|
333
|
-
if (c.type in changeStats) changeStats[c.type]++;
|
|
538
|
+
|
|
539
|
+
for (const [fieldKey, fieldRule] of Object.entries(fields)) {
|
|
540
|
+
if (existingColumns[fieldKey]) {
|
|
541
|
+
const comparison = compareFieldDefinition(existingColumns[fieldKey], fieldRule, fieldKey);
|
|
542
|
+
if (comparison.length > 0) {
|
|
543
|
+
for (const c of comparison) {
|
|
544
|
+
const label =
|
|
545
|
+
{
|
|
546
|
+
length: '长度',
|
|
547
|
+
datatype: '类型',
|
|
548
|
+
comment: '注释',
|
|
549
|
+
default: '默认值'
|
|
550
|
+
}[c.type] || c.type;
|
|
551
|
+
Logger.info(`[字段变更] ${tableName}.${fieldKey} ${label}: ${c.current ?? 'NULL'} -> ${c.new ?? 'NULL'}`);
|
|
552
|
+
// 全量计数:全局累加
|
|
553
|
+
if (c.type === 'datatype') globalCount.typeChanges++;
|
|
554
|
+
else if (c.type === 'length') globalCount.maxChanges++;
|
|
555
|
+
else if (c.type === 'default') globalCount.defaultChanges++;
|
|
556
|
+
else if (c.type === 'comment') globalCount.nameChanges++;
|
|
334
557
|
}
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
const newLen = parseInt(fMax);
|
|
340
|
-
if (existingColumns[fieldName].length > newLen && FLAGS.DISALLOW_SHRINK) {
|
|
341
|
-
Logger.warn(`[跳过危险变更] ${tableName}.${fieldName} 长度收缩 ${existingColumns[fieldName].length} -> ${newLen} 已被跳过(设置 SYNC_DISALLOW_SHRINK=0 可放开)`);
|
|
342
|
-
// 如果仅有 shrink 一个变化,仍可能还有默认/注释变化要处理
|
|
558
|
+
const [fieldName, fieldType, fieldMin, fieldMax, fieldDefault, fieldIndex, fieldRegx] = parseRule(fieldRule);
|
|
559
|
+
if ((fieldType === 'string' || fieldType === 'array') && existingColumns[fieldKey].length) {
|
|
560
|
+
if (existingColumns[fieldKey].length > fieldMax) {
|
|
561
|
+
Logger.warn(`[跳过危险变更] ${tableName}.${fieldKey} 长度收缩 ${existingColumns[fieldKey].length} -> ${fieldMax} 已被跳过(设置 SYNC_DISALLOW_SHRINK=0 可放开)`);
|
|
343
562
|
}
|
|
344
563
|
}
|
|
345
|
-
const
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
564
|
+
const hasTypeChange = comparison.some((c) => c.type === 'datatype');
|
|
565
|
+
const hasLengthChange = comparison.some((c) => c.type === 'length');
|
|
566
|
+
const onlyDefaultChanged = comparison.every((c) => c.type === 'default');
|
|
567
|
+
const defaultChanged = comparison.some((c) => c.type === 'default');
|
|
568
|
+
|
|
569
|
+
// 严格限制:除 string/array 互转外,禁止任何字段类型变更;一旦发现,立即终止同步
|
|
570
|
+
// 说明:string 与 array 在各方言下映射同为 VARCHAR/character varying/TEXT,compare 不会将其视为类型变更
|
|
571
|
+
if (hasTypeChange) {
|
|
572
|
+
const currentSqlType = String(existingColumns[fieldKey].type || '').toLowerCase();
|
|
573
|
+
const newSqlType = String(typeMapping[fieldType] || '').toLowerCase();
|
|
574
|
+
// 明确抛错,阻止后续任何 DDL 应用
|
|
575
|
+
throw new Error(`禁止字段类型变更: ${tableName}.${fieldKey} ${currentSqlType} -> ${newSqlType}。仅允许 string<->array 互相切换`);
|
|
354
576
|
}
|
|
355
577
|
|
|
356
|
-
//
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
defaultClauses.push(`ALTER COLUMN \`${fieldName}\` SET DEFAULT ${v}`);
|
|
366
|
-
}
|
|
367
|
-
} else {
|
|
368
|
-
// 判断是否需要跳过 MODIFY:包含收缩或类型变更时跳过
|
|
369
|
-
let skipModify = false;
|
|
370
|
-
const hasLengthChange = comparison.changes.some((c) => c.type === 'length');
|
|
371
|
-
if (hasLengthChange && (fType === 'string' || fType === 'array') && existingColumns[fieldName].length && fMax !== 'null') {
|
|
372
|
-
const newLen = parseInt(fMax);
|
|
373
|
-
if (existingColumns[fieldName].length > newLen && FLAGS.DISALLOW_SHRINK) {
|
|
374
|
-
skipModify = true;
|
|
578
|
+
// 默认值变化处理:
|
|
579
|
+
if (defaultChanged) {
|
|
580
|
+
const v = fieldType === 'number' ? fieldDefault : `'${fieldDefault}'`;
|
|
581
|
+
if (IS_PG) {
|
|
582
|
+
defaultClauses.push(`ALTER COLUMN "${fieldKey}" SET DEFAULT ${v}`);
|
|
583
|
+
} else if (IS_MYSQL && onlyDefaultChanged) {
|
|
584
|
+
// MySQL 的 TEXT/BLOB 不允许 DEFAULT,跳过 text 类型
|
|
585
|
+
if (fieldType !== 'text') {
|
|
586
|
+
defaultClauses.push(`ALTER COLUMN \`${fieldKey}\` SET DEFAULT ${v}`);
|
|
375
587
|
}
|
|
376
588
|
}
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
// 若不仅仅是默认值变化,继续生成修改子句
|
|
592
|
+
if (!onlyDefaultChanged) {
|
|
593
|
+
let skipModify = false;
|
|
594
|
+
if (hasLengthChange && (fieldType === 'string' || fieldType === 'array') && existingColumns[fieldKey].length) {
|
|
595
|
+
const oldLen = existingColumns[fieldKey].length;
|
|
596
|
+
const isShrink = oldLen > fieldMax;
|
|
597
|
+
if (isShrink) skipModify = true;
|
|
380
598
|
}
|
|
381
|
-
if (
|
|
382
|
-
|
|
383
|
-
|
|
599
|
+
if (hasTypeChange) {
|
|
600
|
+
if (IS_PG && isPgCompatibleTypeChange(existingColumns[fieldKey].type, typeMapping[fieldType].toLowerCase())) {
|
|
601
|
+
Logger.info(`[PG兼容类型变更] ${tableName}.${fieldKey} ${existingColumns[fieldKey].type} -> ${typeMapping[fieldType].toLowerCase()} 允许执行`);
|
|
602
|
+
}
|
|
384
603
|
}
|
|
604
|
+
if (!skipModify) modifyClauses.push(generateDDLClause(fieldKey, fieldRule, false));
|
|
385
605
|
}
|
|
386
606
|
changed = true;
|
|
387
607
|
}
|
|
388
608
|
} else {
|
|
389
|
-
|
|
390
|
-
const
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
Logger.info(`[新增字段] ${tableName}.${fieldName} 类型:${fType}${lenPart} 默认:${expectedDefault ?? 'NULL'}`);
|
|
394
|
-
addClauses.push(generateDDLClause(fieldName, rule, true));
|
|
609
|
+
const [fieldName, fieldType, fieldMin, fieldMax, fieldDefault, fieldIndex, fieldRegx] = parseRule(fieldRule);
|
|
610
|
+
const lenPart = fieldType === 'string' || fieldType === 'array' ? ` 长度:${parseInt(fieldMax)}` : '';
|
|
611
|
+
Logger.info(`[新增字段] ${tableName}.${fieldKey} 类型:${fieldType}${lenPart} 默认:${fieldDefault ?? 'NULL'}`);
|
|
612
|
+
addClauses.push(generateDDLClause(fieldKey, fieldRule, true));
|
|
395
613
|
changed = true;
|
|
396
|
-
|
|
614
|
+
globalCount.addFields++;
|
|
397
615
|
}
|
|
398
616
|
}
|
|
399
617
|
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
618
|
+
for (const sysField of ['created_at', 'updated_at', 'state']) {
|
|
619
|
+
const idxName = `idx_${sysField}`;
|
|
620
|
+
if (!existingIndexes[idxName]) {
|
|
621
|
+
indexActions.push({ action: 'create', indexName: idxName, fieldName: sysField });
|
|
622
|
+
changed = true;
|
|
623
|
+
globalCount.indexCreate++;
|
|
624
|
+
}
|
|
625
|
+
}
|
|
626
|
+
for (const [fieldKey, fieldRule] of Object.entries(fields)) {
|
|
627
|
+
const [fieldName, fieldType, fieldMin, fieldMax, fieldDefault, fieldIndex, fieldRegx] = parseRule(fieldRule);
|
|
628
|
+
const indexName = `idx_${fieldKey}`;
|
|
629
|
+
if (fieldIndex === 1 && !existingIndexes[indexName]) {
|
|
630
|
+
indexActions.push({ action: 'create', indexName, fieldName: fieldKey });
|
|
408
631
|
changed = true;
|
|
409
|
-
|
|
410
|
-
} else if (
|
|
411
|
-
indexActions.push({ action: 'drop', indexName, fieldName });
|
|
632
|
+
globalCount.indexCreate++;
|
|
633
|
+
} else if (!(fieldIndex === 1) && existingIndexes[indexName] && existingIndexes[indexName].length === 1) {
|
|
634
|
+
indexActions.push({ action: 'drop', indexName, fieldName: fieldKey });
|
|
412
635
|
changed = true;
|
|
413
|
-
|
|
636
|
+
globalCount.indexDrop++;
|
|
637
|
+
}
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
const commentActions = [];
|
|
641
|
+
if (IS_PG) {
|
|
642
|
+
for (const [fieldKey, fieldRule] of Object.entries(fields)) {
|
|
643
|
+
if (existingColumns[fieldKey]) {
|
|
644
|
+
const [fieldName, fieldType, fieldMin, fieldMax, fieldDefault, fieldIndex, fieldRegx] = parseRule(fieldRule);
|
|
645
|
+
const curr = existingColumns[fieldKey].comment || '';
|
|
646
|
+
const want = fieldName && fieldName !== 'null' ? String(fieldName) : '';
|
|
647
|
+
if (want !== curr) {
|
|
648
|
+
commentActions.push(`COMMENT ON COLUMN "${tableName}"."${fieldKey}" IS ${want ? `'${want}'` : 'NULL'}`);
|
|
649
|
+
changed = true;
|
|
650
|
+
}
|
|
651
|
+
}
|
|
414
652
|
}
|
|
415
653
|
}
|
|
416
|
-
|
|
654
|
+
// 仅当存在实际动作时才认为有变更(避免仅日志的收缩跳过被计为修改)
|
|
655
|
+
changed = addClauses.length > 0 || modifyClauses.length > 0 || defaultClauses.length > 0 || indexActions.length > 0 || commentActions.length > 0;
|
|
656
|
+
const plan = { changed, addClauses, modifyClauses, defaultClauses, indexActions, commentActions };
|
|
657
|
+
// 将计划应用(包含 --plan 情况下仅输出)
|
|
658
|
+
if (plan.changed) {
|
|
659
|
+
await applyTablePlan(tableName, fields, plan);
|
|
660
|
+
}
|
|
661
|
+
return plan;
|
|
417
662
|
};
|
|
418
663
|
|
|
419
664
|
// 主同步函数
|
|
420
665
|
const SyncDb = async () => {
|
|
421
|
-
let client = null;
|
|
422
|
-
|
|
423
666
|
try {
|
|
424
667
|
Logger.info('开始数据库表结构同步...');
|
|
668
|
+
// 重置全局统计,避免多次调用累加
|
|
669
|
+
for (const k of Object.keys(globalCount)) {
|
|
670
|
+
if (typeof globalCount[k] === 'number') globalCount[k] = 0;
|
|
671
|
+
}
|
|
425
672
|
|
|
426
673
|
// 验证表定义文件
|
|
427
|
-
|
|
428
|
-
if (!tableValidationResult) {
|
|
674
|
+
if (!(await checkTable())) {
|
|
429
675
|
throw new Error('表定义验证失败');
|
|
430
676
|
}
|
|
431
677
|
|
|
432
|
-
//
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
if (version.toLowerCase().includes('mariadb')) {
|
|
438
|
-
throw new Error('此脚本仅支持 MySQL 8.0+,不支持 MariaDB');
|
|
439
|
-
}
|
|
440
|
-
|
|
441
|
-
const majorVersion = parseInt(version.split('.')[0]);
|
|
442
|
-
if (majorVersion < 8) {
|
|
443
|
-
throw new Error(`此脚本仅支持 MySQL 8.0+,当前版本: ${version}`);
|
|
444
|
-
}
|
|
445
|
-
|
|
446
|
-
Logger.info(`MySQL 版本检查通过: ${version}`);
|
|
678
|
+
// 建立数据库连接并检查版本(按方言)
|
|
679
|
+
// 在顶层也保留 sql 引用,便于未来需要跨函数访问
|
|
680
|
+
sql = await createSqlClient({ max: 1 });
|
|
681
|
+
await ensureDbVersion();
|
|
447
682
|
|
|
448
683
|
// 扫描并处理表文件
|
|
449
684
|
const tablesGlob = new Bun.Glob('*.json');
|
|
450
685
|
const directories = [__dirtables, getProjectDir('tables')];
|
|
451
|
-
|
|
452
|
-
let createdTables = 0;
|
|
453
|
-
let modifiedTables = 0;
|
|
454
|
-
// 全局统计
|
|
455
|
-
const overall = {
|
|
456
|
-
addFields: 0,
|
|
457
|
-
typeChanges: 0,
|
|
458
|
-
maxChanges: 0, // 映射为长度变化
|
|
459
|
-
minChanges: 0, // 最小值不参与 DDL,比对保留为0
|
|
460
|
-
defaultChanges: 0,
|
|
461
|
-
nameChanges: 0, // 字段显示名(注释)变更
|
|
462
|
-
indexCreate: 0,
|
|
463
|
-
indexDrop: 0
|
|
464
|
-
};
|
|
686
|
+
// 统计使用全局 globalCount
|
|
465
687
|
|
|
466
688
|
for (const dir of directories) {
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
const result = await exec(client, 'SELECT COUNT(*) as count FROM information_schema.TABLES WHERE TABLE_SCHEMA = ? AND TABLE_NAME = ?', [Env.MYSQL_DB || 'test', tableName]);
|
|
472
|
-
const exists = result[0].count > 0;
|
|
473
|
-
|
|
474
|
-
if (exists) {
|
|
475
|
-
const plan = await syncTable(client, tableName, tableDefinition);
|
|
476
|
-
if (plan.changed) {
|
|
477
|
-
// 汇总统计
|
|
478
|
-
if (plan.metrics) {
|
|
479
|
-
overall.addFields += plan.metrics.addFields;
|
|
480
|
-
overall.typeChanges += plan.metrics.datatype;
|
|
481
|
-
overall.maxChanges += plan.metrics.length;
|
|
482
|
-
overall.defaultChanges += plan.metrics.default;
|
|
483
|
-
overall.indexCreate += plan.metrics.indexCreate;
|
|
484
|
-
overall.indexDrop += plan.metrics.indexDrop;
|
|
485
|
-
overall.nameChanges += plan.metrics.comment;
|
|
486
|
-
}
|
|
487
|
-
// 合并执行 ALTER TABLE 子句
|
|
488
|
-
if (FLAGS.MERGE_ALTER) {
|
|
489
|
-
const clauses = [...plan.addClauses, ...plan.modifyClauses];
|
|
490
|
-
if (clauses.length > 0) {
|
|
491
|
-
const sql = `ALTER TABLE \`${tableName}\` ${clauses.join(', ')}, ALGORITHM=INSTANT, LOCK=NONE`;
|
|
492
|
-
if (FLAGS.DRY_RUN) {
|
|
493
|
-
Logger.info(`[计划] ${sql}`);
|
|
494
|
-
} else {
|
|
495
|
-
await executeDDLSafely(client, sql);
|
|
496
|
-
}
|
|
497
|
-
}
|
|
498
|
-
} else {
|
|
499
|
-
// 分别执行
|
|
500
|
-
for (const c of plan.addClauses) {
|
|
501
|
-
const sql = `ALTER TABLE \`${tableName}\` ${c}, ALGORITHM=INSTANT, LOCK=NONE`;
|
|
502
|
-
if (FLAGS.DRY_RUN) Logger.info(`[计划] ${sql}`);
|
|
503
|
-
else await executeDDLSafely(client, sql);
|
|
504
|
-
}
|
|
505
|
-
for (const c of plan.modifyClauses) {
|
|
506
|
-
const sql = `ALTER TABLE \`${tableName}\` ${c}, ALGORITHM=INSTANT, LOCK=NONE`;
|
|
507
|
-
if (FLAGS.DRY_RUN) Logger.info(`[计划] ${sql}`);
|
|
508
|
-
else await executeDDLSafely(client, sql);
|
|
509
|
-
}
|
|
510
|
-
}
|
|
511
|
-
|
|
512
|
-
// 默认值专用 ALTER
|
|
513
|
-
if (plan.defaultClauses.length > 0) {
|
|
514
|
-
const sql = `ALTER TABLE \`${tableName}\` ${plan.defaultClauses.join(', ')}, ALGORITHM=INSTANT, LOCK=NONE`;
|
|
515
|
-
if (FLAGS.DRY_RUN) Logger.info(`[计划] ${sql}`);
|
|
516
|
-
else await executeDDLSafely(client, sql);
|
|
517
|
-
}
|
|
518
|
-
|
|
519
|
-
// 索引操作
|
|
520
|
-
for (const act of plan.indexActions) {
|
|
521
|
-
const sql = buildIndexSQL(tableName, act.indexName, act.fieldName, act.action);
|
|
522
|
-
if (FLAGS.DRY_RUN) {
|
|
523
|
-
Logger.info(`[计划] ${sql}`);
|
|
524
|
-
} else {
|
|
525
|
-
try {
|
|
526
|
-
await exec(client, sql);
|
|
527
|
-
if (act.action === 'create') {
|
|
528
|
-
Logger.info(`[索引变化] 新建索引 ${tableName}.${act.indexName} (${act.fieldName})`);
|
|
529
|
-
} else {
|
|
530
|
-
Logger.info(`[索引变化] 删除索引 ${tableName}.${act.indexName} (${act.fieldName})`);
|
|
531
|
-
}
|
|
532
|
-
} catch (error) {
|
|
533
|
-
Logger.error(`${act.action === 'create' ? '创建' : '删除'}索引失败: ${error.message}`);
|
|
534
|
-
throw error;
|
|
535
|
-
}
|
|
536
|
-
}
|
|
537
|
-
}
|
|
538
|
-
|
|
539
|
-
modifiedTables++;
|
|
540
|
-
}
|
|
541
|
-
} else {
|
|
542
|
-
await createTable(client, tableName, tableDefinition);
|
|
543
|
-
createdTables++;
|
|
544
|
-
// 新建表已算作变更
|
|
545
|
-
modifiedTables += 0;
|
|
546
|
-
// 创建表统计:按需求仅汇总创建表数量
|
|
547
|
-
}
|
|
689
|
+
for await (const file of tablesGlob.scan({ cwd: dir, absolute: true, onlyFiles: true })) {
|
|
690
|
+
const tableName = toSnakeTableName(path.basename(file, '.json'));
|
|
691
|
+
const tableDefinition = await Bun.file(file).json();
|
|
692
|
+
const existsTable = await tableExists(tableName);
|
|
548
693
|
|
|
549
|
-
|
|
694
|
+
if (existsTable) {
|
|
695
|
+
await modifyTable(tableName, tableDefinition);
|
|
696
|
+
} else {
|
|
697
|
+
await createTable(tableName, tableDefinition);
|
|
698
|
+
globalCount.createdTables++;
|
|
550
699
|
}
|
|
551
|
-
|
|
552
|
-
Logger.warn(`扫描目录 ${dir} 出错: ${error.message}`);
|
|
700
|
+
globalCount.processedTables++;
|
|
553
701
|
}
|
|
554
702
|
}
|
|
555
703
|
|
|
556
704
|
// 显示统计信息(扩展维度)
|
|
557
|
-
Logger.info(`统计 -
|
|
558
|
-
Logger.info(`统计 -
|
|
559
|
-
Logger.info(`统计 -
|
|
560
|
-
Logger.info(`统计 -
|
|
561
|
-
Logger.info(`统计 -
|
|
562
|
-
Logger.info(`统计 -
|
|
563
|
-
Logger.info(`统计 -
|
|
705
|
+
Logger.info(`统计 - 处理表总数: ${globalCount.processedTables}`);
|
|
706
|
+
Logger.info(`统计 - 创建表: ${globalCount.createdTables}`);
|
|
707
|
+
Logger.info(`统计 - 修改表: ${globalCount.modifiedTables}`);
|
|
708
|
+
Logger.info(`统计 - 字段新增: ${globalCount.addFields}`);
|
|
709
|
+
Logger.info(`统计 - 字段名称变更: ${globalCount.nameChanges}`);
|
|
710
|
+
Logger.info(`统计 - 字段类型变更: ${globalCount.typeChanges}`);
|
|
711
|
+
Logger.info(`统计 - 字段最小值变更: ${globalCount.minChanges}`);
|
|
712
|
+
Logger.info(`统计 - 字段最大值变更: ${globalCount.maxChanges}`);
|
|
713
|
+
Logger.info(`统计 - 字段默认值变更: ${globalCount.defaultChanges}`);
|
|
564
714
|
// 索引新增/删除分别打印
|
|
565
|
-
Logger.info(`统计 - 索引新增: ${
|
|
566
|
-
Logger.info(`统计 - 索引删除: ${
|
|
715
|
+
Logger.info(`统计 - 索引新增: ${globalCount.indexCreate}`);
|
|
716
|
+
Logger.info(`统计 - 索引删除: ${globalCount.indexDrop}`);
|
|
567
717
|
|
|
568
|
-
if (
|
|
718
|
+
if (globalCount.processedTables === 0) {
|
|
569
719
|
Logger.warn('没有找到任何表定义文件');
|
|
570
720
|
}
|
|
721
|
+
|
|
722
|
+
// 保持单一职责:此处不再触发开发管理员同步
|
|
571
723
|
} catch (error) {
|
|
572
724
|
Logger.error(`数据库同步失败: ${error.message}`);
|
|
573
725
|
Logger.error(`错误详情: ${error.stack}`);
|
|
@@ -579,9 +731,9 @@ const SyncDb = async () => {
|
|
|
579
731
|
}
|
|
580
732
|
process.exit(1);
|
|
581
733
|
} finally {
|
|
582
|
-
if (
|
|
734
|
+
if (sql) {
|
|
583
735
|
try {
|
|
584
|
-
await
|
|
736
|
+
await sql.close();
|
|
585
737
|
} catch (error) {
|
|
586
738
|
Logger.warn('关闭数据库连接时出错:', error.message);
|
|
587
739
|
}
|
|
@@ -589,8 +741,8 @@ const SyncDb = async () => {
|
|
|
589
741
|
}
|
|
590
742
|
};
|
|
591
743
|
|
|
592
|
-
//
|
|
593
|
-
if (import.meta.
|
|
744
|
+
// 如果直接运行此脚本(Bun 支持 import.meta.main)
|
|
745
|
+
if (import.meta.main) {
|
|
594
746
|
SyncDb().catch((error) => {
|
|
595
747
|
console.error('❌ 数据库同步失败:', error);
|
|
596
748
|
process.exit(1);
|