befly 2.3.3 → 3.0.1
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/checks/conflict.ts +329 -0
- package/checks/table.ts +252 -0
- package/config/env.ts +218 -0
- package/config/fields.ts +55 -0
- package/config/regexAliases.ts +51 -0
- package/config/reserved.ts +96 -0
- package/main.ts +47 -0
- package/package.json +26 -11
- package/plugins/db.ts +60 -0
- package/plugins/logger.ts +28 -0
- package/plugins/redis.ts +47 -0
- package/scripts/syncDb/apply.ts +171 -0
- package/scripts/syncDb/constants.ts +71 -0
- package/scripts/syncDb/ddl.ts +189 -0
- package/scripts/syncDb/helpers.ts +173 -0
- package/scripts/syncDb/index.ts +203 -0
- package/scripts/syncDb/schema.ts +199 -0
- package/scripts/syncDb/sqlite.ts +50 -0
- package/scripts/syncDb/state.ts +106 -0
- package/scripts/syncDb/table.ts +214 -0
- package/scripts/syncDb/tableCreate.ts +148 -0
- package/scripts/syncDb/tests/constants.test.ts +105 -0
- package/scripts/syncDb/tests/ddl.test.ts +134 -0
- package/scripts/syncDb/tests/helpers.test.ts +70 -0
- package/scripts/syncDb/types.ts +92 -0
- package/scripts/syncDb/version.ts +73 -0
- package/scripts/syncDb.ts +10 -0
- package/tsconfig.json +58 -0
- package/types/addon.d.ts +53 -0
- package/types/api.d.ts +249 -0
- package/types/befly.d.ts +230 -0
- package/types/common.d.ts +215 -0
- package/types/context.d.ts +7 -0
- package/types/crypto.d.ts +23 -0
- package/types/database.d.ts +273 -0
- package/types/index.d.ts +450 -0
- package/types/index.ts +438 -0
- package/types/jwt.d.ts +99 -0
- package/types/logger.d.ts +43 -0
- package/types/plugin.d.ts +109 -0
- package/types/redis.d.ts +46 -0
- package/types/tool.d.ts +67 -0
- package/types/validator.d.ts +43 -0
- package/types/validator.ts +43 -0
- package/utils/colors.ts +221 -0
- package/utils/crypto.ts +308 -0
- package/utils/database.ts +348 -0
- package/utils/dbHelper.ts +713 -0
- package/utils/helper.ts +812 -0
- package/utils/index.ts +33 -0
- package/utils/jwt.ts +493 -0
- package/utils/logger.ts +191 -0
- package/utils/redisHelper.ts +321 -0
- package/utils/requestContext.ts +167 -0
- package/utils/sqlBuilder.ts +611 -0
- package/utils/validate.ts +493 -0
- package/utils/{xml.js → xml.ts} +100 -74
- package/.npmrc +0 -3
- package/.prettierignore +0 -2
- package/.prettierrc +0 -11
- package/apis/health/info.js +0 -49
- package/apis/tool/tokenCheck.js +0 -29
- package/bin/befly.js +0 -109
- package/bunfig.toml +0 -3
- package/checks/table.js +0 -206
- package/config/env.js +0 -64
- package/main.js +0 -579
- package/plugins/db.js +0 -46
- package/plugins/logger.js +0 -14
- package/plugins/redis.js +0 -32
- package/plugins/tool.js +0 -8
- package/scripts/syncDb.js +0 -752
- package/scripts/syncDev.js +0 -96
- package/system.js +0 -118
- package/tables/common.json +0 -16
- package/tables/tool.json +0 -6
- package/utils/api.js +0 -27
- package/utils/colors.js +0 -83
- package/utils/crypto.js +0 -260
- package/utils/index.js +0 -334
- package/utils/jwt.js +0 -387
- package/utils/logger.js +0 -143
- package/utils/redisHelper.js +0 -74
- package/utils/sqlBuilder.js +0 -498
- package/utils/sqlManager.js +0 -471
- package/utils/tool.js +0 -31
- package/utils/validate.js +0 -226
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* syncDb 表操作模块
|
|
3
|
+
*
|
|
4
|
+
* 包含:
|
|
5
|
+
* - 修改表结构
|
|
6
|
+
* - 对比字段变化
|
|
7
|
+
* - 应用变更计划
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { Logger } from '../../utils/logger.js';
|
|
11
|
+
import { toSnakeCase } from '../../utils/helper.js';
|
|
12
|
+
import { parseRule } from '../../utils/helper.js';
|
|
13
|
+
import { IS_MYSQL, IS_PG, IS_SQLITE, SYSTEM_INDEX_FIELDS, CHANGE_TYPE_LABELS, typeMapping } from './constants.js';
|
|
14
|
+
import { quoteIdentifier, logFieldChange, resolveDefaultValue, generateDefaultSql, isStringOrArrayType, getSqlType } from './helpers.js';
|
|
15
|
+
import { buildIndexSQL, generateDDLClause, isPgCompatibleTypeChange } from './ddl.js';
|
|
16
|
+
import { getTableColumns, getTableIndexes, type ColumnInfo } from './schema.js';
|
|
17
|
+
import { compareFieldDefinition, applyTablePlan } from './apply.js';
|
|
18
|
+
import { createTable } from './tableCreate.js';
|
|
19
|
+
import type { TablePlan } from './types.js';
|
|
20
|
+
import type { SQL } from 'bun';
|
|
21
|
+
|
|
22
|
+
// 是否为计划模式(从环境变量读取)
|
|
23
|
+
const IS_PLAN = process.argv.includes('--plan');
|
|
24
|
+
|
|
25
|
+
// 导出 createTable 供其他模块使用
|
|
26
|
+
export { createTable };
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* 同步表结构(对比和应用变更)
|
|
30
|
+
*
|
|
31
|
+
* 主要逻辑:
|
|
32
|
+
* 1. 获取表的现有列和索引信息
|
|
33
|
+
* 2. 对比每个字段的定义变化
|
|
34
|
+
* 3. 生成 DDL 变更计划
|
|
35
|
+
* 4. 处理索引的增删
|
|
36
|
+
* 5. 应用变更计划
|
|
37
|
+
*
|
|
38
|
+
* 安全策略:
|
|
39
|
+
* - 禁止字段类型变更(除 string<->array)
|
|
40
|
+
* - 跳过危险的长度收缩
|
|
41
|
+
* - 使用在线 DDL(MySQL/PG)
|
|
42
|
+
*
|
|
43
|
+
* @param sql - SQL 客户端实例
|
|
44
|
+
* @param tableName - 表名
|
|
45
|
+
* @param fields - 字段定义对象
|
|
46
|
+
* @param globalCount - 全局统计对象(用于计数)
|
|
47
|
+
* @returns 表结构变更计划
|
|
48
|
+
*/
|
|
49
|
+
export async function modifyTable(sql: SQL, tableName: string, fields: Record<string, string>, globalCount: Record<string, number>): Promise<TablePlan> {
|
|
50
|
+
const existingColumns = await getTableColumns(sql, tableName);
|
|
51
|
+
const existingIndexes = await getTableIndexes(sql, tableName);
|
|
52
|
+
let changed = false;
|
|
53
|
+
|
|
54
|
+
const addClauses = [];
|
|
55
|
+
const modifyClauses = [];
|
|
56
|
+
const defaultClauses = [];
|
|
57
|
+
const indexActions = [];
|
|
58
|
+
|
|
59
|
+
for (const [fieldKey, fieldRule] of Object.entries(fields)) {
|
|
60
|
+
// 转换字段名为下划线格式(用于与数据库字段对比)
|
|
61
|
+
const dbFieldName = toSnakeCase(fieldKey);
|
|
62
|
+
|
|
63
|
+
if (existingColumns[dbFieldName]) {
|
|
64
|
+
const comparison = compareFieldDefinition(existingColumns[dbFieldName], fieldRule, dbFieldName);
|
|
65
|
+
if (comparison.length > 0) {
|
|
66
|
+
for (const c of comparison) {
|
|
67
|
+
// 使用统一的日志格式函数和常量标签
|
|
68
|
+
const changeLabel = CHANGE_TYPE_LABELS[c.type] || '未知';
|
|
69
|
+
logFieldChange(tableName, dbFieldName, c.type, c.current, c.expected, changeLabel);
|
|
70
|
+
|
|
71
|
+
// 全量计数:全局累加
|
|
72
|
+
if (c.type === 'datatype') globalCount.typeChanges++;
|
|
73
|
+
else if (c.type === 'length') globalCount.maxChanges++;
|
|
74
|
+
else if (c.type === 'default') globalCount.defaultChanges++;
|
|
75
|
+
else if (c.type === 'comment') globalCount.nameChanges++;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const parsed = parseRule(fieldRule);
|
|
79
|
+
const { name: fieldName, type: fieldType, max: fieldMax, default: fieldDefault } = parsed;
|
|
80
|
+
|
|
81
|
+
if (isStringOrArrayType(fieldType) && existingColumns[dbFieldName].length) {
|
|
82
|
+
if (existingColumns[dbFieldName].length! > fieldMax) {
|
|
83
|
+
Logger.warn(`[跳过危险变更] ${tableName}.${dbFieldName} 长度收缩 ${existingColumns[dbFieldName].length} -> ${fieldMax} 已被跳过(设置 SYNC_DISALLOW_SHRINK=0 可放开)`);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const hasTypeChange = comparison.some((c) => c.type === 'datatype');
|
|
88
|
+
const hasLengthChange = comparison.some((c) => c.type === 'length');
|
|
89
|
+
const onlyDefaultChanged = comparison.every((c) => c.type === 'default');
|
|
90
|
+
const defaultChanged = comparison.some((c) => c.type === 'default');
|
|
91
|
+
|
|
92
|
+
// 严格限制:除 string/array 互转外,禁止任何字段类型变更;一旦发现,立即终止同步
|
|
93
|
+
if (hasTypeChange) {
|
|
94
|
+
const currentSqlType = String(existingColumns[dbFieldName].type || '').toLowerCase();
|
|
95
|
+
const newSqlType = String(typeMapping[fieldType] || '').toLowerCase();
|
|
96
|
+
const errorMsg = [`禁止字段类型变更: ${tableName}.${dbFieldName}`, `当前类型: ${currentSqlType}`, `目标类型: ${newSqlType}`, `说明: 仅允许 string<->array 互相切换,其他类型变更需要手动处理`].join('\n');
|
|
97
|
+
throw new Error(errorMsg);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// 默认值变化处理
|
|
101
|
+
if (defaultChanged) {
|
|
102
|
+
// 使用公共函数处理默认值
|
|
103
|
+
const actualDefault = resolveDefaultValue(fieldDefault, fieldType);
|
|
104
|
+
|
|
105
|
+
// 生成 SQL DEFAULT 值(不包含前导空格,因为要用于 ALTER COLUMN)
|
|
106
|
+
let v: string | null = null;
|
|
107
|
+
if (actualDefault !== 'null') {
|
|
108
|
+
const defaultSql = generateDefaultSql(actualDefault, fieldType);
|
|
109
|
+
// 移除前导空格 ' DEFAULT ' -> 'DEFAULT '
|
|
110
|
+
v = defaultSql.trim().replace(/^DEFAULT\s+/, '');
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
if (v !== null && v !== '') {
|
|
114
|
+
if (IS_PG) {
|
|
115
|
+
defaultClauses.push(`ALTER COLUMN "${dbFieldName}" SET DEFAULT ${v}`);
|
|
116
|
+
} else if (IS_MYSQL && onlyDefaultChanged) {
|
|
117
|
+
// MySQL 的 TEXT/BLOB 不允许 DEFAULT,跳过 text 类型
|
|
118
|
+
if (fieldType !== 'text') {
|
|
119
|
+
defaultClauses.push(`ALTER COLUMN \`${dbFieldName}\` SET DEFAULT ${v}`);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// 若不仅仅是默认值变化,继续生成修改子句
|
|
126
|
+
if (!onlyDefaultChanged) {
|
|
127
|
+
let skipModify = false;
|
|
128
|
+
if (hasLengthChange && isStringOrArrayType(fieldType) && existingColumns[dbFieldName].length) {
|
|
129
|
+
const oldLen = existingColumns[dbFieldName].length!;
|
|
130
|
+
const isShrink = oldLen > fieldMax;
|
|
131
|
+
if (isShrink) skipModify = true;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
if (hasTypeChange) {
|
|
135
|
+
if (IS_PG && isPgCompatibleTypeChange(existingColumns[dbFieldName].type, typeMapping[fieldType].toLowerCase())) {
|
|
136
|
+
Logger.info(`[PG兼容类型变更] ${tableName}.${dbFieldName} ${existingColumns[dbFieldName].type} -> ${typeMapping[fieldType].toLowerCase()} 允许执行`);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
if (!skipModify) modifyClauses.push(generateDDLClause(fieldKey, fieldRule, false));
|
|
141
|
+
}
|
|
142
|
+
changed = true;
|
|
143
|
+
}
|
|
144
|
+
} else {
|
|
145
|
+
const parsed = parseRule(fieldRule);
|
|
146
|
+
const { name: fieldName, type: fieldType, max: fieldMax, default: fieldDefault } = parsed;
|
|
147
|
+
const lenPart = isStringOrArrayType(fieldType) ? ` 长度:${parseInt(String(fieldMax))}` : '';
|
|
148
|
+
Logger.info(`[新增字段] ${tableName}.${dbFieldName} 类型:${fieldType}${lenPart} 默认:${fieldDefault ?? 'NULL'}`);
|
|
149
|
+
addClauses.push(generateDDLClause(fieldKey, fieldRule, true));
|
|
150
|
+
changed = true;
|
|
151
|
+
globalCount.addFields++;
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// 检查系统字段索引
|
|
156
|
+
for (const sysField of ['created_at', 'updated_at', 'state']) {
|
|
157
|
+
const idxName = `idx_${sysField}`;
|
|
158
|
+
if (!existingIndexes[idxName]) {
|
|
159
|
+
indexActions.push({ action: 'create', indexName: idxName, fieldName: sysField });
|
|
160
|
+
changed = true;
|
|
161
|
+
globalCount.indexCreate++;
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// 检查业务字段索引
|
|
166
|
+
for (const [fieldKey, fieldRule] of Object.entries(fields)) {
|
|
167
|
+
// 转换字段名为下划线格式
|
|
168
|
+
const dbFieldName = toSnakeCase(fieldKey);
|
|
169
|
+
|
|
170
|
+
const parsed = parseRule(fieldRule);
|
|
171
|
+
const indexName = `idx_${dbFieldName}`;
|
|
172
|
+
if (parsed.index === 1 && !existingIndexes[indexName]) {
|
|
173
|
+
indexActions.push({ action: 'create', indexName, fieldName: dbFieldName });
|
|
174
|
+
changed = true;
|
|
175
|
+
globalCount.indexCreate++;
|
|
176
|
+
} else if (!(parsed.index === 1) && existingIndexes[indexName] && existingIndexes[indexName].length === 1) {
|
|
177
|
+
indexActions.push({ action: 'drop', indexName, fieldName: dbFieldName });
|
|
178
|
+
changed = true;
|
|
179
|
+
globalCount.indexDrop++;
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// PG 列注释处理
|
|
184
|
+
const commentActions = [];
|
|
185
|
+
if (IS_PG) {
|
|
186
|
+
for (const [fieldKey, fieldRule] of Object.entries(fields)) {
|
|
187
|
+
// 转换字段名为下划线格式
|
|
188
|
+
const dbFieldName = toSnakeCase(fieldKey);
|
|
189
|
+
|
|
190
|
+
if (existingColumns[dbFieldName]) {
|
|
191
|
+
const parsed = parseRule(fieldRule);
|
|
192
|
+
const { name: fieldName } = parsed;
|
|
193
|
+
const curr = existingColumns[dbFieldName].comment || '';
|
|
194
|
+
const want = fieldName && fieldName !== 'null' ? String(fieldName) : '';
|
|
195
|
+
if (want !== curr) {
|
|
196
|
+
commentActions.push(`COMMENT ON COLUMN "${tableName}"."${dbFieldName}" IS ${want ? `'${want}'` : 'NULL'}`);
|
|
197
|
+
changed = true;
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// 仅当存在实际动作时才认为有变更(避免仅日志的收缩跳过被计为修改)
|
|
204
|
+
changed = addClauses.length > 0 || modifyClauses.length > 0 || defaultClauses.length > 0 || indexActions.length > 0 || commentActions.length > 0;
|
|
205
|
+
|
|
206
|
+
const plan = { changed, addClauses, modifyClauses, defaultClauses, indexActions, commentActions };
|
|
207
|
+
|
|
208
|
+
// 将计划应用(包含 --plan 情况下仅输出)
|
|
209
|
+
if (plan.changed) {
|
|
210
|
+
await applyTablePlan(sql, tableName, fields, plan, globalCount);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
return plan;
|
|
214
|
+
}
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* syncDb 表创建模块
|
|
3
|
+
*
|
|
4
|
+
* 包含:
|
|
5
|
+
* - 创建表(包含系统字段和业务字段)
|
|
6
|
+
* - 添加 PostgreSQL 注释
|
|
7
|
+
* - 创建表索引
|
|
8
|
+
*
|
|
9
|
+
* 注意:此模块从 table.ts 中提取,用于解除循环依赖
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { Logger } from '../../utils/logger.js';
|
|
13
|
+
import { IS_MYSQL, IS_PG, MYSQL_TABLE_CONFIG } from './constants.js';
|
|
14
|
+
import { quoteIdentifier } from './helpers.js';
|
|
15
|
+
import { buildSystemColumnDefs, buildBusinessColumnDefs, buildIndexSQL } from './ddl.js';
|
|
16
|
+
import { parseRule, toSnakeCase } from '../../utils/helper.js';
|
|
17
|
+
import type { SQL } from 'bun';
|
|
18
|
+
|
|
19
|
+
// 是否为计划模式(从环境变量读取)
|
|
20
|
+
const IS_PLAN = process.argv.includes('--plan');
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* 为 PostgreSQL 表添加列注释
|
|
24
|
+
*
|
|
25
|
+
* @param sql - SQL 客户端实例
|
|
26
|
+
* @param tableName - 表名
|
|
27
|
+
* @param fields - 字段定义对象
|
|
28
|
+
*/
|
|
29
|
+
async function addPostgresComments(sql: SQL, tableName: string, fields: Record<string, string>): Promise<void> {
|
|
30
|
+
// 系统字段注释
|
|
31
|
+
const systemComments = [
|
|
32
|
+
['id', '主键ID'],
|
|
33
|
+
['created_at', '创建时间'],
|
|
34
|
+
['updated_at', '更新时间'],
|
|
35
|
+
['deleted_at', '删除时间'],
|
|
36
|
+
['state', '状态字段']
|
|
37
|
+
];
|
|
38
|
+
|
|
39
|
+
for (const [name, comment] of systemComments) {
|
|
40
|
+
const stmt = `COMMENT ON COLUMN "${tableName}"."${name}" IS '${comment}'`;
|
|
41
|
+
if (IS_PLAN) {
|
|
42
|
+
Logger.info(`[计划] ${stmt}`);
|
|
43
|
+
} else {
|
|
44
|
+
await sql.unsafe(stmt);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// 业务字段注释
|
|
49
|
+
for (const [fieldKey, fieldRule] of Object.entries(fields)) {
|
|
50
|
+
// 转换字段名为下划线格式
|
|
51
|
+
const dbFieldName = toSnakeCase(fieldKey);
|
|
52
|
+
|
|
53
|
+
const parsed = parseRule(fieldRule);
|
|
54
|
+
const { name: fieldName } = parsed;
|
|
55
|
+
const stmt = `COMMENT ON COLUMN "${tableName}"."${dbFieldName}" IS '${fieldName}'`;
|
|
56
|
+
if (IS_PLAN) {
|
|
57
|
+
Logger.info(`[计划] ${stmt}`);
|
|
58
|
+
} else {
|
|
59
|
+
await sql.unsafe(stmt);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* 创建表的索引(并行执行以提升性能)
|
|
66
|
+
*
|
|
67
|
+
* @param sql - SQL 客户端实例
|
|
68
|
+
* @param tableName - 表名
|
|
69
|
+
* @param fields - 字段定义对象
|
|
70
|
+
* @param systemIndexFields - 系统字段索引列表
|
|
71
|
+
*/
|
|
72
|
+
async function createTableIndexes(sql: SQL, tableName: string, fields: Record<string, string>, systemIndexFields: string[]): Promise<void> {
|
|
73
|
+
const indexTasks: Promise<void>[] = [];
|
|
74
|
+
|
|
75
|
+
// 系统字段索引
|
|
76
|
+
for (const sysField of systemIndexFields) {
|
|
77
|
+
const stmt = buildIndexSQL(tableName, `idx_${sysField}`, sysField, 'create');
|
|
78
|
+
if (IS_PLAN) {
|
|
79
|
+
Logger.info(`[计划] ${stmt}`);
|
|
80
|
+
} else {
|
|
81
|
+
indexTasks.push(sql.unsafe(stmt));
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// 业务字段索引
|
|
86
|
+
for (const [fieldKey, fieldRule] of Object.entries(fields)) {
|
|
87
|
+
// 转换字段名为下划线格式
|
|
88
|
+
const dbFieldName = toSnakeCase(fieldKey);
|
|
89
|
+
|
|
90
|
+
const parsed = parseRule(fieldRule);
|
|
91
|
+
if (parsed.index === 1) {
|
|
92
|
+
const stmt = buildIndexSQL(tableName, `idx_${dbFieldName}`, dbFieldName, 'create');
|
|
93
|
+
if (IS_PLAN) {
|
|
94
|
+
Logger.info(`[计划] ${stmt}`);
|
|
95
|
+
} else {
|
|
96
|
+
indexTasks.push(sql.unsafe(stmt));
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// 并行执行所有索引创建
|
|
102
|
+
if (indexTasks.length > 0) {
|
|
103
|
+
await Promise.all(indexTasks);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* 创建表(包含系统字段和业务字段)
|
|
109
|
+
*
|
|
110
|
+
* @param sql - SQL 客户端实例
|
|
111
|
+
* @param tableName - 表名
|
|
112
|
+
* @param fields - 字段定义对象
|
|
113
|
+
* @param systemIndexFields - 系统字段索引列表(可选,默认使用 ['created_at', 'updated_at', 'state'])
|
|
114
|
+
*/
|
|
115
|
+
export async function createTable(sql: SQL, tableName: string, fields: Record<string, string>, systemIndexFields: string[] = ['created_at', 'updated_at', 'state']): Promise<void> {
|
|
116
|
+
// 构建列定义
|
|
117
|
+
const colDefs = [...buildSystemColumnDefs(), ...buildBusinessColumnDefs(fields)];
|
|
118
|
+
|
|
119
|
+
// 生成 CREATE TABLE 语句
|
|
120
|
+
const cols = colDefs.join(',\n ');
|
|
121
|
+
const tableQuoted = quoteIdentifier(tableName);
|
|
122
|
+
const { ENGINE, CHARSET, COLLATE } = MYSQL_TABLE_CONFIG;
|
|
123
|
+
const createSQL = IS_MYSQL
|
|
124
|
+
? `CREATE TABLE ${tableQuoted} (
|
|
125
|
+
${cols}
|
|
126
|
+
) ENGINE=${ENGINE} DEFAULT CHARSET=${CHARSET} COLLATE=${COLLATE}`
|
|
127
|
+
: `CREATE TABLE ${tableQuoted} (
|
|
128
|
+
${cols}
|
|
129
|
+
)`;
|
|
130
|
+
|
|
131
|
+
if (IS_PLAN) {
|
|
132
|
+
Logger.info(`[计划] ${createSQL.replace(/\n+/g, ' ')}`);
|
|
133
|
+
} else {
|
|
134
|
+
await sql.unsafe(createSQL);
|
|
135
|
+
Logger.info(`[新建表] ${tableName}`);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// PostgreSQL: 添加列注释
|
|
139
|
+
if (IS_PG && !IS_PLAN) {
|
|
140
|
+
await addPostgresComments(sql, tableName, fields);
|
|
141
|
+
} else if (IS_PG && IS_PLAN) {
|
|
142
|
+
// 计划模式也要输出注释语句
|
|
143
|
+
await addPostgresComments(sql, tableName, fields);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// 创建索引
|
|
147
|
+
await createTableIndexes(sql, tableName, fields, systemIndexFields);
|
|
148
|
+
}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* syncDb 常量模块测试
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { describe, test, expect } from 'bun:test';
|
|
6
|
+
import { DB_VERSION_REQUIREMENTS, SYSTEM_FIELDS, SYSTEM_INDEX_FIELDS, CHANGE_TYPE_LABELS, MYSQL_TABLE_CONFIG, IS_MYSQL, IS_PG, IS_SQLITE, typeMapping } from '../constants.js';
|
|
7
|
+
|
|
8
|
+
describe('syncDb/constants', () => {
|
|
9
|
+
describe('数据库版本要求', () => {
|
|
10
|
+
test('应定义 MySQL 最低版本', () => {
|
|
11
|
+
expect(DB_VERSION_REQUIREMENTS.MYSQL_MIN_MAJOR).toBe(8);
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
test('应定义 PostgreSQL 最低版本', () => {
|
|
15
|
+
expect(DB_VERSION_REQUIREMENTS.POSTGRES_MIN_MAJOR).toBe(17);
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
test('应定义 SQLite 最低版本', () => {
|
|
19
|
+
expect(DB_VERSION_REQUIREMENTS.SQLITE_MIN_VERSION).toBe('3.50.0');
|
|
20
|
+
expect(DB_VERSION_REQUIREMENTS.SQLITE_MIN_VERSION_NUM).toBe(35000);
|
|
21
|
+
});
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
describe('系统字段定义', () => {
|
|
25
|
+
test('应包含 5 个系统字段', () => {
|
|
26
|
+
const fields = Object.keys(SYSTEM_FIELDS);
|
|
27
|
+
expect(fields).toHaveLength(5);
|
|
28
|
+
expect(fields).toContain('ID');
|
|
29
|
+
expect(fields).toContain('CREATED_AT');
|
|
30
|
+
expect(fields).toContain('UPDATED_AT');
|
|
31
|
+
expect(fields).toContain('DELETED_AT');
|
|
32
|
+
expect(fields).toContain('STATE');
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
test('每个系统字段应有名称和注释', () => {
|
|
36
|
+
expect(SYSTEM_FIELDS.ID.name).toBe('id');
|
|
37
|
+
expect(SYSTEM_FIELDS.ID.comment).toBe('主键ID');
|
|
38
|
+
});
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
describe('系统索引字段', () => {
|
|
42
|
+
test('应包含 3 个索引字段', () => {
|
|
43
|
+
expect(SYSTEM_INDEX_FIELDS).toHaveLength(3);
|
|
44
|
+
expect(SYSTEM_INDEX_FIELDS).toContain('created_at');
|
|
45
|
+
expect(SYSTEM_INDEX_FIELDS).toContain('updated_at');
|
|
46
|
+
expect(SYSTEM_INDEX_FIELDS).toContain('state');
|
|
47
|
+
});
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
describe('变更类型标签', () => {
|
|
51
|
+
test('应包含所有变更类型', () => {
|
|
52
|
+
expect(CHANGE_TYPE_LABELS.length).toBe('长度');
|
|
53
|
+
expect(CHANGE_TYPE_LABELS.datatype).toBe('类型');
|
|
54
|
+
expect(CHANGE_TYPE_LABELS.comment).toBe('注释');
|
|
55
|
+
expect(CHANGE_TYPE_LABELS.default).toBe('默认值');
|
|
56
|
+
});
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
describe('MySQL 表配置', () => {
|
|
60
|
+
test('应有默认配置', () => {
|
|
61
|
+
expect(MYSQL_TABLE_CONFIG.ENGINE).toBeDefined();
|
|
62
|
+
expect(MYSQL_TABLE_CONFIG.CHARSET).toBeDefined();
|
|
63
|
+
expect(MYSQL_TABLE_CONFIG.COLLATE).toBeDefined();
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
test('默认引擎应为 InnoDB', () => {
|
|
67
|
+
expect(MYSQL_TABLE_CONFIG.ENGINE).toMatch(/InnoDB/i);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test('默认字符集应为 utf8mb4', () => {
|
|
71
|
+
expect(MYSQL_TABLE_CONFIG.CHARSET).toMatch(/utf8mb4/i);
|
|
72
|
+
});
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
describe('数据库类型检测', () => {
|
|
76
|
+
test('IS_MYSQL, IS_PG, IS_SQLITE 三者只有一个为 true', () => {
|
|
77
|
+
const trueCount = [IS_MYSQL, IS_PG, IS_SQLITE].filter(Boolean).length;
|
|
78
|
+
expect(trueCount).toBe(1);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
test('类型映射应包含所有字段类型', () => {
|
|
82
|
+
expect(typeMapping.number).toBeDefined();
|
|
83
|
+
expect(typeMapping.string).toBeDefined();
|
|
84
|
+
expect(typeMapping.text).toBeDefined();
|
|
85
|
+
expect(typeMapping.array_string).toBeDefined();
|
|
86
|
+
expect(typeMapping.array_text).toBeDefined();
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
test('不同数据库的类型映射应不同', () => {
|
|
90
|
+
if (IS_MYSQL) {
|
|
91
|
+
expect(typeMapping.number).toBe('BIGINT');
|
|
92
|
+
expect(typeMapping.string).toBe('VARCHAR');
|
|
93
|
+
expect(typeMapping.text).toBe('MEDIUMTEXT');
|
|
94
|
+
} else if (IS_PG) {
|
|
95
|
+
expect(typeMapping.number).toBe('BIGINT');
|
|
96
|
+
expect(typeMapping.string).toBe('character varying');
|
|
97
|
+
expect(typeMapping.text).toBe('TEXT');
|
|
98
|
+
} else if (IS_SQLITE) {
|
|
99
|
+
expect(typeMapping.number).toBe('INTEGER');
|
|
100
|
+
expect(typeMapping.string).toBe('TEXT');
|
|
101
|
+
expect(typeMapping.text).toBe('TEXT');
|
|
102
|
+
}
|
|
103
|
+
});
|
|
104
|
+
});
|
|
105
|
+
});
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* syncDb DDL 构建测试
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { describe, test, expect } from 'bun:test';
|
|
6
|
+
import { buildIndexSQL, buildSystemColumnDefs, buildBusinessColumnDefs, generateDDLClause, isPgCompatibleTypeChange } from '../ddl.js';
|
|
7
|
+
import { IS_MYSQL, IS_PG, IS_SQLITE } from '../constants.js';
|
|
8
|
+
|
|
9
|
+
describe('syncDb/ddl', () => {
|
|
10
|
+
describe('buildIndexSQL', () => {
|
|
11
|
+
test('应生成创建索引 SQL', () => {
|
|
12
|
+
const sql = buildIndexSQL('users', 'idx_email', 'email', 'create');
|
|
13
|
+
expect(sql).toBeDefined();
|
|
14
|
+
expect(sql.length).toBeGreaterThan(0);
|
|
15
|
+
|
|
16
|
+
if (IS_MYSQL) {
|
|
17
|
+
expect(sql).toContain('ADD INDEX');
|
|
18
|
+
expect(sql).toContain('ALGORITHM=INPLACE');
|
|
19
|
+
expect(sql).toContain('LOCK=NONE');
|
|
20
|
+
} else if (IS_PG) {
|
|
21
|
+
expect(sql).toContain('CREATE INDEX CONCURRENTLY');
|
|
22
|
+
} else if (IS_SQLITE) {
|
|
23
|
+
expect(sql).toContain('CREATE INDEX');
|
|
24
|
+
}
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
test('应生成删除索引 SQL', () => {
|
|
28
|
+
const sql = buildIndexSQL('users', 'idx_email', 'email', 'drop');
|
|
29
|
+
expect(sql).toBeDefined();
|
|
30
|
+
|
|
31
|
+
if (IS_MYSQL) {
|
|
32
|
+
expect(sql).toContain('DROP INDEX');
|
|
33
|
+
} else if (IS_PG) {
|
|
34
|
+
expect(sql).toContain('DROP INDEX CONCURRENTLY');
|
|
35
|
+
} else if (IS_SQLITE) {
|
|
36
|
+
expect(sql).toContain('DROP INDEX');
|
|
37
|
+
}
|
|
38
|
+
});
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
describe('buildSystemColumnDefs', () => {
|
|
42
|
+
test('应返回 5 个系统字段定义', () => {
|
|
43
|
+
const defs = buildSystemColumnDefs();
|
|
44
|
+
expect(defs).toHaveLength(5);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test('系统字段应包含 id, created_at, updated_at, deleted_at, state', () => {
|
|
48
|
+
const defs = buildSystemColumnDefs();
|
|
49
|
+
const combined = defs.join(' ');
|
|
50
|
+
|
|
51
|
+
expect(combined).toContain('id');
|
|
52
|
+
expect(combined).toContain('created_at');
|
|
53
|
+
expect(combined).toContain('updated_at');
|
|
54
|
+
expect(combined).toContain('deleted_at');
|
|
55
|
+
expect(combined).toContain('state');
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test('MySQL 应包含 COMMENT', () => {
|
|
59
|
+
const defs = buildSystemColumnDefs();
|
|
60
|
+
const combined = defs.join(' ');
|
|
61
|
+
|
|
62
|
+
if (IS_MYSQL) {
|
|
63
|
+
expect(combined).toContain('COMMENT');
|
|
64
|
+
}
|
|
65
|
+
});
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
describe('buildBusinessColumnDefs', () => {
|
|
69
|
+
test('应处理空字段对象', () => {
|
|
70
|
+
const defs = buildBusinessColumnDefs({});
|
|
71
|
+
expect(defs).toHaveLength(0);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test('应生成字段定义', () => {
|
|
75
|
+
const fields = {
|
|
76
|
+
username: '用户名|string|1|50||1',
|
|
77
|
+
age: '年龄|number|0|150|0|0'
|
|
78
|
+
};
|
|
79
|
+
const defs = buildBusinessColumnDefs(fields);
|
|
80
|
+
|
|
81
|
+
expect(defs.length).toBeGreaterThan(0);
|
|
82
|
+
expect(defs.join(' ')).toContain('username');
|
|
83
|
+
expect(defs.join(' ')).toContain('age');
|
|
84
|
+
});
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
describe('generateDDLClause', () => {
|
|
88
|
+
test('应生成添加字段子句', () => {
|
|
89
|
+
const clause = generateDDLClause('email', '邮箱|string|0|100||0', true);
|
|
90
|
+
expect(clause).toBeDefined();
|
|
91
|
+
|
|
92
|
+
if (IS_MYSQL) {
|
|
93
|
+
expect(clause).toContain('ADD COLUMN');
|
|
94
|
+
} else if (IS_PG) {
|
|
95
|
+
expect(clause).toContain('ADD COLUMN');
|
|
96
|
+
} else if (IS_SQLITE) {
|
|
97
|
+
expect(clause).toContain('ADD COLUMN');
|
|
98
|
+
}
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
test('应生成修改字段子句', () => {
|
|
102
|
+
const clause = generateDDLClause('email', '邮箱|string|0|100||0', false);
|
|
103
|
+
expect(clause).toBeDefined();
|
|
104
|
+
|
|
105
|
+
if (IS_MYSQL) {
|
|
106
|
+
expect(clause).toContain('MODIFY COLUMN');
|
|
107
|
+
} else if (IS_PG) {
|
|
108
|
+
expect(clause).toContain('ALTER COLUMN');
|
|
109
|
+
}
|
|
110
|
+
});
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
describe('isPgCompatibleTypeChange', () => {
|
|
114
|
+
test('varchar -> text 应为兼容变更', () => {
|
|
115
|
+
const result = isPgCompatibleTypeChange('character varying', 'text');
|
|
116
|
+
expect(result).toBe(true);
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
test('text -> varchar 应为不兼容变更', () => {
|
|
120
|
+
const result = isPgCompatibleTypeChange('text', 'character varying');
|
|
121
|
+
expect(result).toBe(false);
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
test('相同类型应为不兼容变更(无需变更)', () => {
|
|
125
|
+
const result = isPgCompatibleTypeChange('text', 'text');
|
|
126
|
+
expect(result).toBe(false);
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
test('应处理大小写', () => {
|
|
130
|
+
const result = isPgCompatibleTypeChange('CHARACTER VARYING', 'TEXT');
|
|
131
|
+
expect(result).toBe(true);
|
|
132
|
+
});
|
|
133
|
+
});
|
|
134
|
+
});
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* syncDb 辅助函数测试
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { describe, test, expect } from 'bun:test';
|
|
6
|
+
import { quoteIdentifier, logFieldChange, formatFieldList } from '../helpers.js';
|
|
7
|
+
|
|
8
|
+
describe('syncDb/helpers', () => {
|
|
9
|
+
describe('quoteIdentifier', () => {
|
|
10
|
+
test('应正确引用标识符', () => {
|
|
11
|
+
const result = quoteIdentifier('user_table');
|
|
12
|
+
|
|
13
|
+
// 根据当前数据库类型验证
|
|
14
|
+
expect(typeof result).toBe('string');
|
|
15
|
+
expect(result.length).toBeGreaterThan(0);
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
test('应处理特殊字符', () => {
|
|
19
|
+
const result = quoteIdentifier('table_name_with_underscore');
|
|
20
|
+
expect(result).toBeDefined();
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
test('应处理空字符串', () => {
|
|
24
|
+
const result = quoteIdentifier('');
|
|
25
|
+
expect(typeof result).toBe('string');
|
|
26
|
+
});
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
describe('logFieldChange', () => {
|
|
30
|
+
test('应不抛出错误', () => {
|
|
31
|
+
expect(() => {
|
|
32
|
+
logFieldChange('test_table', 'test_field', 'length', 100, 200, '长度');
|
|
33
|
+
}).not.toThrow();
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
test('应处理各种变更类型', () => {
|
|
37
|
+
expect(() => {
|
|
38
|
+
logFieldChange('table1', 'field1', 'length', 100, 200, '长度');
|
|
39
|
+
logFieldChange('table2', 'field2', 'datatype', 'INT', 'VARCHAR', '类型');
|
|
40
|
+
logFieldChange('table3', 'field3', 'comment', 'old', 'new', '注释');
|
|
41
|
+
logFieldChange('table4', 'field4', 'default', 0, 1, '默认值');
|
|
42
|
+
}).not.toThrow();
|
|
43
|
+
});
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
describe('formatFieldList', () => {
|
|
47
|
+
test('应正确格式化单个字段', () => {
|
|
48
|
+
const result = formatFieldList(['id']);
|
|
49
|
+
expect(result).toBeDefined();
|
|
50
|
+
expect(typeof result).toBe('string');
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test('应正确格式化多个字段', () => {
|
|
54
|
+
const result = formatFieldList(['id', 'name', 'email']);
|
|
55
|
+
expect(result).toContain(',');
|
|
56
|
+
expect(result.split(',').length).toBe(3);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test('应处理空数组', () => {
|
|
60
|
+
const result = formatFieldList([]);
|
|
61
|
+
expect(result).toBe('');
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
test('应正确引用字段名', () => {
|
|
65
|
+
const result = formatFieldList(['user_id', 'user_name']);
|
|
66
|
+
expect(result).toBeDefined();
|
|
67
|
+
// 结果应包含引用符号(取决于数据库类型)
|
|
68
|
+
});
|
|
69
|
+
});
|
|
70
|
+
});
|