befly 2.3.2 → 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 +71 -86
- package/config/env.js +10 -8
- package/package.json +7 -14
- package/plugins/db.js +2 -2
- package/scripts/syncDb.js +585 -436
- package/scripts/syncDev.js +96 -0
- package/tables/common.json +10 -10
- package/tables/tool.json +3 -3
- package/utils/index.js +80 -133
- package/utils/sqlManager.js +1 -1
- package/utils/validate.js +7 -9
package/scripts/syncDb.js
CHANGED
|
@@ -1,297 +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
|
-
};
|
|
18
|
-
|
|
19
|
-
// 表名转换函数已移动至 utils/index.js 的 toSnakeTableName
|
|
20
|
-
|
|
21
|
-
// 环境开关读取(支持未在 Env 显式声明的变量,默认值兜底)
|
|
22
|
-
const getFlag = (val, def = 0) => {
|
|
23
|
-
if (val === undefined || val === null || val === '') return !!def;
|
|
24
|
-
const n = Number(val);
|
|
25
|
-
if (!Number.isNaN(n)) return n !== 0;
|
|
26
|
-
const s = String(val).toLowerCase();
|
|
27
|
-
return s === 'true' || s === 'on' || s === 'yes';
|
|
28
|
-
};
|
|
13
|
+
// 顶部管理数据库客户端(按需求使用 Bun SQL 模板,不使用 exec 辅助)
|
|
14
|
+
let sql = null;
|
|
29
15
|
|
|
30
|
-
//
|
|
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'; // 命令行参数
|
|
31
21
|
const ARGV = Array.isArray(process.argv) ? process.argv : [];
|
|
32
|
-
const
|
|
33
|
-
DRY_RUN: ARGV.includes('--dry-run')
|
|
34
|
-
};
|
|
22
|
+
const IS_PLAN = ARGV.includes('--plan');
|
|
35
23
|
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
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'
|
|
43
30
|
};
|
|
44
31
|
|
|
45
|
-
//
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
default:
|
|
61
|
-
return null;
|
|
62
|
-
}
|
|
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
|
|
63
47
|
};
|
|
64
48
|
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
const
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
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
|
+
};
|
|
73
58
|
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
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;
|
|
78
71
|
}
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
columnDef += ` DEFAULT ${expectedDefault}`;
|
|
88
|
-
} else {
|
|
89
|
-
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}`);
|
|
90
80
|
}
|
|
81
|
+
return;
|
|
91
82
|
}
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
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;
|
|
97
96
|
}
|
|
98
|
-
|
|
99
|
-
return columnDef;
|
|
100
97
|
};
|
|
101
98
|
|
|
102
|
-
//
|
|
103
|
-
const
|
|
104
|
-
if (
|
|
105
|
-
|
|
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;
|
|
109
|
+
}
|
|
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;
|
|
106
113
|
}
|
|
107
|
-
return
|
|
114
|
+
return false;
|
|
108
115
|
};
|
|
109
116
|
|
|
110
|
-
//
|
|
111
|
-
const getTableColumns = async (
|
|
112
|
-
const result = await exec(
|
|
113
|
-
client,
|
|
114
|
-
`SELECT COLUMN_NAME, DATA_TYPE, CHARACTER_MAXIMUM_LENGTH, IS_NULLABLE, COLUMN_DEFAULT, COLUMN_COMMENT, COLUMN_TYPE
|
|
115
|
-
FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = ? AND TABLE_NAME = ? ORDER BY ORDINAL_POSITION`,
|
|
116
|
-
[Env.MYSQL_DB || 'test', tableName]
|
|
117
|
-
);
|
|
118
|
-
|
|
117
|
+
// 获取表的现有列信息(按方言)
|
|
118
|
+
const getTableColumns = async (tableName) => {
|
|
119
119
|
const columns = {};
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
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
|
+
}
|
|
130
180
|
return columns;
|
|
131
181
|
};
|
|
132
182
|
|
|
133
|
-
//
|
|
134
|
-
const getTableIndexes = async (
|
|
135
|
-
const result = await exec(
|
|
136
|
-
client,
|
|
137
|
-
`SELECT INDEX_NAME, COLUMN_NAME FROM information_schema.STATISTICS
|
|
138
|
-
WHERE TABLE_SCHEMA = ? AND TABLE_NAME = ? AND INDEX_NAME != 'PRIMARY' ORDER BY INDEX_NAME`,
|
|
139
|
-
[Env.MYSQL_DB || 'test', tableName]
|
|
140
|
-
);
|
|
141
|
-
|
|
183
|
+
// 获取表的现有索引信息(单列索引)
|
|
184
|
+
const getTableIndexes = async (tableName) => {
|
|
142
185
|
const indexes = {};
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
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
|
+
}
|
|
147
216
|
return indexes;
|
|
148
217
|
};
|
|
149
218
|
|
|
150
|
-
// 构建索引操作 SQL
|
|
219
|
+
// 构建索引操作 SQL(统一使用在线策略)
|
|
151
220
|
const buildIndexSQL = (tableName, indexName, fieldName, action) => {
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
parts.push(`ADD INDEX \`${indexName}\` (\`${fieldName}\`)`);
|
|
155
|
-
|
|
156
|
-
parts.push(`DROP INDEX \`${indexName}\``);
|
|
157
|
-
}
|
|
158
|
-
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
|
+
// 始终使用在线算法
|
|
159
225
|
parts.push('ALGORITHM=INPLACE');
|
|
160
226
|
parts.push('LOCK=NONE');
|
|
227
|
+
return `ALTER TABLE \`${tableName}\` ${parts.join(', ')}`;
|
|
161
228
|
}
|
|
162
|
-
|
|
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}"`;
|
|
235
|
+
}
|
|
236
|
+
// SQLite
|
|
237
|
+
if (action === 'create') return `CREATE INDEX IF NOT EXISTS "${indexName}" ON "${tableName}"("${fieldName}")`;
|
|
238
|
+
return `DROP INDEX IF EXISTS "${indexName}"`;
|
|
163
239
|
};
|
|
164
240
|
|
|
165
|
-
//
|
|
166
|
-
const createTable = async (
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
'`
|
|
173
|
-
'`
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
//
|
|
178
|
-
'INDEX `idx_created_at` (`created_at`)',
|
|
179
|
-
'INDEX `idx_updated_at` (`updated_at`)',
|
|
180
|
-
'INDEX `idx_state` (`state`)'
|
|
181
|
-
];
|
|
182
|
-
|
|
183
|
-
// 添加自定义字段和索引
|
|
184
|
-
for (const [fieldName, rule] of Object.entries(fields)) {
|
|
185
|
-
columns.push(getColumnDefinition(fieldName, rule));
|
|
186
|
-
|
|
187
|
-
// 使用第6个属性判断是否设置索引
|
|
188
|
-
const ruleParts = parseFieldRule(rule);
|
|
189
|
-
const fieldHasIndex = ruleParts[5]; // 第6个属性
|
|
190
|
-
if (fieldHasIndex === '1') {
|
|
191
|
-
indexes.push(`INDEX \`idx_${fieldName}\` (\`${fieldName}\`)`);
|
|
192
|
-
}
|
|
193
|
-
}
|
|
194
|
-
|
|
195
|
-
const createTableSQL = `
|
|
196
|
-
CREATE TABLE \`${tableName}\` (
|
|
197
|
-
${columns.join(',\n ')},
|
|
198
|
-
${indexes.join(',\n ')}
|
|
199
|
-
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_as_cs
|
|
200
|
-
`;
|
|
201
|
-
|
|
202
|
-
if (FLAGS.DRY_RUN) {
|
|
203
|
-
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 "状态字段"');
|
|
204
253
|
} else {
|
|
205
|
-
|
|
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);
|
|
206
286
|
Logger.info(`[新建表] ${tableName}`);
|
|
207
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
|
+
}
|
|
208
337
|
};
|
|
209
338
|
|
|
210
339
|
// 比较字段定义变化
|
|
211
|
-
const compareFieldDefinition = (existingColumn, newRule,
|
|
212
|
-
const
|
|
213
|
-
const [fieldDisplayName, fieldType, fieldMin, fieldMaxLength, fieldDefaultValue] = ruleParts;
|
|
340
|
+
const compareFieldDefinition = (existingColumn, newRule, colName) => {
|
|
341
|
+
const [fieldName, fieldType, fieldMin, fieldMax, fieldDefault, fieldIndex, fieldRegx] = parseRule(newRule);
|
|
214
342
|
const changes = [];
|
|
215
343
|
|
|
216
|
-
// 检查长度变化(string和array类型)
|
|
217
|
-
if (fieldType === 'string' || fieldType === 'array') {
|
|
218
|
-
if (
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
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
|
+
});
|
|
224
352
|
}
|
|
225
353
|
}
|
|
226
354
|
|
|
227
|
-
//
|
|
228
|
-
if (
|
|
355
|
+
// 检查注释变化(MySQL/PG 支持列注释)
|
|
356
|
+
if (!IS_SQLITE) {
|
|
229
357
|
const currentComment = existingColumn.comment || '';
|
|
230
|
-
if (currentComment !==
|
|
231
|
-
changes.push({
|
|
358
|
+
if (currentComment !== fieldName) {
|
|
359
|
+
changes.push({
|
|
360
|
+
type: 'comment',
|
|
361
|
+
current: currentComment,
|
|
362
|
+
new: fieldName
|
|
363
|
+
});
|
|
232
364
|
}
|
|
233
365
|
}
|
|
234
366
|
|
|
235
|
-
//
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
if (existingColumn.type.toLowerCase() !== expectedDbType) {
|
|
244
|
-
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
|
+
});
|
|
245
374
|
}
|
|
246
375
|
|
|
247
376
|
// 检查默认值变化(按照生成规则推导期望默认值)
|
|
248
|
-
|
|
249
|
-
const currDef = normalizeDefault(existingColumn.defaultValue);
|
|
250
|
-
const newDef = normalizeDefault(expectedDefault);
|
|
251
|
-
if (currDef !== newDef) {
|
|
252
|
-
changes.push({ type: 'default', current: existingColumn.defaultValue, new: expectedDefault });
|
|
253
|
-
}
|
|
254
|
-
|
|
255
|
-
// 检查可空性变化(统一期望 NOT NULL)
|
|
256
|
-
const expectedNullable = false; // 期望 NOT NULL
|
|
257
|
-
if (existingColumn.nullable !== expectedNullable) {
|
|
258
|
-
// existingColumn.nullable 为 true 表示可空
|
|
377
|
+
if (String(existingColumn.defaultValue) !== String(fieldDefault)) {
|
|
259
378
|
changes.push({
|
|
260
|
-
type: '
|
|
261
|
-
current: existingColumn.
|
|
262
|
-
new:
|
|
379
|
+
type: 'default',
|
|
380
|
+
current: existingColumn.defaultValue,
|
|
381
|
+
new: fieldDefault
|
|
263
382
|
});
|
|
264
383
|
}
|
|
265
384
|
|
|
266
|
-
return
|
|
385
|
+
return changes;
|
|
267
386
|
};
|
|
268
387
|
|
|
269
388
|
// 生成字段 DDL 子句(不含 ALTER TABLE 前缀)
|
|
270
|
-
const generateDDLClause = (
|
|
271
|
-
const
|
|
272
|
-
const
|
|
273
|
-
|
|
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 '';
|
|
274
404
|
};
|
|
275
405
|
|
|
276
406
|
// 安全执行DDL语句
|
|
277
|
-
const executeDDLSafely = async (
|
|
407
|
+
const executeDDLSafely = async (stmt) => {
|
|
278
408
|
try {
|
|
279
|
-
await
|
|
409
|
+
await sql.unsafe(stmt);
|
|
280
410
|
return true;
|
|
281
411
|
} catch (error) {
|
|
282
|
-
//
|
|
283
|
-
if (
|
|
284
|
-
const inplaceSql =
|
|
412
|
+
// MySQL 专用降级路径
|
|
413
|
+
if (stmt.includes('ALGORITHM=INSTANT')) {
|
|
414
|
+
const inplaceSql = stmt.replace(/ALGORITHM=INSTANT/g, 'ALGORITHM=INPLACE');
|
|
285
415
|
try {
|
|
286
|
-
await
|
|
416
|
+
await sql.unsafe(inplaceSql);
|
|
287
417
|
return true;
|
|
288
418
|
} catch (inplaceError) {
|
|
289
419
|
// 最后尝试传统DDL:移除 ALGORITHM/LOCK 附加子句后执行
|
|
290
|
-
const traditionSql =
|
|
420
|
+
const traditionSql = stmt
|
|
291
421
|
.replace(/,\s*ALGORITHM=INPLACE/g, '')
|
|
292
422
|
.replace(/,\s*ALGORITHM=INSTANT/g, '')
|
|
293
423
|
.replace(/,\s*LOCK=(NONE|SHARED|EXCLUSIVE)/g, '');
|
|
294
|
-
await
|
|
424
|
+
await sql.unsafe(traditionSql);
|
|
295
425
|
return true;
|
|
296
426
|
}
|
|
297
427
|
} else {
|
|
@@ -300,277 +430,296 @@ const executeDDLSafely = async (client, sql) => {
|
|
|
300
430
|
}
|
|
301
431
|
};
|
|
302
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
|
+
|
|
303
528
|
// 同步表结构
|
|
304
|
-
const
|
|
305
|
-
const existingColumns = await getTableColumns(
|
|
306
|
-
const existingIndexes = await getTableIndexes(
|
|
307
|
-
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);
|
|
308
532
|
let changed = false;
|
|
309
533
|
|
|
310
534
|
const addClauses = [];
|
|
311
535
|
const modifyClauses = [];
|
|
312
536
|
const defaultClauses = [];
|
|
313
537
|
const indexActions = [];
|
|
314
|
-
|
|
315
|
-
const
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
const label = { length: '长度', datatype: '类型', comment: '注释', default: '默认值' }[c.type] || c.type;
|
|
334
|
-
Logger.info(`[字段变更] ${tableName}.${fieldName} ${label}: ${c.current ?? 'NULL'} -> ${c.new ?? 'NULL'}`);
|
|
335
|
-
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++;
|
|
336
557
|
}
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
const newLen = parseInt(fMax);
|
|
342
|
-
if (existingColumns[fieldName].length > newLen && FLAGS.DISALLOW_SHRINK) {
|
|
343
|
-
Logger.warn(`[跳过危险变更] ${tableName}.${fieldName} 长度收缩 ${existingColumns[fieldName].length} -> ${newLen} 已被跳过(设置 SYNC_DISALLOW_SHRINK=0 可放开)`);
|
|
344
|
-
// 如果仅有 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 可放开)`);
|
|
345
562
|
}
|
|
346
563
|
}
|
|
347
|
-
const
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
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 互相切换`);
|
|
356
576
|
}
|
|
357
577
|
|
|
358
|
-
//
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
defaultClauses.push(`ALTER COLUMN \`${fieldName}\` SET DEFAULT ${v}`);
|
|
368
|
-
}
|
|
369
|
-
} else {
|
|
370
|
-
// 判断是否需要跳过 MODIFY:包含收缩或类型变更时跳过
|
|
371
|
-
let skipModify = false;
|
|
372
|
-
const hasLengthChange = comparison.changes.some((c) => c.type === 'length');
|
|
373
|
-
if (hasLengthChange && (fType === 'string' || fType === 'array') && existingColumns[fieldName].length && fMax !== 'null') {
|
|
374
|
-
const newLen = parseInt(fMax);
|
|
375
|
-
if (existingColumns[fieldName].length > newLen && FLAGS.DISALLOW_SHRINK) {
|
|
376
|
-
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}`);
|
|
377
587
|
}
|
|
378
588
|
}
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
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;
|
|
382
598
|
}
|
|
383
|
-
if (
|
|
384
|
-
|
|
385
|
-
|
|
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
|
+
}
|
|
386
603
|
}
|
|
604
|
+
if (!skipModify) modifyClauses.push(generateDDLClause(fieldKey, fieldRule, false));
|
|
387
605
|
}
|
|
388
606
|
changed = true;
|
|
389
607
|
}
|
|
390
608
|
} else {
|
|
391
|
-
|
|
392
|
-
const
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
Logger.info(`[新增字段] ${tableName}.${fieldName} 类型:${fType}${lenPart} 默认:${expectedDefault ?? 'NULL'}`);
|
|
396
|
-
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));
|
|
397
613
|
changed = true;
|
|
398
|
-
|
|
614
|
+
globalCount.addFields++;
|
|
399
615
|
}
|
|
400
616
|
}
|
|
401
617
|
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
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 });
|
|
410
631
|
changed = true;
|
|
411
|
-
|
|
412
|
-
} else if (
|
|
413
|
-
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 });
|
|
414
635
|
changed = true;
|
|
415
|
-
|
|
636
|
+
globalCount.indexDrop++;
|
|
416
637
|
}
|
|
417
638
|
}
|
|
418
|
-
|
|
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
|
+
}
|
|
652
|
+
}
|
|
653
|
+
}
|
|
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;
|
|
419
662
|
};
|
|
420
663
|
|
|
421
664
|
// 主同步函数
|
|
422
665
|
const SyncDb = async () => {
|
|
423
|
-
let client = null;
|
|
424
|
-
|
|
425
666
|
try {
|
|
426
667
|
Logger.info('开始数据库表结构同步...');
|
|
668
|
+
// 重置全局统计,避免多次调用累加
|
|
669
|
+
for (const k of Object.keys(globalCount)) {
|
|
670
|
+
if (typeof globalCount[k] === 'number') globalCount[k] = 0;
|
|
671
|
+
}
|
|
427
672
|
|
|
428
673
|
// 验证表定义文件
|
|
429
|
-
|
|
430
|
-
if (!tableValidationResult) {
|
|
674
|
+
if (!(await checkTable())) {
|
|
431
675
|
throw new Error('表定义验证失败');
|
|
432
676
|
}
|
|
433
677
|
|
|
434
|
-
//
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
if (version.toLowerCase().includes('mariadb')) {
|
|
440
|
-
throw new Error('此脚本仅支持 MySQL 8.0+,不支持 MariaDB');
|
|
441
|
-
}
|
|
442
|
-
|
|
443
|
-
const majorVersion = parseInt(version.split('.')[0]);
|
|
444
|
-
if (majorVersion < 8) {
|
|
445
|
-
throw new Error(`此脚本仅支持 MySQL 8.0+,当前版本: ${version}`);
|
|
446
|
-
}
|
|
447
|
-
|
|
448
|
-
Logger.info(`MySQL 版本检查通过: ${version}`);
|
|
678
|
+
// 建立数据库连接并检查版本(按方言)
|
|
679
|
+
// 在顶层也保留 sql 引用,便于未来需要跨函数访问
|
|
680
|
+
sql = await createSqlClient({ max: 1 });
|
|
681
|
+
await ensureDbVersion();
|
|
449
682
|
|
|
450
683
|
// 扫描并处理表文件
|
|
451
684
|
const tablesGlob = new Bun.Glob('*.json');
|
|
452
685
|
const directories = [__dirtables, getProjectDir('tables')];
|
|
453
|
-
|
|
454
|
-
let createdTables = 0;
|
|
455
|
-
let modifiedTables = 0;
|
|
456
|
-
// 全局统计
|
|
457
|
-
const overall = {
|
|
458
|
-
addFields: 0,
|
|
459
|
-
typeChanges: 0,
|
|
460
|
-
maxChanges: 0, // 映射为长度变化
|
|
461
|
-
minChanges: 0, // 最小值不参与 DDL,比对保留为0
|
|
462
|
-
defaultChanges: 0,
|
|
463
|
-
nameChanges: 0, // 字段显示名(注释)变更
|
|
464
|
-
indexCreate: 0,
|
|
465
|
-
indexDrop: 0
|
|
466
|
-
};
|
|
686
|
+
// 统计使用全局 globalCount
|
|
467
687
|
|
|
468
688
|
for (const dir of directories) {
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
const tableDefinition = await Bun.file(file).json();
|
|
474
|
-
const result = await exec(client, 'SELECT COUNT(*) as count FROM information_schema.TABLES WHERE TABLE_SCHEMA = ? AND TABLE_NAME = ?', [Env.MYSQL_DB || 'test', tableName]);
|
|
475
|
-
const exists = result[0].count > 0;
|
|
476
|
-
|
|
477
|
-
if (exists) {
|
|
478
|
-
const plan = await syncTable(client, tableName, tableDefinition);
|
|
479
|
-
if (plan.changed) {
|
|
480
|
-
// 汇总统计
|
|
481
|
-
if (plan.metrics) {
|
|
482
|
-
overall.addFields += plan.metrics.addFields;
|
|
483
|
-
overall.typeChanges += plan.metrics.datatype;
|
|
484
|
-
overall.maxChanges += plan.metrics.length;
|
|
485
|
-
overall.defaultChanges += plan.metrics.default;
|
|
486
|
-
overall.indexCreate += plan.metrics.indexCreate;
|
|
487
|
-
overall.indexDrop += plan.metrics.indexDrop;
|
|
488
|
-
overall.nameChanges += plan.metrics.comment;
|
|
489
|
-
}
|
|
490
|
-
// 合并执行 ALTER TABLE 子句
|
|
491
|
-
if (FLAGS.MERGE_ALTER) {
|
|
492
|
-
const clauses = [...plan.addClauses, ...plan.modifyClauses];
|
|
493
|
-
if (clauses.length > 0) {
|
|
494
|
-
const sql = `ALTER TABLE \`${tableName}\` ${clauses.join(', ')}, ALGORITHM=INSTANT, LOCK=NONE`;
|
|
495
|
-
if (FLAGS.DRY_RUN) {
|
|
496
|
-
Logger.info(`[计划] ${sql}`);
|
|
497
|
-
} else {
|
|
498
|
-
await executeDDLSafely(client, sql);
|
|
499
|
-
}
|
|
500
|
-
}
|
|
501
|
-
} else {
|
|
502
|
-
// 分别执行
|
|
503
|
-
for (const c of plan.addClauses) {
|
|
504
|
-
const sql = `ALTER TABLE \`${tableName}\` ${c}, ALGORITHM=INSTANT, LOCK=NONE`;
|
|
505
|
-
if (FLAGS.DRY_RUN) Logger.info(`[计划] ${sql}`);
|
|
506
|
-
else await executeDDLSafely(client, sql);
|
|
507
|
-
}
|
|
508
|
-
for (const c of plan.modifyClauses) {
|
|
509
|
-
const sql = `ALTER TABLE \`${tableName}\` ${c}, ALGORITHM=INSTANT, LOCK=NONE`;
|
|
510
|
-
if (FLAGS.DRY_RUN) Logger.info(`[计划] ${sql}`);
|
|
511
|
-
else await executeDDLSafely(client, sql);
|
|
512
|
-
}
|
|
513
|
-
}
|
|
514
|
-
|
|
515
|
-
// 默认值专用 ALTER
|
|
516
|
-
if (plan.defaultClauses.length > 0) {
|
|
517
|
-
const sql = `ALTER TABLE \`${tableName}\` ${plan.defaultClauses.join(', ')}, ALGORITHM=INSTANT, LOCK=NONE`;
|
|
518
|
-
if (FLAGS.DRY_RUN) Logger.info(`[计划] ${sql}`);
|
|
519
|
-
else await executeDDLSafely(client, sql);
|
|
520
|
-
}
|
|
521
|
-
|
|
522
|
-
// 索引操作
|
|
523
|
-
for (const act of plan.indexActions) {
|
|
524
|
-
const sql = buildIndexSQL(tableName, act.indexName, act.fieldName, act.action);
|
|
525
|
-
if (FLAGS.DRY_RUN) {
|
|
526
|
-
Logger.info(`[计划] ${sql}`);
|
|
527
|
-
} else {
|
|
528
|
-
try {
|
|
529
|
-
await exec(client, sql);
|
|
530
|
-
if (act.action === 'create') {
|
|
531
|
-
Logger.info(`[索引变化] 新建索引 ${tableName}.${act.indexName} (${act.fieldName})`);
|
|
532
|
-
} else {
|
|
533
|
-
Logger.info(`[索引变化] 删除索引 ${tableName}.${act.indexName} (${act.fieldName})`);
|
|
534
|
-
}
|
|
535
|
-
} catch (error) {
|
|
536
|
-
Logger.error(`${act.action === 'create' ? '创建' : '删除'}索引失败: ${error.message}`);
|
|
537
|
-
throw error;
|
|
538
|
-
}
|
|
539
|
-
}
|
|
540
|
-
}
|
|
541
|
-
|
|
542
|
-
modifiedTables++;
|
|
543
|
-
}
|
|
544
|
-
} else {
|
|
545
|
-
await createTable(client, tableName, tableDefinition);
|
|
546
|
-
createdTables++;
|
|
547
|
-
// 新建表已算作变更
|
|
548
|
-
modifiedTables += 0;
|
|
549
|
-
// 创建表统计:按需求仅汇总创建表数量
|
|
550
|
-
}
|
|
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);
|
|
551
693
|
|
|
552
|
-
|
|
694
|
+
if (existsTable) {
|
|
695
|
+
await modifyTable(tableName, tableDefinition);
|
|
696
|
+
} else {
|
|
697
|
+
await createTable(tableName, tableDefinition);
|
|
698
|
+
globalCount.createdTables++;
|
|
553
699
|
}
|
|
554
|
-
|
|
555
|
-
Logger.warn(`扫描目录 ${dir} 出错: ${error.message}`);
|
|
700
|
+
globalCount.processedTables++;
|
|
556
701
|
}
|
|
557
702
|
}
|
|
558
703
|
|
|
559
704
|
// 显示统计信息(扩展维度)
|
|
560
|
-
Logger.info(`统计 -
|
|
561
|
-
Logger.info(`统计 -
|
|
562
|
-
Logger.info(`统计 -
|
|
563
|
-
Logger.info(`统计 -
|
|
564
|
-
Logger.info(`统计 -
|
|
565
|
-
Logger.info(`统计 -
|
|
566
|
-
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}`);
|
|
567
714
|
// 索引新增/删除分别打印
|
|
568
|
-
Logger.info(`统计 - 索引新增: ${
|
|
569
|
-
Logger.info(`统计 - 索引删除: ${
|
|
715
|
+
Logger.info(`统计 - 索引新增: ${globalCount.indexCreate}`);
|
|
716
|
+
Logger.info(`统计 - 索引删除: ${globalCount.indexDrop}`);
|
|
570
717
|
|
|
571
|
-
if (
|
|
718
|
+
if (globalCount.processedTables === 0) {
|
|
572
719
|
Logger.warn('没有找到任何表定义文件');
|
|
573
720
|
}
|
|
721
|
+
|
|
722
|
+
// 保持单一职责:此处不再触发开发管理员同步
|
|
574
723
|
} catch (error) {
|
|
575
724
|
Logger.error(`数据库同步失败: ${error.message}`);
|
|
576
725
|
Logger.error(`错误详情: ${error.stack}`);
|
|
@@ -582,9 +731,9 @@ const SyncDb = async () => {
|
|
|
582
731
|
}
|
|
583
732
|
process.exit(1);
|
|
584
733
|
} finally {
|
|
585
|
-
if (
|
|
734
|
+
if (sql) {
|
|
586
735
|
try {
|
|
587
|
-
await
|
|
736
|
+
await sql.close();
|
|
588
737
|
} catch (error) {
|
|
589
738
|
Logger.warn('关闭数据库连接时出错:', error.message);
|
|
590
739
|
}
|
|
@@ -592,8 +741,8 @@ const SyncDb = async () => {
|
|
|
592
741
|
}
|
|
593
742
|
};
|
|
594
743
|
|
|
595
|
-
//
|
|
596
|
-
if (import.meta.
|
|
744
|
+
// 如果直接运行此脚本(Bun 支持 import.meta.main)
|
|
745
|
+
if (import.meta.main) {
|
|
597
746
|
SyncDb().catch((error) => {
|
|
598
747
|
console.error('❌ 数据库同步失败:', error);
|
|
599
748
|
process.exit(1);
|