fdb2 1.0.37 → 1.0.38

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.
Files changed (106) hide show
  1. package/README.md +41 -0
  2. package/dist/public/explorer.css +517 -478
  3. package/dist/public/explorer.js +1114 -872
  4. package/dist/public/index.css +87 -27
  5. package/dist/public/index.js +97 -49
  6. package/dist/public/layout.js +2 -2
  7. package/dist/public/vue.js +1 -1
  8. package/dist/server/core/schema/__tests__/canonical.test.ts +195 -0
  9. package/dist/server/core/schema/__tests__/ddl-emitter.test.ts +456 -0
  10. package/dist/server/core/schema/__tests__/diff.test.ts +242 -0
  11. package/dist/server/core/schema/__tests__/roundtrip.property.test.ts +403 -0
  12. package/dist/server/core/schema/canonical.d.ts +50 -0
  13. package/dist/server/core/schema/canonical.d.ts.map +1 -0
  14. package/dist/server/core/schema/canonical.js +212 -0
  15. package/dist/server/core/schema/canonical.js.map +1 -0
  16. package/dist/server/core/schema/canonical.ts +223 -0
  17. package/dist/server/core/schema/ddl-emitter.d.ts +68 -0
  18. package/dist/server/core/schema/ddl-emitter.d.ts.map +1 -0
  19. package/dist/server/core/schema/ddl-emitter.js +813 -0
  20. package/dist/server/core/schema/ddl-emitter.js.map +1 -0
  21. package/dist/server/core/schema/ddl-emitter.ts +925 -0
  22. package/dist/server/core/schema/diff.d.ts +16 -0
  23. package/dist/server/core/schema/diff.d.ts.map +1 -0
  24. package/dist/server/core/schema/diff.js +115 -0
  25. package/dist/server/core/schema/diff.js.map +1 -0
  26. package/dist/server/core/schema/diff.ts +134 -0
  27. package/dist/server/core/schema/type-defaults.d.ts +27 -0
  28. package/dist/server/core/schema/type-defaults.d.ts.map +1 -0
  29. package/dist/server/core/schema/type-defaults.js +61 -0
  30. package/dist/server/core/schema/type-defaults.js.map +1 -0
  31. package/dist/server/core/schema/type-defaults.ts +79 -0
  32. package/dist/server/index.d.ts +1 -1
  33. package/dist/server/index.d.ts.map +1 -1
  34. package/dist/server/index.js +347 -124
  35. package/dist/server/index.js.map +1 -1
  36. package/dist/server/index.ts +336 -161
  37. package/dist/server/model/ddl-spec.d.ts +163 -0
  38. package/dist/server/model/ddl-spec.d.ts.map +1 -0
  39. package/dist/server/model/ddl-spec.js +32 -0
  40. package/dist/server/model/ddl-spec.js.map +1 -0
  41. package/dist/server/model/ddl-spec.ts +175 -0
  42. package/dist/server/service/connection.service.d.ts +20 -0
  43. package/dist/server/service/connection.service.d.ts.map +1 -1
  44. package/dist/server/service/connection.service.js +99 -25
  45. package/dist/server/service/connection.service.js.map +1 -1
  46. package/dist/server/service/connection.service.ts +104 -25
  47. package/dist/server/service/database/base.service.d.ts +0 -4
  48. package/dist/server/service/database/base.service.d.ts.map +1 -1
  49. package/dist/server/service/database/base.service.js.map +1 -1
  50. package/dist/server/service/database/base.service.ts +0 -5
  51. package/dist/server/service/database/clickhouse.service.d.ts +0 -5
  52. package/dist/server/service/database/clickhouse.service.d.ts.map +1 -1
  53. package/dist/server/service/database/clickhouse.service.js +0 -49
  54. package/dist/server/service/database/clickhouse.service.js.map +1 -1
  55. package/dist/server/service/database/clickhouse.service.ts +0 -52
  56. package/dist/server/service/database/cockroachdb.service.d.ts +0 -5
  57. package/dist/server/service/database/cockroachdb.service.d.ts.map +1 -1
  58. package/dist/server/service/database/cockroachdb.service.js +11 -142
  59. package/dist/server/service/database/cockroachdb.service.js.map +1 -1
  60. package/dist/server/service/database/cockroachdb.service.ts +11 -161
  61. package/dist/server/service/database/database.service.d.ts +55 -2
  62. package/dist/server/service/database/database.service.d.ts.map +1 -1
  63. package/dist/server/service/database/database.service.js +246 -4
  64. package/dist/server/service/database/database.service.js.map +1 -1
  65. package/dist/server/service/database/database.service.ts +295 -7
  66. package/dist/server/service/database/mongodb.service.d.ts +0 -6
  67. package/dist/server/service/database/mongodb.service.d.ts.map +1 -1
  68. package/dist/server/service/database/mongodb.service.js +0 -8
  69. package/dist/server/service/database/mongodb.service.js.map +1 -1
  70. package/dist/server/service/database/mongodb.service.ts +0 -8
  71. package/dist/server/service/database/mssql.service.d.ts +8 -4
  72. package/dist/server/service/database/mssql.service.d.ts.map +1 -1
  73. package/dist/server/service/database/mssql.service.js +27 -101
  74. package/dist/server/service/database/mssql.service.js.map +1 -1
  75. package/dist/server/service/database/mssql.service.ts +28 -122
  76. package/dist/server/service/database/mysql.service.d.ts +0 -4
  77. package/dist/server/service/database/mysql.service.d.ts.map +1 -1
  78. package/dist/server/service/database/mysql.service.js +11 -137
  79. package/dist/server/service/database/mysql.service.js.map +1 -1
  80. package/dist/server/service/database/mysql.service.ts +11 -173
  81. package/dist/server/service/database/oracle.service.d.ts +0 -4
  82. package/dist/server/service/database/oracle.service.d.ts.map +1 -1
  83. package/dist/server/service/database/oracle.service.js +11 -104
  84. package/dist/server/service/database/oracle.service.js.map +1 -1
  85. package/dist/server/service/database/oracle.service.ts +11 -127
  86. package/dist/server/service/database/postgres.service.d.ts +0 -4
  87. package/dist/server/service/database/postgres.service.d.ts.map +1 -1
  88. package/dist/server/service/database/postgres.service.js +11 -112
  89. package/dist/server/service/database/postgres.service.js.map +1 -1
  90. package/dist/server/service/database/postgres.service.ts +11 -133
  91. package/dist/server/service/database/sap.service.d.ts +0 -4
  92. package/dist/server/service/database/sap.service.d.ts.map +1 -1
  93. package/dist/server/service/database/sap.service.js +11 -137
  94. package/dist/server/service/database/sap.service.js.map +1 -1
  95. package/dist/server/service/database/sap.service.ts +11 -158
  96. package/dist/server/service/database/sql-type.helper.d.ts.map +1 -1
  97. package/dist/server/service/database/sql-type.helper.js +9 -47
  98. package/dist/server/service/database/sql-type.helper.js.map +1 -1
  99. package/dist/server/service/database/sql-type.helper.ts +11 -53
  100. package/dist/server/service/database/sqlite.service.d.ts +0 -5
  101. package/dist/server/service/database/sqlite.service.d.ts.map +1 -1
  102. package/dist/server/service/database/sqlite.service.js +11 -147
  103. package/dist/server/service/database/sqlite.service.js.map +1 -1
  104. package/dist/server/service/database/sqlite.service.ts +11 -177
  105. package/package.json +1 -1
  106. package/server.js +1 -1
