befly 3.9.1 → 3.9.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/package.json +2 -2
- package/sync/syncDb/ddl.ts +67 -11
- package/sync/syncDb/table.ts +31 -15
- package/tests/syncDb-ddl.test.ts +33 -9
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "befly",
|
|
3
|
-
"version": "3.9.
|
|
3
|
+
"version": "3.9.3",
|
|
4
4
|
"description": "Befly - 为 Bun 专属打造的 TypeScript API 接口框架核心引擎",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"private": false,
|
|
@@ -73,7 +73,7 @@
|
|
|
73
73
|
"pino": "^10.1.0",
|
|
74
74
|
"pino-roll": "^4.0.0"
|
|
75
75
|
},
|
|
76
|
-
"gitHead": "
|
|
76
|
+
"gitHead": "4baa6420da1d505fb6a7cd82169d597456d9a99f",
|
|
77
77
|
"devDependencies": {
|
|
78
78
|
"typescript": "^5.9.3"
|
|
79
79
|
}
|
package/sync/syncDb/ddl.ts
CHANGED
|
@@ -60,16 +60,39 @@ export function buildIndexSQL(tableName: string, indexName: string, fieldName: s
|
|
|
60
60
|
return `DROP INDEX IF EXISTS ${indexQuoted}`;
|
|
61
61
|
}
|
|
62
62
|
|
|
63
|
+
/**
|
|
64
|
+
* 获取单个系统字段的列定义(用于 ADD COLUMN 或 CREATE TABLE)
|
|
65
|
+
*
|
|
66
|
+
* @param fieldName - 系统字段名(id, created_at, updated_at, deleted_at, state)
|
|
67
|
+
* @returns 列定义字符串,如果不是系统字段则返回 null
|
|
68
|
+
*/
|
|
69
|
+
export function getSystemColumnDef(fieldName: string): string | null {
|
|
70
|
+
const mysqlDefs: Record<string, string> = {
|
|
71
|
+
id: '`id` BIGINT UNSIGNED PRIMARY KEY AUTO_INCREMENT COMMENT "主键ID"',
|
|
72
|
+
created_at: '`created_at` BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT "创建时间"',
|
|
73
|
+
updated_at: '`updated_at` BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT "更新时间"',
|
|
74
|
+
deleted_at: '`deleted_at` BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT "删除时间"',
|
|
75
|
+
state: '`state` BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT "状态字段"'
|
|
76
|
+
};
|
|
77
|
+
const pgDefs: Record<string, string> = {
|
|
78
|
+
id: '"id" INTEGER PRIMARY KEY',
|
|
79
|
+
created_at: '"created_at" INTEGER NOT NULL DEFAULT 0',
|
|
80
|
+
updated_at: '"updated_at" INTEGER NOT NULL DEFAULT 0',
|
|
81
|
+
deleted_at: '"deleted_at" INTEGER NOT NULL DEFAULT 0',
|
|
82
|
+
state: '"state" INTEGER NOT NULL DEFAULT 0'
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
const defs = isMySQL() ? mysqlDefs : pgDefs;
|
|
86
|
+
return defs[fieldName] || null;
|
|
87
|
+
}
|
|
88
|
+
|
|
63
89
|
/**
|
|
64
90
|
* 构建系统字段列定义
|
|
65
91
|
*
|
|
66
92
|
* @returns 系统字段的列定义数组
|
|
67
93
|
*/
|
|
68
94
|
export function buildSystemColumnDefs(): string[] {
|
|
69
|
-
|
|
70
|
-
return ['`id` BIGINT UNSIGNED PRIMARY KEY AUTO_INCREMENT COMMENT "主键ID"', '`created_at` BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT "创建时间"', '`updated_at` BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT "更新时间"', '`deleted_at` BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT "删除时间"', '`state` BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT "状态字段"'];
|
|
71
|
-
}
|
|
72
|
-
return ['"id" INTEGER PRIMARY KEY', '"created_at" INTEGER NOT NULL DEFAULT 0', '"updated_at" INTEGER NOT NULL DEFAULT 0', '"deleted_at" INTEGER NOT NULL DEFAULT 0', '"state" INTEGER NOT NULL DEFAULT 0'];
|
|
95
|
+
return [getSystemColumnDef('id')!, getSystemColumnDef('created_at')!, getSystemColumnDef('updated_at')!, getSystemColumnDef('deleted_at')!, getSystemColumnDef('state')!];
|
|
73
96
|
}
|
|
74
97
|
|
|
75
98
|
/**
|
|
@@ -180,17 +203,50 @@ export async function executeDDLSafely(sql: SQL, stmt: string): Promise<boolean>
|
|
|
180
203
|
}
|
|
181
204
|
|
|
182
205
|
/**
|
|
183
|
-
*
|
|
206
|
+
* 判断是否为兼容的类型变更(宽化型变更,无数据丢失风险)
|
|
184
207
|
*
|
|
185
|
-
*
|
|
186
|
-
*
|
|
208
|
+
* 允许的变更:
|
|
209
|
+
* - MySQL: INT -> BIGINT, TINYINT -> INT/BIGINT, etc.
|
|
210
|
+
* - MySQL: VARCHAR -> TEXT/MEDIUMTEXT
|
|
211
|
+
* - PG: INTEGER -> BIGINT
|
|
212
|
+
* - PG: VARCHAR -> TEXT
|
|
213
|
+
*
|
|
214
|
+
* @param currentType - 当前数据库中的类型
|
|
215
|
+
* @param newType - 目标类型
|
|
187
216
|
* @returns 是否为兼容变更
|
|
188
217
|
*/
|
|
189
|
-
export function
|
|
218
|
+
export function isCompatibleTypeChange(currentType: string, newType: string): boolean {
|
|
190
219
|
const c = String(currentType || '').toLowerCase();
|
|
191
220
|
const n = String(newType || '').toLowerCase();
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
221
|
+
|
|
222
|
+
// 相同类型不算变更
|
|
223
|
+
if (c === n) return false;
|
|
224
|
+
|
|
225
|
+
// 提取基础类型(去掉 unsigned、长度等修饰)
|
|
226
|
+
const extractBaseType = (t: string): string => {
|
|
227
|
+
// 移除 unsigned 和括号内容
|
|
228
|
+
return t
|
|
229
|
+
.replace(/\s*unsigned/gi, '')
|
|
230
|
+
.replace(/\([^)]*\)/g, '')
|
|
231
|
+
.trim();
|
|
232
|
+
};
|
|
233
|
+
|
|
234
|
+
const cBase = extractBaseType(c);
|
|
235
|
+
const nBase = extractBaseType(n);
|
|
236
|
+
|
|
237
|
+
// MySQL/通用 整数类型宽化(小 -> 大)
|
|
238
|
+
const intTypes = ['tinyint', 'smallint', 'mediumint', 'int', 'integer', 'bigint'];
|
|
239
|
+
const cIntIdx = intTypes.indexOf(cBase);
|
|
240
|
+
const nIntIdx = intTypes.indexOf(nBase);
|
|
241
|
+
if (cIntIdx !== -1 && nIntIdx !== -1 && nIntIdx > cIntIdx) {
|
|
242
|
+
return true;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
// 字符串类型宽化
|
|
246
|
+
// MySQL: varchar -> text/mediumtext/longtext
|
|
247
|
+
if (cBase === 'varchar' && (nBase === 'text' || nBase === 'mediumtext' || nBase === 'longtext')) return true;
|
|
248
|
+
// PG: character varying -> text
|
|
249
|
+
if (cBase === 'character varying' && nBase === 'text') return true;
|
|
250
|
+
|
|
195
251
|
return false;
|
|
196
252
|
}
|
package/sync/syncDb/table.ts
CHANGED
|
@@ -9,9 +9,9 @@
|
|
|
9
9
|
|
|
10
10
|
import { snakeCase } from 'es-toolkit/string';
|
|
11
11
|
import { Logger } from '../../lib/logger.js';
|
|
12
|
-
import { isMySQL, isPG, CHANGE_TYPE_LABELS, getTypeMapping } from './constants.js';
|
|
12
|
+
import { isMySQL, isPG, CHANGE_TYPE_LABELS, getTypeMapping, SYSTEM_INDEX_FIELDS } from './constants.js';
|
|
13
13
|
import { logFieldChange, resolveDefaultValue, generateDefaultSql, isStringOrArrayType } from './helpers.js';
|
|
14
|
-
import { generateDDLClause,
|
|
14
|
+
import { generateDDLClause, getSystemColumnDef, isCompatibleTypeChange } from './ddl.js';
|
|
15
15
|
import { getTableColumns, getTableIndexes } from './schema.js';
|
|
16
16
|
import { compareFieldDefinition, applyTablePlan } from './apply.js';
|
|
17
17
|
import type { TablePlan } from '../../types/sync.js';
|
|
@@ -71,11 +71,18 @@ export async function modifyTable(sql: SQL, tableName: string, fields: Record<st
|
|
|
71
71
|
const onlyDefaultChanged = comparison.every((c) => c.type === 'default');
|
|
72
72
|
const defaultChanged = comparison.some((c) => c.type === 'default');
|
|
73
73
|
|
|
74
|
-
//
|
|
74
|
+
// 类型变更检查:只允许兼容的宽化型变更(如 INT -> BIGINT)
|
|
75
75
|
if (hasTypeChange) {
|
|
76
76
|
const typeChange = comparison.find((c) => c.type === 'datatype');
|
|
77
|
-
const
|
|
78
|
-
|
|
77
|
+
const currentType = String(typeChange?.current || '').toLowerCase();
|
|
78
|
+
const typeMapping = getTypeMapping();
|
|
79
|
+
const expectedType = typeMapping[fieldDef.type]?.toLowerCase() || '';
|
|
80
|
+
|
|
81
|
+
if (!isCompatibleTypeChange(currentType, expectedType)) {
|
|
82
|
+
const errorMsg = [`禁止字段类型变更: ${tableName}.${dbFieldName}`, `当前类型: ${typeChange?.current}`, `目标类型: ${typeChange?.expected}`, `说明: 仅允许宽化型变更(如 INT->BIGINT, VARCHAR->TEXT),其他类型变更需要手动处理`].join('\n');
|
|
83
|
+
throw new Error(errorMsg);
|
|
84
|
+
}
|
|
85
|
+
Logger.debug(`[兼容类型变更] ${tableName}.${dbFieldName} ${currentType} -> ${expectedType}`);
|
|
79
86
|
}
|
|
80
87
|
|
|
81
88
|
// 默认值变化处理
|
|
@@ -111,13 +118,6 @@ export async function modifyTable(sql: SQL, tableName: string, fields: Record<st
|
|
|
111
118
|
if (isShrink && !force) skipModify = true;
|
|
112
119
|
}
|
|
113
120
|
|
|
114
|
-
if (hasTypeChange) {
|
|
115
|
-
const typeMapping = getTypeMapping();
|
|
116
|
-
if (isPG() && isPgCompatibleTypeChange(existingColumns[dbFieldName].type, typeMapping[fieldDef.type].toLowerCase())) {
|
|
117
|
-
Logger.debug(`[PG兼容类型变更] ${tableName}.${dbFieldName} ${existingColumns[dbFieldName].type} -> ${typeMapping[fieldDef.type].toLowerCase()} 允许执行`);
|
|
118
|
-
}
|
|
119
|
-
}
|
|
120
|
-
|
|
121
121
|
if (!skipModify) modifyClauses.push(generateDDLClause(fieldKey, fieldDef, false));
|
|
122
122
|
}
|
|
123
123
|
changed = true;
|
|
@@ -130,10 +130,26 @@ export async function modifyTable(sql: SQL, tableName: string, fields: Record<st
|
|
|
130
130
|
}
|
|
131
131
|
}
|
|
132
132
|
|
|
133
|
-
//
|
|
134
|
-
|
|
133
|
+
// 检查并添加缺失的系统字段(created_at, updated_at, deleted_at, state)
|
|
134
|
+
// 注意:id 是主键,不会缺失;这里只处理可能缺失的其他系统字段
|
|
135
|
+
const systemFieldNames = ['created_at', 'updated_at', 'deleted_at', 'state'];
|
|
136
|
+
for (const sysFieldName of systemFieldNames) {
|
|
137
|
+
if (!existingColumns[sysFieldName]) {
|
|
138
|
+
const colDef = getSystemColumnDef(sysFieldName);
|
|
139
|
+
if (colDef) {
|
|
140
|
+
Logger.debug(` + 新增系统字段 ${sysFieldName}`);
|
|
141
|
+
addClauses.push(`ADD COLUMN ${colDef}`);
|
|
142
|
+
changed = true;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// 检查系统字段索引(字段存在或即将被添加时才创建索引)
|
|
148
|
+
for (const sysField of SYSTEM_INDEX_FIELDS) {
|
|
135
149
|
const idxName = `idx_${sysField}`;
|
|
136
|
-
|
|
150
|
+
// 字段已存在或刚添加到 addClauses 中
|
|
151
|
+
const fieldWillExist = existingColumns[sysField] || systemFieldNames.includes(sysField);
|
|
152
|
+
if (fieldWillExist && !existingIndexes[idxName]) {
|
|
137
153
|
indexActions.push({ action: 'create', indexName: idxName, fieldName: sysField });
|
|
138
154
|
changed = true;
|
|
139
155
|
}
|
package/tests/syncDb-ddl.test.ts
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
* - buildSystemColumnDefs
|
|
7
7
|
* - buildBusinessColumnDefs
|
|
8
8
|
* - generateDDLClause
|
|
9
|
-
* -
|
|
9
|
+
* - isCompatibleTypeChange
|
|
10
10
|
*/
|
|
11
11
|
|
|
12
12
|
import { describe, test, expect, beforeAll } from 'bun:test';
|
|
@@ -19,7 +19,7 @@ let buildIndexSQL: any;
|
|
|
19
19
|
let buildSystemColumnDefs: any;
|
|
20
20
|
let buildBusinessColumnDefs: any;
|
|
21
21
|
let generateDDLClause: any;
|
|
22
|
-
let
|
|
22
|
+
let isCompatibleTypeChange: any;
|
|
23
23
|
|
|
24
24
|
beforeAll(async () => {
|
|
25
25
|
const ddl = await import('../sync/syncDb/ddl.js');
|
|
@@ -27,7 +27,7 @@ beforeAll(async () => {
|
|
|
27
27
|
buildSystemColumnDefs = ddl.buildSystemColumnDefs;
|
|
28
28
|
buildBusinessColumnDefs = ddl.buildBusinessColumnDefs;
|
|
29
29
|
generateDDLClause = ddl.generateDDLClause;
|
|
30
|
-
|
|
30
|
+
isCompatibleTypeChange = ddl.isCompatibleTypeChange;
|
|
31
31
|
});
|
|
32
32
|
|
|
33
33
|
describe('buildIndexSQL (MySQL)', () => {
|
|
@@ -186,21 +186,45 @@ describe('generateDDLClause (MySQL)', () => {
|
|
|
186
186
|
});
|
|
187
187
|
});
|
|
188
188
|
|
|
189
|
-
describe('
|
|
189
|
+
describe('isCompatibleTypeChange', () => {
|
|
190
190
|
test('varchar -> text 是兼容变更', () => {
|
|
191
|
-
expect(
|
|
191
|
+
expect(isCompatibleTypeChange('character varying', 'text')).toBe(true);
|
|
192
|
+
expect(isCompatibleTypeChange('varchar(100)', 'text')).toBe(true);
|
|
193
|
+
expect(isCompatibleTypeChange('varchar(100)', 'mediumtext')).toBe(true);
|
|
192
194
|
});
|
|
193
195
|
|
|
194
196
|
test('text -> varchar 不是兼容变更', () => {
|
|
195
|
-
expect(
|
|
197
|
+
expect(isCompatibleTypeChange('text', 'character varying')).toBe(false);
|
|
198
|
+
expect(isCompatibleTypeChange('text', 'varchar(100)')).toBe(false);
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
test('int -> bigint 是兼容变更', () => {
|
|
202
|
+
expect(isCompatibleTypeChange('int', 'bigint')).toBe(true);
|
|
203
|
+
expect(isCompatibleTypeChange('int unsigned', 'bigint unsigned')).toBe(true);
|
|
204
|
+
expect(isCompatibleTypeChange('tinyint', 'int')).toBe(true);
|
|
205
|
+
expect(isCompatibleTypeChange('tinyint', 'bigint')).toBe(true);
|
|
206
|
+
expect(isCompatibleTypeChange('smallint', 'int')).toBe(true);
|
|
207
|
+
expect(isCompatibleTypeChange('mediumint', 'bigint')).toBe(true);
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
test('bigint -> int 不是兼容变更(收缩)', () => {
|
|
211
|
+
expect(isCompatibleTypeChange('bigint', 'int')).toBe(false);
|
|
212
|
+
expect(isCompatibleTypeChange('int', 'tinyint')).toBe(false);
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
test('PG integer -> bigint 是兼容变更', () => {
|
|
216
|
+
expect(isCompatibleTypeChange('integer', 'bigint')).toBe(true);
|
|
217
|
+
expect(isCompatibleTypeChange('smallint', 'integer')).toBe(true);
|
|
218
|
+
expect(isCompatibleTypeChange('smallint', 'bigint')).toBe(true);
|
|
196
219
|
});
|
|
197
220
|
|
|
198
221
|
test('相同类型不是变更', () => {
|
|
199
|
-
expect(
|
|
222
|
+
expect(isCompatibleTypeChange('text', 'text')).toBe(false);
|
|
223
|
+
expect(isCompatibleTypeChange('bigint', 'bigint')).toBe(false);
|
|
200
224
|
});
|
|
201
225
|
|
|
202
226
|
test('空值处理', () => {
|
|
203
|
-
expect(
|
|
204
|
-
expect(
|
|
227
|
+
expect(isCompatibleTypeChange(null, 'text')).toBe(false);
|
|
228
|
+
expect(isCompatibleTypeChange('text', null)).toBe(false);
|
|
205
229
|
});
|
|
206
230
|
});
|