@@ -0,0 +1,195 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import {
3
+ canonicalizeTableEntity,
4
+ canonicalizeTableSpec,
5
+ classifyDefault,
6
+ canonicalizeType,
7
+ diffColumnFields,
8
+ normalizeNumericLiteral
9
+ } from '../canonical';
10
+ import type { TableSpec } from '../../../model/ddl-spec';
11
+
12
+ describe('classifyDefault:三态 + 表达式分类(消灭 || 兜底吃掉假值)', () => {
13
+ it('0 / false / 空串是真实默认值,不得回落为 none', () => {
14
+ expect(classifyDefault(0)).toEqual({ kind: 'literal', text: '0' });
15
+ expect(classifyDefault('0')).toEqual({ kind: 'literal', text: '0' });
16
+ expect(classifyDefault(false)).toEqual({ kind: 'literal', text: 'false' });
17
+ expect(classifyDefault('')).toEqual({ kind: 'literal', text: '' }); // MySQL DEFAULT ''
18
+ });
19
+
20
+ it('无默认值 / DEFAULT NULL / 显式标记 三者可区分', () => {
21
+ expect(classifyDefault(undefined)).toEqual({ kind: 'none', text: '' });
22
+ expect(classifyDefault(null)).toEqual({ kind: 'none', text: '' });
23
+ expect(classifyDefault('NULL')).toEqual({ kind: 'null', text: '' });
24
+ expect(classifyDefault('null')).toEqual({ kind: 'null', text: '' });
25
+ expect(classifyDefault('x', false)).toEqual({ kind: 'none', text: '' }); // hasDefault=false 优先
26
+ expect(classifyDefault(undefined, true)).toEqual({ kind: 'literal', text: '' }); // DEFAULT ''(显式)
27
+ });
28
+
29
+ it('引号字面量与 PG 类型标注', () => {
30
+ expect(classifyDefault("'abc'")).toEqual({ kind: 'literal', text: 'abc' });
31
+ expect(classifyDefault("'0'::character varying")).toEqual({
32
+ kind: 'expression',
33
+ text: "'0'::CHARACTER VARYING"
34
+ });
35
+ });
36
+
37
+ it('表达式默认值:关键字与函数调用', () => {
38
+ expect(classifyDefault('CURRENT_TIMESTAMP')).toEqual({
39
+ kind: 'expression',
40
+ text: 'CURRENT_TIMESTAMP'
41
+ });
42
+ expect(classifyDefault('now()')).toEqual({ kind: 'expression', text: 'NOW()' });
43
+ expect(classifyDefault("nextval('s')")).toEqual({ kind: 'expression', text: "NEXTVAL('S')" });
44
+ });
45
+
46
+ it('数值书写差异归一:007→7、0.50→0.5、-0→0', () => {
47
+ expect(normalizeNumericLiteral('007')).toBe('7');
48
+ expect(normalizeNumericLiteral('0.50')).toBe('0.5');
49
+ expect(normalizeNumericLiteral('-0')).toBe('0');
50
+ expect(classifyDefault('0.50')).toEqual({ kind: 'literal', text: '0.5' });
51
+ expect(diffColumnBetween('0.5', '0.50')).toBe(false); // 同一默认值
52
+ });
53
+ });
54
+
55
+ describe('canonicalizeType:全串与拆分字段两种表示等价', () => {
56
+ it("实体 'varchar(255)' 与表单 {type:'VARCHAR', length:'255'} 等价", () => {
57
+ const a = canonicalizeType({ type: 'varchar(255)', length: 255 });
58
+ const b = canonicalizeType({ type: 'VARCHAR', length: '255' });
59
+ expect(a).toEqual(b);
60
+ expect(a.baseType).toBe('VARCHAR');
61
+ expect(a.length).toBe(255);
62
+ });
63
+
64
+ it('DECIMAL(10,0):scale=0 必须保留(假值不丢弃)', () => {
65
+ const a = canonicalizeType({ type: 'decimal(10,0)' });
66
+ const b = canonicalizeType({ type: 'DECIMAL', precision: '10', scale: '0' });
67
+ expect(a).toEqual(b);
68
+ expect(a.precision).toBe(10);
69
+ expect(a.scale).toBe(0);
70
+ });
71
+
72
+ it("单参数精度类型:'decimal(10)' 解析为 precision", () => {
73
+ expect(canonicalizeType({ type: 'decimal(10)' })).toEqual({
74
+ baseType: 'DECIMAL',
75
+ precision: 10,
76
+ scale: undefined
77
+ });
78
+ });
79
+
80
+ it("非数字参数(ENUM)整体原样保留", () => {
81
+ const a = canonicalizeType({ type: "enum('a','b')" });
82
+ const b = canonicalizeType({ type: 'ENUM', customType: "enum('a','b')" });
83
+ expect(a).toEqual(b);
84
+ expect(a.baseType).toBe("enum('a','b')");
85
+ });
86
+
87
+ it('大小写与空格归一', () => {
88
+ expect(canonicalizeType({ type: ' varchar ' }).baseType).toBe('VARCHAR');
89
+ });
90
+ });
91
+
92
+ describe('canonicalizeTable*:跨表示等价与自增 clamp', () => {
93
+ const entity = {
94
+ name: 'users',
95
+ comment: '用户',
96
+ columns: [
97
+ { name: 'id', type: 'int', nullable: false, isPrimary: true, isAutoIncrement: true, length: 11 },
98
+ { name: 'name', type: 'varchar(64)', nullable: true, defaultValue: '', comment: ' 名字 ' },
99
+ { name: 'amount', type: 'decimal(10,0)', nullable: true, defaultValue: 0 }
100
+ ]
101
+ };
102
+
103
+ /** Phase E 前端应做的回显:canonical → TableSpec */
104
+ function echoSpec(): TableSpec {
105
+ const t = canonicalizeTableEntity(entity);
106
+ const mapDefault = (i: number): Pick<TableSpec['columns'][number], 'hasDefault' | 'defaultValue'> => {
107
+ const c = t.columns[i]!;
108
+ switch (c.defaultKind) {
109
+ case 'none': return { hasDefault: false };
110
+ case 'null': return { hasDefault: true, defaultValue: 'NULL' };
111
+ case 'literal': return { hasDefault: true, defaultValue: c.defaultText };
112
+ case 'expression': return { hasDefault: true, defaultValue: c.defaultText };
113
+ }
114
+ };
115
+ const mk = (i: number, extra: Record<string, unknown> = {}) => {
116
+ const c = t.columns[i]!;
117
+ const full = c.baseType.includes('(');
118
+ return {
119
+ name: c.name,
120
+ ...(full ? { type: c.baseType, customType: c.baseType } : { type: c.baseType, length: c.length, precision: c.precision, scale: c.scale }),
121
+ nullable: c.nullable,
122
+ ...mapDefault(i),
123
+ comment: c.comment,
124
+ ...extra
125
+ };
126
+ };
127
+ return {
128
+ name: t.name,
129
+ comment: t.comment,
130
+ columns: [
131
+ mk(0, { autoIncrement: true }),
132
+ mk(1),
133
+ mk(2)
134
+ ],
135
+ primaryKey: t.primaryKey
136
+ };
137
+ }
138
+
139
+ it('「实体 → canonical → 表单回显」全程零 diff(往返幂等的基石)', () => {
140
+ const before = canonicalizeTableEntity(entity);
141
+ const after = canonicalizeTableSpec(echoSpec());
142
+ expect(diffColumnFields(before.columns[0]!, after.columns[0]!)).toEqual([]);
143
+ expect(diffColumnFields(before.columns[1]!, after.columns[1]!)).toEqual([]);
144
+ expect(diffColumnFields(before.columns[2]!, after.columns[2]!)).toEqual([]);
145
+ });
146
+
147
+ it('rawAutoIncrement 不被 clamp 吞掉(供"加主键时恢复自增")', () => {
148
+ const t = canonicalizeTableEntity({
149
+ name: 't',
150
+ columns: [{ name: 'id', type: 'int', nullable: false, isAutoIncrement: true, isPrimary: false }]
151
+ });
152
+ expect(t.columns[0]!.rawAutoIncrement).toBe(true);
153
+ expect(t.columns[0]!.effectiveAutoIncrement).toBe(false); // 无主键 → 不生效
154
+ });
155
+
156
+ it('clamp 是两侧同一规则:非主键列勾自增不产生列级 diff', () => {
157
+ const before = canonicalizeTableEntity({
158
+ name: 't',
159
+ columns: [{ name: 'id', type: 'int', nullable: false, isPrimary: false, isAutoIncrement: false }]
160
+ });
161
+ const after = canonicalizeTableSpec({
162
+ name: 't',
163
+ primaryKey: [],
164
+ columns: [{ name: 'id', type: 'INT', nullable: false, hasDefault: false, autoIncrement: true }] // 勾了自增但非主键
165
+ });
166
+ expect(diffColumnFields(before.columns[0]!, after.columns[0]!)).toEqual([]);
167
+ });
168
+
169
+ it('注释 trim:两侧空格差异不算改动', () => {
170
+ const before = canonicalizeTableEntity({
171
+ name: 't',
172
+ columns: [{ name: 'a', type: 'int', nullable: true, comment: 'x' }]
173
+ });
174
+ const after = canonicalizeTableSpec({
175
+ name: 't',
176
+ primaryKey: [],
177
+ columns: [{ name: 'a', type: 'INT', nullable: true, hasDefault: false, comment: ' x ' }]
178
+ });
179
+ expect(diffColumnFields(before.columns[0]!, after.columns[0]!)).toEqual([]);
180
+ });
181
+ });
182
+
183
+ function diffColumnBetween(aRaw: string, bRaw: string): boolean {
184
+ const col = (defaultValue: unknown) => ({
185
+ name: 'x', type: 'int', nullable: true, defaultValue, isPrimary: false
186
+ });
187
+ const before = canonicalizeTableEntity({ name: 't', columns: [col(aRaw)] }).columns[0]!;
188
+ const specCol = (defaultValue: string) => ({
189
+ name: 'x', type: 'INT', nullable: true, hasDefault: true, defaultValue
190
+ });
191
+ const after = canonicalizeTableSpec({
192
+ name: 't', primaryKey: [], columns: [specCol(bRaw)]
193
+ }).columns[0]!;
194
+ return diffColumnFields(before, after).includes('default');
195
+ }
@@ -0,0 +1,456 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { canonicalizeTableEntity, canonicalizeTableSpec } from '../canonical';
3
+ import { ddlCapabilities, emitAlterDdl, emitCreateDdl, tableNameEquals } from '../ddl-emitter';
4
+ import type { TableSpec } from '../../../model/ddl-spec';
5
+
6
+ const baseEntity = () => ({
7
+ name: 'users',
8
+ comment: '',
9
+ columns: [
10
+ { name: 'id', type: 'int', nullable: false, isPrimary: true, isAutoIncrement: true },
11
+ { name: 'name', type: 'varchar(64)', nullable: false },
12
+ { name: 'age', type: 'int', nullable: true, defaultValue: '0' }
13
+ ]
14
+ });
15
+
16
+ function mirrorSpec(entity: ReturnType<typeof baseEntity>, overrides?: Partial<TableSpec>): TableSpec {
17
+ return {
18
+ name: entity.name,
19
+ comment: entity.comment,
20
+ columns: entity.columns.map((c) => ({
21
+ name: c.name,
22
+ type: c.type.replace(/\(\d+(,\d+)?\)/, ''),
23
+ length: c.type.includes('varchar') ? '64' : undefined,
24
+ nullable: c.nullable,
25
+ hasDefault: c.defaultValue !== undefined,
26
+ defaultValue: c.defaultValue !== undefined ? String(c.defaultValue) : undefined,
27
+ autoIncrement: c.isAutoIncrement
28
+ })),
29
+ primaryKey: entity.columns.filter((c) => c.isPrimary).map((c) => c.name),
30
+ ...overrides
31
+ };
32
+ }
33
+
34
+ describe('纯函数不变式', () => {
35
+ it.each(['mysql', 'postgres', 'sqlite', 'oracle', 'mssql'] as const)(
36
+ '%s:未修改的表不产生任何语句',
37
+ (kind) => {
38
+ const entity = baseEntity();
39
+ const out = emitAlterDdl(kind, canonicalizeTableEntity(entity), canonicalizeTableSpec(mirrorSpec(entity)));
40
+ expect(out).toEqual([]);
41
+ }
42
+ );
43
+
44
+ it('mongodb:unsupported 方言返回空数组', () => {
45
+ const entity = baseEntity();
46
+ const spec = mirrorSpec(entity, {
47
+ columns: [
48
+ { name: 'id', type: 'int', nullable: false, hasDefault: false },
49
+ { name: 'x', type: 'text', nullable: true, hasDefault: false }
50
+ ]
51
+ });
52
+ expect(emitAlterDdl('mongodb', canonicalizeTableEntity(entity), canonicalizeTableSpec(spec))).toEqual([]);
53
+ });
54
+ });
55
+
56
+ describe('MySQL', () => {
57
+ it('改列类型:MODIFY COLUMN 重建整列定义', () => {
58
+ const entity = baseEntity();
59
+ const spec = mirrorSpec(entity, {
60
+ columns: [
61
+ { name: 'id', type: 'int', nullable: false, hasDefault: false, autoIncrement: true },
62
+ { name: 'name', type: 'varchar', length: '128', nullable: false, hasDefault: false },
63
+ { name: 'age', type: 'int', nullable: true, hasDefault: true, defaultValue: '0' }
64
+ ],
65
+ primaryKey: ['id']
66
+ });
67
+ const out = emitAlterDdl('mysql', canonicalizeTableEntity(entity), canonicalizeTableSpec(spec));
68
+ expect(out).toEqual([
69
+ "ALTER TABLE `users` MODIFY COLUMN `name` VARCHAR(128) NOT NULL"
70
+ ]);
71
+ });
72
+
73
+ it('改名且改定义:合并为一条 CHANGE COLUMN(保留数据与列位置)', () => {
74
+ const entity = baseEntity();
75
+ const spec = mirrorSpec(entity, {
76
+ columns: [
77
+ { name: 'id', type: 'int', nullable: false, hasDefault: false, autoIncrement: true },
78
+ { name: 'name2', type: 'varchar', length: '128', nullable: true, hasDefault: false },
79
+ { name: 'age', type: 'int', nullable: true, hasDefault: true, defaultValue: '0' }
80
+ ],
81
+ primaryKey: ['id']
82
+ });
83
+ const out = emitAlterDdl('mysql', canonicalizeTableEntity(entity), canonicalizeTableSpec(spec));
84
+ expect(out).toEqual([
85
+ "ALTER TABLE `users` CHANGE COLUMN `name` `name2` VARCHAR(128)"
86
+ ]);
87
+ });
88
+
89
+ it('默认值三态:DEFAULT 0 不丢、DEFAULT \'\' 可表达、无默认不出子句', () => {
90
+ const entity = {
91
+ name: 't',
92
+ columns: [
93
+ { name: 'a', type: 'int', nullable: true },
94
+ { name: 'b', type: 'int', nullable: true, defaultValue: '5' },
95
+ { name: 'c', type: 'varchar(10)', nullable: true }
96
+ ]
97
+ };
98
+ const spec: TableSpec = {
99
+ name: 't',
100
+ columns: [
101
+ { name: 'a', type: 'int', nullable: true, hasDefault: true, defaultValue: '0' },
102
+ { name: 'b', type: 'int', nullable: true, hasDefault: false }, // 撤销默认值 → none
103
+ { name: 'c', type: 'varchar', length: '10', nullable: true, hasDefault: true, defaultValue: '' }
104
+ ],
105
+ primaryKey: []
106
+ };
107
+ const out = emitAlterDdl('mysql', canonicalizeTableEntity(entity), canonicalizeTableSpec(spec));
108
+ expect(out).toContain("ALTER TABLE `t` MODIFY COLUMN `a` INT DEFAULT 0");
109
+ expect(out).toContain("ALTER TABLE `t` MODIFY COLUMN `b` INT"); // 无 DEFAULT 子句(撤销默认值)
110
+ expect(out).toContain("ALTER TABLE `t` MODIFY COLUMN `c` VARCHAR(10) DEFAULT ''");
111
+ expect(out.join('\n')).not.toContain('DEFAULT NULL');
112
+ });
113
+
114
+ it('主键 A→B:去自增先于 DROP PRIMARY KEY,ADD PRIMARY KEY 先于加自增的 MODIFY', () => {
115
+ const entity = {
116
+ name: 't',
117
+ columns: [
118
+ { name: 'id', type: 'bigint', nullable: false, isPrimary: true, isAutoIncrement: true },
119
+ { name: 'uid', type: 'bigint', nullable: true }
120
+ ]
121
+ };
122
+ // 主键 id → uid,且 uid 要自增(effectiveAutoIncrement false→true)、id 去自增(true→false)
123
+ const spec: TableSpec = {
124
+ name: 't',
125
+ columns: [
126
+ { name: 'id', type: 'bigint', nullable: false, hasDefault: false },
127
+ { name: 'uid', type: 'bigint', nullable: false, hasDefault: false, autoIncrement: true }
128
+ ],
129
+ primaryKey: ['uid']
130
+ };
131
+ const out = emitAlterDdl('mysql', canonicalizeTableEntity(entity), canonicalizeTableSpec(spec));
132
+ expect(out).toEqual([
133
+ 'ALTER TABLE `t` MODIFY COLUMN `id` BIGINT NOT NULL', // 1) 先去 id 自增(去自增语句已含 id 的全部变化)
134
+ // 2) DROP 与 ADD 必须在同一条语句内:拆开会让表出现「无主键 + 仍有自增列」中间态(ER_WRONG_AUTO_KEY)
135
+ 'ALTER TABLE `t` DROP PRIMARY KEY, ADD PRIMARY KEY (`uid`)',
136
+ 'ALTER TABLE `t` MODIFY COLUMN `uid` BIGINT NOT NULL AUTO_INCREMENT' // 3) 主键就位后再加自增
137
+ ]);
138
+ });
139
+
140
+ it('主键扩为复合(自增列仍在主键内):同样合并为单条 DROP+ADD,且不产生去自增语句', () => {
141
+ const entity = {
142
+ name: 't',
143
+ columns: [
144
+ { name: 'id', type: 'bigint', nullable: false, isPrimary: true, isAutoIncrement: true },
145
+ { name: 'uid', type: 'bigint', nullable: false }
146
+ ]
147
+ };
148
+ const spec: TableSpec = {
149
+ name: 't',
150
+ columns: [
151
+ { name: 'id', type: 'bigint', nullable: false, hasDefault: false, autoIncrement: true },
152
+ { name: 'uid', type: 'bigint', nullable: false, hasDefault: false }
153
+ ],
154
+ primaryKey: ['id', 'uid'] // id 仍是主键且仍自增
155
+ };
156
+ const out = emitAlterDdl('mysql', canonicalizeTableEntity(entity), canonicalizeTableSpec(spec));
157
+ // 关键:不能出现单独的 DROP PRIMARY KEY(执行时 id 还是自增列 → 1075)
158
+ expect(out.some((s) => s === 'ALTER TABLE `t` DROP PRIMARY KEY')).toBe(false);
159
+ expect(out).toContain('ALTER TABLE `t` DROP PRIMARY KEY, ADD PRIMARY KEY (`id`, `uid`)');
160
+ });
161
+
162
+ it('新增自增列并设为主键:ADD COLUMN 不带 AUTO_INCREMENT,主键就位后再 MODIFY 加上', () => {
163
+ const entity = {
164
+ name: 't',
165
+ columns: [{ name: 'name', type: 'varchar(64)', nullable: true }]
166
+ };
167
+ const spec: TableSpec = {
168
+ name: 't',
169
+ columns: [
170
+ { name: 'id', type: 'bigint', nullable: false, hasDefault: false, autoIncrement: true },
171
+ { name: 'name', type: 'varchar', length: '64', nullable: true, hasDefault: false }
172
+ ],
173
+ primaryKey: ['id']
174
+ };
175
+ const out = emitAlterDdl('mysql', canonicalizeTableEntity(entity), canonicalizeTableSpec(spec));
176
+ expect(out).toEqual([
177
+ // 1) 先建列(不带 AUTO_INCREMENT——此刻还没有键)
178
+ 'ALTER TABLE `t` ADD COLUMN `id` BIGINT NOT NULL',
179
+ // 2) 建主键(原表无主键,故只有 ADD)
180
+ 'ALTER TABLE `t` ADD PRIMARY KEY (`id`)',
181
+ // 3) 再补 AUTO_INCREMENT
182
+ 'ALTER TABLE `t` MODIFY COLUMN `id` BIGINT NOT NULL AUTO_INCREMENT'
183
+ ]);
184
+ });
185
+
186
+ it('取消主键(新主键为空):只 DROP,绝不生成 ADD PRIMARY KEY ()', () => {
187
+ const entity = baseEntity();
188
+ const spec = mirrorSpec(entity, { primaryKey: [] });
189
+ const out = emitAlterDdl('mysql', canonicalizeTableEntity(entity), canonicalizeTableSpec(spec));
190
+ expect(out).toContain('ALTER TABLE `users` DROP PRIMARY KEY');
191
+ expect(out.some((s) => s.includes('ADD PRIMARY KEY'))).toBe(false);
192
+ });
193
+ });
194
+
195
+ describe('PostgreSQL', () => {
196
+ it('按 fields 精确输出:只改 nullable 时只有一条 SET/DROP NOT NULL', () => {
197
+ const entity = baseEntity();
198
+ const spec = mirrorSpec(entity, {
199
+ columns: [
200
+ { name: 'id', type: 'int', nullable: false, hasDefault: false, autoIncrement: true },
201
+ { name: 'name', type: 'varchar', length: '64', nullable: true, hasDefault: false },
202
+ { name: 'age', type: 'int', nullable: true, hasDefault: true, defaultValue: '0' }
203
+ ],
204
+ primaryKey: ['id']
205
+ });
206
+ const out = emitAlterDdl('postgres', canonicalizeTableEntity(entity), canonicalizeTableSpec(spec));
207
+ expect(out).toEqual(['ALTER TABLE "users" ALTER COLUMN "name" DROP NOT NULL']);
208
+ });
209
+
210
+ it('改默认值:SET DEFAULT / DROP DEFAULT 精确区分', () => {
211
+ const entity = {
212
+ name: 't',
213
+ columns: [
214
+ { name: 'a', type: 'int', nullable: true, defaultValue: '0' },
215
+ { name: 'b', type: 'int', nullable: true, defaultValue: '1' }
216
+ ]
217
+ };
218
+ const spec: TableSpec = {
219
+ name: 't',
220
+ columns: [
221
+ { name: 'a', type: 'int', nullable: true, hasDefault: false }, // 撤销默认值
222
+ { name: 'b', type: 'int', nullable: true, hasDefault: true, defaultValue: '2' } // 换默认值
223
+ ],
224
+ primaryKey: []
225
+ };
226
+ const out = emitAlterDdl('postgres', canonicalizeTableEntity(entity), canonicalizeTableSpec(spec));
227
+ expect(out).toEqual([
228
+ 'ALTER TABLE "t" ALTER COLUMN "a" DROP DEFAULT',
229
+ 'ALTER TABLE "t" ALTER COLUMN "b" SET DEFAULT 2'
230
+ ]);
231
+ });
232
+
233
+ it('改名后定义也变:RENAME COLUMN 仍最先执行', () => {
234
+ const entity = {
235
+ name: 't',
236
+ columns: [{ name: 'nm', type: 'varchar(10)', nullable: false }]
237
+ };
238
+ const spec: TableSpec = {
239
+ name: 't',
240
+ columns: [{ name: 'nm2', type: 'varchar', length: '20', nullable: false, hasDefault: false }],
241
+ primaryKey: []
242
+ };
243
+ const out = emitAlterDdl('postgres', canonicalizeTableEntity(entity), canonicalizeTableSpec(spec));
244
+ expect(out[0]).toBe('ALTER TABLE "t" RENAME COLUMN "nm" TO "nm2"');
245
+ expect(out).toContain('ALTER TABLE "t" ALTER COLUMN "nm2" SET DATA TYPE VARCHAR(20)');
246
+ });
247
+ });
248
+
249
+ describe('SQLite 重建表', () => {
250
+ it('输出确定性序列:固定临时表名、改名列从旧名取值、新列用默认值占位', () => {
251
+ const entity = {
252
+ name: 't',
253
+ columns: [
254
+ { name: 'id', type: 'integer', nullable: false, isPrimary: true, isAutoIncrement: true },
255
+ { name: 'nm', type: 'text', nullable: false },
256
+ { name: 'gone', type: 'text', nullable: true }
257
+ ]
258
+ };
259
+ const spec: TableSpec = {
260
+ name: 't',
261
+ columns: [
262
+ { name: 'id', type: 'integer', nullable: false, hasDefault: false, autoIncrement: true },
263
+ { name: 'nm2', type: 'text', nullable: true, hasDefault: false }, // nm 改名并放开非空
264
+ { name: 'created', type: 'text', nullable: true, hasDefault: true, defaultValue: "'x'" }
265
+ ],
266
+ primaryKey: ['id']
267
+ };
268
+ const out = emitAlterDdl('sqlite', canonicalizeTableEntity(entity), canonicalizeTableSpec(spec));
269
+ expect(out).toEqual([
270
+ '-- SQLite 不支持直接 ALTER 列/主键,以下为重建表序列(请置于事务中执行)',
271
+ 'PRAGMA foreign_keys = OFF',
272
+ 'DROP TABLE IF EXISTS "_rebuild_t"',
273
+ 'CREATE TABLE "_rebuild_t" (\n "id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,\n "nm2" TEXT,\n "created" TEXT DEFAULT \'x\'\n)',
274
+ 'INSERT INTO "_rebuild_t" ("id", "nm2", "created") SELECT "id", "nm", \'x\' FROM "t"',
275
+ 'DROP TABLE "t"',
276
+ 'ALTER TABLE "_rebuild_t" RENAME TO "t"',
277
+ 'PRAGMA foreign_keys = ON'
278
+ ]);
279
+ });
280
+
281
+ it('纯函数:同一输入两次调用输出完全一致(可幂等重试)', () => {
282
+ const entity = baseEntity();
283
+ const spec = mirrorSpec(entity, {
284
+ columns: [
285
+ { name: 'id', type: 'int', nullable: false, hasDefault: false, autoIncrement: true },
286
+ { name: 'title', type: 'varchar', length: '64', nullable: false, hasDefault: false },
287
+ { name: 'age', type: 'int', nullable: true, hasDefault: true, defaultValue: '0' }
288
+ ],
289
+ primaryKey: ['id']
290
+ });
291
+ const a = emitAlterDdl('sqlite', canonicalizeTableEntity(entity), canonicalizeTableSpec(spec));
292
+ const b = emitAlterDdl('sqlite', canonicalizeTableEntity(entity), canonicalizeTableSpec(spec));
293
+ expect(a).toEqual(b);
294
+ });
295
+ });
296
+
297
+ describe('能力声明', () => {
298
+ it('sqlite 声明 rebuild、不支持 alterColumn/注释,但支持改主键(走整表重建)', () => {
299
+ const caps = ddlCapabilities('sqlite');
300
+ expect(caps.alterStrategy).toBe('rebuild');
301
+ expect(caps.alterColumn).toBe(false);
302
+ // 重建表可以正确落地新的主键并保留数据,故按真实能力声明为支持
303
+ expect(caps.alterPrimaryKey).toBe(true);
304
+ expect(caps.tableComment).toBe(false);
305
+ });
306
+
307
+ it('mssql 用 bracket 引用、mongodb 全不支持', () => {
308
+ expect(ddlCapabilities('mssql').quoteStyle).toBe('bracket');
309
+ expect(ddlCapabilities('mongodb').alterStrategy).toBe('unsupported');
310
+ expect(ddlCapabilities('mysql').quoteStyle).toBe('backtick');
311
+ });
312
+ });
313
+
314
+ describe('emitCreateDdl:建表单一出口', () => {
315
+ const spec: TableSpec = {
316
+ name: 'users',
317
+ comment: '用户表',
318
+ columns: [
319
+ { name: 'id', type: 'int', nullable: false, hasDefault: false, autoIncrement: true },
320
+ { name: 'name', type: 'varchar', length: '64', nullable: false, hasDefault: false, comment: '姓名' },
321
+ { name: 'age', type: 'int', nullable: true, hasDefault: true, defaultValue: '0' }
322
+ ],
323
+ primaryKey: ['id']
324
+ };
325
+ const canon = () => canonicalizeTableSpec(spec);
326
+
327
+ it('mysql:表级主键 + ENGINE=InnoDB utf8mb4 + 内联表/列注释 + DEFAULT 0 裸输出', () => {
328
+ const out = emitCreateDdl('mysql', canon());
329
+ expect(out).toEqual([
330
+ "CREATE TABLE `users` (\n" +
331
+ " `id` INT NOT NULL AUTO_INCREMENT,\n" +
332
+ " `name` VARCHAR(64) NOT NULL COMMENT '姓名',\n" +
333
+ " `age` INT DEFAULT 0,\n" +
334
+ " PRIMARY KEY (`id`)\n" +
335
+ ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户表'"
336
+ ]);
337
+ });
338
+
339
+ it('mysql:ifNotExists 选项输出 IF NOT EXISTS', () => {
340
+ const out = emitCreateDdl('mysql', canon(), { ifNotExists: true });
341
+ expect(out[0]).toContain('CREATE TABLE IF NOT EXISTS `users`');
342
+ });
343
+
344
+ it('postgres:表级 PRIMARY KEY + GENERATED ALWAYS AS IDENTITY + COMMENT ON 后置(修复旧版 int SERIAL 语法错误)', () => {
345
+ const out = emitCreateDdl('postgres', canon());
346
+ expect(out[0]).toBe(
347
+ "CREATE TABLE \"users\" (\n" +
348
+ ' "id" INT NOT NULL GENERATED ALWAYS AS IDENTITY,\n' +
349
+ ' "name" VARCHAR(64) NOT NULL,\n' +
350
+ ' "age" INT DEFAULT 0,\n' +
351
+ ' PRIMARY KEY ("id")\n' +
352
+ ')'
353
+ );
354
+ expect(out[1]).toBe("COMMENT ON TABLE \"users\" IS '用户表'");
355
+ expect(out[2]).toBe("COMMENT ON COLUMN \"users\".\"name\" IS '姓名'");
356
+ });
357
+
358
+ it('sqlite:单列自增主键内联 PRIMARY KEY AUTOINCREMENT、表级注释被忽略、多列主键用表级约束', () => {
359
+ const out = emitCreateDdl('sqlite', canon());
360
+ expect(out).toEqual([
361
+ "CREATE TABLE \"users\" (\n" +
362
+ ' "id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,\n' + // 自增主键在 SQLite 必须落到字面 INTEGER
363
+ ' "name" VARCHAR(64) NOT NULL,\n' +
364
+ ' "age" INT DEFAULT 0\n' +
365
+ ')'
366
+ ]);
367
+ // 多列主键:列不内联,表级约束
368
+ const multi = emitCreateDdl('sqlite', canonicalizeTableSpec({
369
+ ...spec,
370
+ columns: spec.columns.map((c) => ({ ...c, autoIncrement: false })),
371
+ primaryKey: ['id', 'name']
372
+ }));
373
+ expect(multi[0]).toContain('"id" INT NOT NULL');
374
+ expect(multi[0]).toContain('PRIMARY KEY ("id", "name")');
375
+ expect(multi[0]).not.toContain('PRIMARY KEY AUTOINCREMENT');
376
+ });
377
+
378
+ it('mssql:IDENTITY(1,1) + 三段名前缀 + sp_addextendedproperty 注释后置', () => {
379
+ const out = emitCreateDdl('mssql', canon(), { mssqlDatabase: 'mydb' });
380
+ expect(out[0]).toBe(
381
+ "CREATE TABLE [mydb].[dbo].[users] (\n" +
382
+ ' [id] INT NOT NULL IDENTITY(1,1),\n' +
383
+ ' [name] VARCHAR(64) NOT NULL,\n' +
384
+ ' [age] INT DEFAULT 0,\n' +
385
+ ' PRIMARY KEY ([id])\n' +
386
+ ')'
387
+ );
388
+ expect(out[1]).toContain("sp_addextendedproperty @name=N'MS_Description', @value=N'用户表'");
389
+ expect(out[2]).toContain("@level2type=N'COLUMN', @level2name=N'name'");
390
+ });
391
+
392
+ it('oracle:标识符大写 + COMMENT ON 后置', () => {
393
+ const out = emitCreateDdl('oracle', canon());
394
+ expect(out[0]).toContain('CREATE TABLE "USERS" (');
395
+ expect(out[0]).toContain('"ID" INT NOT NULL');
396
+ expect(out[1]).toBe("COMMENT ON TABLE \"USERS\" IS '用户表'");
397
+ });
398
+
399
+ it('clickhouse:ENGINE 尾缀 + 主键落 ORDER BY + 无主键 ORDER BY tuple()', () => {
400
+ const out = emitCreateDdl('clickhouse', canon());
401
+ expect(out[0]).toContain(') ENGINE = MergeTree');
402
+ expect(out[0]).toContain(' ORDER BY (`id`)');
403
+ const noPk = emitCreateDdl('clickhouse', canonicalizeTableSpec({ ...spec, primaryKey: [] }));
404
+ expect(noPk[0]).toContain(' ORDER BY tuple()');
405
+ });
406
+
407
+ it('clickhouse:可空列包装 Nullable(...) 且不写 NOT NULL(裸类型即不可空)', () => {
408
+ const out = emitCreateDdl('clickhouse', canon());
409
+ // age 可空 → Nullable;id/name 不可空 → 裸类型
410
+ expect(out[0]).toContain('`age` Nullable(INT) DEFAULT 0');
411
+ expect(out[0]).toContain('`id` INT');
412
+ expect(out[0]).toContain('`name` VARCHAR(64)');
413
+ expect(out[0]).not.toContain('NOT NULL');
414
+ // 已是 Nullable 的类型不重复包装
415
+ const already = emitCreateDdl('clickhouse', canonicalizeTableSpec({
416
+ ...spec,
417
+ columns: [{ name: 'a', type: 'Nullable(String)', nullable: true, hasDefault: false }],
418
+ primaryKey: []
419
+ }));
420
+ expect(already[0]).toContain('`a` Nullable(String)');
421
+ expect(already[0]).not.toContain('Nullable(Nullable(');
422
+ });
423
+
424
+ it('tableNameEquals:按方言大小写语义判断重名', () => {
425
+ // PG/Oracle/SQLite/MSSQL 等标识符不敏感(PG 会把裸标识符落成小写)
426
+ expect(tableNameEquals('postgres', 'Users', 'users')).toBe(true);
427
+ expect(tableNameEquals('oracle', 'Users', 'USERS')).toBe(true);
428
+ expect(tableNameEquals('mysql', 'users', 'users')).toBe(true);
429
+ // ClickHouse / MongoDB 严格区分大小写
430
+ expect(tableNameEquals('clickhouse', 'Users', 'users')).toBe(false);
431
+ expect(tableNameEquals('mongodb', 'Users', 'users')).toBe(false);
432
+ });
433
+
434
+ it('sqlite:自增列类型强制 INTEGER(INT/BIGINT + AUTOINCREMENT 在 SQLite 是语法错误)', () => {
435
+ // 用户填 int / bigint 时 canonical 保留原类型名,SQLite 侧必须落到字面 INTEGER
436
+ for (const t of ['int', 'bigint']) {
437
+ const out = emitCreateDdl('sqlite', canonicalizeTableSpec({ ...spec, columns: [{ name: 'id', type: t, nullable: false, hasDefault: false, autoIncrement: true }] }));
438
+ expect(out[0]).toContain('"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT');
439
+ }
440
+ // 非自增列保持用户类型字面
441
+ const plain = emitCreateDdl('sqlite', canonicalizeTableSpec({ ...spec, columns: [{ name: 'id', type: 'int', nullable: false, hasDefault: false }], primaryKey: [] }));
442
+ expect(plain[0]).toContain('"id" INT NOT NULL');
443
+ });
444
+
445
+ it('sap:CREATE COLUMN TABLE + 表级 COMMENT 后缀(对齐旧方言 createTable,避免丢表注释)', () => {
446
+ const out = emitCreateDdl('sap', canon());
447
+ expect(out[0]).toContain('CREATE COLUMN TABLE "users" (');
448
+ expect(out[0]).toContain('"id" INT NOT NULL GENERATED BY DEFAULT AS IDENTITY');
449
+ expect(out[0]).toContain("COMMENT '姓名'"); // 列注释内联
450
+ expect(out[0]).toContain(") COMMENT '用户表'"); // 表注释后缀
451
+ });
452
+
453
+ it('非 SQL 方言返回空数组(调用方按 caps.createTable 拒绝)', () => {
454
+ expect(emitCreateDdl('mongodb', canon())).toEqual([]);
455
+ });
456
+ });