fdb2 1.0.37 → 1.0.39

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 (107) hide show
  1. package/README.md +47 -0
  2. package/bin/fdb2.js +78 -3
  3. package/dist/public/explorer.css +517 -478
  4. package/dist/public/explorer.js +1114 -872
  5. package/dist/public/index.css +87 -27
  6. package/dist/public/index.js +97 -49
  7. package/dist/public/layout.js +2 -2
  8. package/dist/public/vue.js +1 -1
  9. package/dist/server/core/schema/__tests__/canonical.test.ts +195 -0
  10. package/dist/server/core/schema/__tests__/ddl-emitter.test.ts +456 -0
  11. package/dist/server/core/schema/__tests__/diff.test.ts +242 -0
  12. package/dist/server/core/schema/__tests__/roundtrip.property.test.ts +403 -0
  13. package/dist/server/core/schema/canonical.d.ts +50 -0
  14. package/dist/server/core/schema/canonical.d.ts.map +1 -0
  15. package/dist/server/core/schema/canonical.js +212 -0
  16. package/dist/server/core/schema/canonical.js.map +1 -0
  17. package/dist/server/core/schema/canonical.ts +223 -0
  18. package/dist/server/core/schema/ddl-emitter.d.ts +68 -0
  19. package/dist/server/core/schema/ddl-emitter.d.ts.map +1 -0
  20. package/dist/server/core/schema/ddl-emitter.js +813 -0
  21. package/dist/server/core/schema/ddl-emitter.js.map +1 -0
  22. package/dist/server/core/schema/ddl-emitter.ts +925 -0
  23. package/dist/server/core/schema/diff.d.ts +16 -0
  24. package/dist/server/core/schema/diff.d.ts.map +1 -0
  25. package/dist/server/core/schema/diff.js +115 -0
  26. package/dist/server/core/schema/diff.js.map +1 -0
  27. package/dist/server/core/schema/diff.ts +134 -0
  28. package/dist/server/core/schema/type-defaults.d.ts +27 -0
  29. package/dist/server/core/schema/type-defaults.d.ts.map +1 -0
  30. package/dist/server/core/schema/type-defaults.js +61 -0
  31. package/dist/server/core/schema/type-defaults.js.map +1 -0
  32. package/dist/server/core/schema/type-defaults.ts +79 -0
  33. package/dist/server/index.d.ts +1 -1
  34. package/dist/server/index.d.ts.map +1 -1
  35. package/dist/server/index.js +347 -124
  36. package/dist/server/index.js.map +1 -1
  37. package/dist/server/index.ts +336 -161
  38. package/dist/server/model/ddl-spec.d.ts +163 -0
  39. package/dist/server/model/ddl-spec.d.ts.map +1 -0
  40. package/dist/server/model/ddl-spec.js +32 -0
  41. package/dist/server/model/ddl-spec.js.map +1 -0
  42. package/dist/server/model/ddl-spec.ts +175 -0
  43. package/dist/server/service/connection.service.d.ts +20 -0
  44. package/dist/server/service/connection.service.d.ts.map +1 -1
  45. package/dist/server/service/connection.service.js +99 -25
  46. package/dist/server/service/connection.service.js.map +1 -1
  47. package/dist/server/service/connection.service.ts +104 -25
  48. package/dist/server/service/database/base.service.d.ts +0 -4
  49. package/dist/server/service/database/base.service.d.ts.map +1 -1
  50. package/dist/server/service/database/base.service.js.map +1 -1
  51. package/dist/server/service/database/base.service.ts +0 -5
  52. package/dist/server/service/database/clickhouse.service.d.ts +0 -5
  53. package/dist/server/service/database/clickhouse.service.d.ts.map +1 -1
  54. package/dist/server/service/database/clickhouse.service.js +0 -49
  55. package/dist/server/service/database/clickhouse.service.js.map +1 -1
  56. package/dist/server/service/database/clickhouse.service.ts +0 -52
  57. package/dist/server/service/database/cockroachdb.service.d.ts +0 -5
  58. package/dist/server/service/database/cockroachdb.service.d.ts.map +1 -1
  59. package/dist/server/service/database/cockroachdb.service.js +11 -142
  60. package/dist/server/service/database/cockroachdb.service.js.map +1 -1
  61. package/dist/server/service/database/cockroachdb.service.ts +11 -161
  62. package/dist/server/service/database/database.service.d.ts +55 -2
  63. package/dist/server/service/database/database.service.d.ts.map +1 -1
  64. package/dist/server/service/database/database.service.js +246 -4
  65. package/dist/server/service/database/database.service.js.map +1 -1
  66. package/dist/server/service/database/database.service.ts +295 -7
  67. package/dist/server/service/database/mongodb.service.d.ts +0 -6
  68. package/dist/server/service/database/mongodb.service.d.ts.map +1 -1
  69. package/dist/server/service/database/mongodb.service.js +0 -8
  70. package/dist/server/service/database/mongodb.service.js.map +1 -1
  71. package/dist/server/service/database/mongodb.service.ts +0 -8
  72. package/dist/server/service/database/mssql.service.d.ts +8 -4
  73. package/dist/server/service/database/mssql.service.d.ts.map +1 -1
  74. package/dist/server/service/database/mssql.service.js +27 -101
  75. package/dist/server/service/database/mssql.service.js.map +1 -1
  76. package/dist/server/service/database/mssql.service.ts +28 -122
  77. package/dist/server/service/database/mysql.service.d.ts +0 -4
  78. package/dist/server/service/database/mysql.service.d.ts.map +1 -1
  79. package/dist/server/service/database/mysql.service.js +11 -137
  80. package/dist/server/service/database/mysql.service.js.map +1 -1
  81. package/dist/server/service/database/mysql.service.ts +11 -173
  82. package/dist/server/service/database/oracle.service.d.ts +0 -4
  83. package/dist/server/service/database/oracle.service.d.ts.map +1 -1
  84. package/dist/server/service/database/oracle.service.js +11 -104
  85. package/dist/server/service/database/oracle.service.js.map +1 -1
  86. package/dist/server/service/database/oracle.service.ts +11 -127
  87. package/dist/server/service/database/postgres.service.d.ts +0 -4
  88. package/dist/server/service/database/postgres.service.d.ts.map +1 -1
  89. package/dist/server/service/database/postgres.service.js +11 -112
  90. package/dist/server/service/database/postgres.service.js.map +1 -1
  91. package/dist/server/service/database/postgres.service.ts +11 -133
  92. package/dist/server/service/database/sap.service.d.ts +0 -4
  93. package/dist/server/service/database/sap.service.d.ts.map +1 -1
  94. package/dist/server/service/database/sap.service.js +11 -137
  95. package/dist/server/service/database/sap.service.js.map +1 -1
  96. package/dist/server/service/database/sap.service.ts +11 -158
  97. package/dist/server/service/database/sql-type.helper.d.ts.map +1 -1
  98. package/dist/server/service/database/sql-type.helper.js +9 -47
  99. package/dist/server/service/database/sql-type.helper.js.map +1 -1
  100. package/dist/server/service/database/sql-type.helper.ts +11 -53
  101. package/dist/server/service/database/sqlite.service.d.ts +0 -5
  102. package/dist/server/service/database/sqlite.service.d.ts.map +1 -1
  103. package/dist/server/service/database/sqlite.service.js +11 -147
  104. package/dist/server/service/database/sqlite.service.js.map +1 -1
  105. package/dist/server/service/database/sqlite.service.ts +11 -177
  106. package/package.json +1 -1
  107. package/server.js +1 -1
@@ -0,0 +1,242 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { canonicalizeTableEntity, canonicalizeTableSpec } from '../canonical';
3
+ import { diffTable } from '../diff';
4
+ import { isEmptyChangeSet } from '../../../model/ddl-spec';
5
+ import type { TableSpec } from '../../../model/ddl-spec';
6
+
7
+ const baseEntity = () => ({
8
+ name: 'users',
9
+ comment: '用户表',
10
+ columns: [
11
+ { name: 'id', type: 'int', nullable: false, isPrimary: true, isAutoIncrement: true },
12
+ { name: 'name', type: 'varchar(64)', nullable: false },
13
+ { name: 'age', type: 'int', nullable: true, defaultValue: 0 }
14
+ ]
15
+ });
16
+
17
+ /** 由实体镜像出的目标 spec(等价回显,测试的"未修改"基线) */
18
+ function mirrorSpec(
19
+ entity: ReturnType<typeof baseEntity>,
20
+ overrides?: { pk?: string[]; specColumns?: TableSpec['columns'] }
21
+ ): TableSpec {
22
+ const pk = overrides?.pk ?? entity.columns.filter((c) => c.isPrimary).map((c) => c.name);
23
+ return {
24
+ name: entity.name,
25
+ comment: entity.comment,
26
+ columns:
27
+ overrides?.specColumns ??
28
+ entity.columns.map((c) => ({
29
+ name: c.name,
30
+ type: c.type.replace(/\(\d+(,\d+)?\)/, ''),
31
+ length: c.type.includes('varchar') ? '64' : undefined,
32
+ nullable: c.nullable,
33
+ hasDefault: c.defaultValue !== undefined,
34
+ defaultValue: c.defaultValue !== undefined ? String(c.defaultValue) : undefined,
35
+ autoIncrement: c.isAutoIncrement
36
+ })),
37
+ primaryKey: pk
38
+ };
39
+ }
40
+
41
+ function diffBoth(entity: ReturnType<typeof baseEntity>, spec: TableSpec) {
42
+ return diffTable(canonicalizeTableEntity(entity), canonicalizeTableSpec(spec));
43
+ }
44
+
45
+ describe('主键(用户报修的 bug)', () => {
46
+ it('勾选某列为主键又取消:零 diff,不产生任何语句', () => {
47
+ const entity = baseEntity();
48
+ // 勾 name 为主键(同时顶掉 id)再取消 —— 终态与初态一致
49
+ const spec = mirrorSpec(entity, { pk: ['id'] });
50
+ const cs = diffBoth(entity, spec);
51
+ expect(isEmptyChangeSet(cs)).toBe(true);
52
+ });
53
+
54
+ it('主键 A→B:只动 primaryKey 桶,绝不产生列级 modified(旧实现的双重记账)', () => {
55
+ const entity = baseEntity();
56
+ const spec = mirrorSpec(entity, { pk: ['name'] });
57
+ const cs = diffBoth(entity, spec);
58
+ expect(cs.primaryKey).toEqual({ from: ['id'], to: ['name'] });
59
+ // 唯一允许的列级联动:id 失去主键身份 → 自增失效(emit 层需要这个信息)
60
+ const pkChangedCols = ['id', 'name'];
61
+ cs.modified.forEach((m) => {
62
+ expect(pkChangedCols).toContain(m.name);
63
+ expect(m.fields).toEqual(['autoIncrement']);
64
+ });
65
+ expect(cs.renamed).toEqual([]);
66
+ expect(cs.added).toEqual([]);
67
+ expect(cs.dropped).toEqual([]);
68
+ });
69
+
70
+ it('主键 A→无:from=[id], to=[](旧前端会生成非法的 ADD PRIMARY KEY ())', () => {
71
+ const entity = baseEntity();
72
+ const cs = diffBoth(entity, mirrorSpec(entity, { pk: [] }));
73
+ expect(cs.primaryKey).toEqual({ from: ['id'], to: [] });
74
+ });
75
+
76
+ it('无→有:to=[name](旧前端会多生成一条多余的 DROP PRIMARY KEY)', () => {
77
+ const entity = {
78
+ name: 'users',
79
+ comment: '',
80
+ columns: [
81
+ { name: 'name', type: 'varchar(64)', nullable: false }
82
+ ]
83
+ };
84
+ const spec = mirrorSpec(entity as any, { pk: ['name'] });
85
+ const cs = diffBoth(entity as any, spec);
86
+ expect(cs.primaryKey).toEqual({ from: [], to: ['name'] });
87
+ });
88
+
89
+ it('复合主键顺序敏感:(a,b) 与 (b,a) 是变更', () => {
90
+ const entity = {
91
+ name: 't',
92
+ comment: '',
93
+ columns: [
94
+ { name: 'a', type: 'int', nullable: false, isPrimary: true },
95
+ { name: 'b', type: 'int', nullable: false, isPrimary: true }
96
+ ]
97
+ };
98
+ const cs = diffBoth(entity as any, mirrorSpec(entity as any, { pk: ['b', 'a'] }));
99
+ expect(cs.primaryKey).toEqual({ from: ['a', 'b'], to: ['b', 'a'] });
100
+ });
101
+ });
102
+
103
+ describe('重命名检测(防 DROP+ADD 数据丢失)', () => {
104
+ const entityWith = (name: string) => ({
105
+ name: 't',
106
+ comment: '',
107
+ columns: [{ name, type: 'int', nullable: false }]
108
+ });
109
+
110
+ it('usr_nme → user_name:进 renamed 桶,不进 added/dropped', () => {
111
+ const entity = entityWith('usr_nme');
112
+ const spec: TableSpec = {
113
+ name: 't',
114
+ comment: '',
115
+ primaryKey: [],
116
+ columns: [{ name: 'user_name', type: 'INT', nullable: false, hasDefault: false }]
117
+ };
118
+ const cs = diffBoth(entity as any, spec);
119
+ expect(cs.renamed).toEqual([
120
+ { from: 'usr_nme', to: 'user_name', definitionChanged: false, modifiedFields: [] }
121
+ ]);
122
+ expect(cs.added).toEqual([]);
123
+ expect(cs.dropped).toEqual([]);
124
+ expect(cs.modified).toEqual([]);
125
+ });
126
+
127
+ it('改名 + 改定义:renamed.definitionChanged,不重复进 modified', () => {
128
+ const entity = entityWith('usr_nme');
129
+ const spec: TableSpec = {
130
+ name: 't',
131
+ comment: '',
132
+ primaryKey: [],
133
+ columns: [{ name: 'user_name', type: 'BIGINT', nullable: false, hasDefault: false }]
134
+ };
135
+ const cs = diffBoth(entity as any, spec);
136
+ expect(cs.renamed[0]!.definitionChanged).toBe(true);
137
+ expect(cs.renamed[0]!.modifiedFields).toEqual(['type']);
138
+ expect(cs.modified).toEqual([]); // 单归属:不在 renamed 之外重复记账
139
+ });
140
+
141
+ it('完全无关的名称不构成改名', () => {
142
+ const entity = {
143
+ name: 't',
144
+ comment: '',
145
+ columns: [{ name: 'aaa', type: 'int', nullable: false }]
146
+ };
147
+ const spec: TableSpec = {
148
+ name: 't',
149
+ comment: '',
150
+ primaryKey: [],
151
+ columns: [{ name: 'zzz', type: 'INT', nullable: false, hasDefault: false }]
152
+ };
153
+ const cs = diffBoth(entity as any, spec);
154
+ expect(cs.renamed).toEqual([]);
155
+ expect(cs.added.map((c) => c.name)).toEqual(['zzz']);
156
+ expect(cs.dropped).toEqual(['aaa']);
157
+ });
158
+ });
159
+
160
+ describe('列级与表级', () => {
161
+ it('类型参数变化:fields 恰为 [type]', () => {
162
+ const entity = baseEntity();
163
+ const spec = mirrorSpec(entity);
164
+ spec.columns[1] = { ...spec.columns[1]!, length: 128 } as TableSpec['columns'][number];
165
+ const cs = diffBoth(entity, spec);
166
+ expect(cs.modified).toHaveLength(1);
167
+ expect(cs.modified[0]!.name).toBe('name');
168
+ expect(cs.modified[0]!.fields).toEqual(['type']);
169
+ });
170
+
171
+ it('DEFAULT 0 → 无默认:是真实变更,必须检出(旧代码会因空值兜底漏检)', () => {
172
+ const entity = baseEntity();
173
+ const spec = mirrorSpec(entity);
174
+ spec.columns[2] = {
175
+ ...spec.columns[2]!, hasDefault: false
176
+ } as TableSpec['columns'][number];
177
+ const cs = diffBoth(entity, spec);
178
+ expect(cs.modified[0]!.fields).toEqual(['default']);
179
+ });
180
+
181
+ it('改表名 / 改表注释', () => {
182
+ const entity = baseEntity();
183
+ const spec = mirrorSpec(entity);
184
+ spec.name = 'users_v2';
185
+ spec.comment = '新注释';
186
+ const cs = diffBoth(entity, spec);
187
+ expect(cs.rename).toEqual({ from: 'users', to: 'users_v2' });
188
+ expect(cs.comment).toEqual({ from: '用户表', to: '新注释' });
189
+ expect(cs.modified).toEqual([]);
190
+ });
191
+
192
+ it('增列 / 删列', () => {
193
+ const entity = baseEntity();
194
+ const spec = mirrorSpec(entity);
195
+ spec.columns.push({
196
+ name: 'email', type: 'VARCHAR', length: 128, nullable: true, hasDefault: false
197
+ } as TableSpec['columns'][number]);
198
+ let cs = diffBoth(entity, spec);
199
+ expect(cs.added.map((c) => c.name)).toEqual(['email']);
200
+
201
+ const spec2 = mirrorSpec(entity);
202
+ spec2.columns = spec2.columns.filter((c) => c.name !== 'age');
203
+ cs = diffBoth(entity, spec2);
204
+ expect(cs.dropped).toEqual(['age']);
205
+ });
206
+
207
+ it('幸存列顺序变化:orderChanged=true 且不产生列级变更', () => {
208
+ const entity = {
209
+ name: 't',
210
+ comment: '',
211
+ columns: [
212
+ { name: 'a', type: 'int', nullable: true },
213
+ { name: 'b', type: 'int', nullable: true },
214
+ { name: 'c', type: 'int', nullable: true }
215
+ ]
216
+ };
217
+ const spec: TableSpec = {
218
+ name: 't',
219
+ comment: '',
220
+ primaryKey: [],
221
+ columns: [
222
+ { name: 'a', type: 'INT', nullable: true, hasDefault: false },
223
+ { name: 'c', type: 'INT', nullable: true, hasDefault: false },
224
+ { name: 'b', type: 'INT', nullable: true, hasDefault: false }
225
+ ]
226
+ };
227
+ const cs = diffBoth(entity as any, spec);
228
+ expect(cs.orderChanged).toBe(true);
229
+ expect(cs.columnOrder).toEqual(['a', 'c', 'b']);
230
+ expect(cs.modified).toEqual([]);
231
+ expect(cs.added).toEqual([]);
232
+ expect(cs.dropped).toEqual([]);
233
+ });
234
+ });
235
+
236
+ describe('幂等', () => {
237
+ it('diffTable(x, x) 为空', () => {
238
+ const entity = baseEntity();
239
+ const c = canonicalizeTableEntity(entity);
240
+ expect(isEmptyChangeSet(diffTable(c, c))).toBe(true);
241
+ });
242
+ });
@@ -0,0 +1,403 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { canonicalizeTableEntity, canonicalizeTableSpec } from '../canonical';
3
+ import { diffTable } from '../diff';
4
+ import { isEmptyChangeSet } from '../../../model/ddl-spec';
5
+ import type { CanonicalTable, TableSpec } from '../../../model/ddl-spec';
6
+
7
+ /**
8
+ * 属性测试:任何「改了又改回」的操作序列,diff 必须为空(幂等由纯函数保证)。
9
+ * 使用固定种子的确定性 PRNG(mulberry32),失败时输出种子与操作序列,可精确复现。
10
+ */
11
+
12
+ function mulberry32(seed: number): () => number {
13
+ return function () {
14
+ seed |= 0;
15
+ seed = (seed + 0x6d2b79f5) | 0;
16
+ let t = Math.imul(seed ^ (seed >>> 15), 1 | seed);
17
+ t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
18
+ return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
19
+ };
20
+ }
21
+
22
+ const SEED = 0xc0ffee;
23
+ const NAME_POOL = ['id', 'name', 'created_at', 'amount', 'status', 'user_id', 'remark'];
24
+ const TYPE_ENTRIES = [
25
+ { entity: 'varchar(255)', spec: { type: 'VARCHAR', length: 255 } },
26
+ { entity: 'int', spec: { type: 'INT' } },
27
+ { entity: 'decimal(10,0)', spec: { type: 'DECIMAL', precision: 10, scale: 0 } },
28
+ { entity: 'text', spec: { type: 'TEXT' } },
29
+ { entity: 'date', spec: { type: 'DATE' } },
30
+ { entity: "enum('x','y')", spec: { type: 'ENUM', customType: "enum('x','y')" } }
31
+ ] as const;
32
+ const DEFAULT_POOL: Array<undefined | 0 | '' | 'NULL' | 'abc' | 'CURRENT_TIMESTAMP' | '0.50'> = [
33
+ undefined, 0, '', 'NULL', 'abc', 'CURRENT_TIMESTAMP', '0.50'
34
+ ];
35
+
36
+ interface EntityCol {
37
+ name: string;
38
+ type: string;
39
+ nullable: boolean;
40
+ isPrimary?: boolean;
41
+ isAutoIncrement?: boolean;
42
+ defaultValue?: unknown;
43
+ comment?: string;
44
+ }
45
+
46
+ function genEntity(rand: () => number) {
47
+ const n = 2 + Math.floor(rand() * 4);
48
+ const names = NAME_POOL.slice(0, n);
49
+ const pkCount = rand() < 0.2 ? 0 : 1 + Math.floor(rand() * 2);
50
+ const pkNames = new Set(names.slice(0, Math.min(pkCount, n)));
51
+ const columns: EntityCol[] = names.map((name, i) => {
52
+ const entry = TYPE_ENTRIES[Math.floor(rand() * TYPE_ENTRIES.length)]!;
53
+ const def = DEFAULT_POOL[Math.floor(rand() * DEFAULT_POOL.length)];
54
+ return {
55
+ name,
56
+ type: entry.entity,
57
+ nullable: !pkNames.has(name),
58
+ isPrimary: pkNames.has(name),
59
+ isAutoIncrement: pkNames.has(name) && i === 0 && rand() < 0.5,
60
+ defaultValue: def,
61
+ comment: rand() < 0.5 ? `备注${i}` : ` 备注${i} `
62
+ };
63
+ });
64
+ return { name: 'rnd_table', comment: ' 随机表 ', columns };
65
+ }
66
+
67
+ /** canonical → TableSpec:Phase E 前端要做的「等价回显」 */
68
+ function toSpec(t: CanonicalTable): TableSpec {
69
+ return {
70
+ name: t.name,
71
+ comment: t.comment,
72
+ columns: t.columns.map((c) => {
73
+ const full = c.baseType.includes('(');
74
+ return {
75
+ name: c.name,
76
+ ...(full
77
+ ? { type: c.baseType, customType: c.baseType }
78
+ : { type: c.baseType, length: c.length, precision: c.precision, scale: c.scale }),
79
+ nullable: c.nullable,
80
+ hasDefault: c.defaultKind !== 'none',
81
+ defaultValue:
82
+ c.defaultKind === 'none' ? undefined : c.defaultKind === 'null' ? 'NULL' : c.defaultText,
83
+ autoIncrement: c.rawAutoIncrement,
84
+ comment: c.comment
85
+ };
86
+ }),
87
+ primaryKey: t.primaryKey.slice()
88
+ };
89
+ }
90
+
91
+ interface Mutation {
92
+ label: string;
93
+ revert: (spec: TableSpec) => void;
94
+ }
95
+
96
+ function replaceCol(spec: TableSpec, name: string, next: TableSpec['columns'][number]): void {
97
+ const i = spec.columns.findIndex((c) => c.name === name);
98
+ if (i >= 0) spec.columns[i] = next;
99
+ }
100
+
101
+ function mutate(spec: TableSpec, rand: () => number, counter: { n: number }): Mutation {
102
+ const kind = Math.floor(rand() * 9);
103
+ const cols = () => spec.columns;
104
+ const pick = () => cols()[Math.floor(rand() * cols().length)]!;
105
+
106
+ switch (kind) {
107
+ case 0: {
108
+ // 勾选/取消主键
109
+ const col = pick();
110
+ const wasIn = spec.primaryKey.includes(col.name);
111
+ const prevPk = spec.primaryKey.slice();
112
+ spec.primaryKey = wasIn
113
+ ? spec.primaryKey.filter((n) => n !== col.name)
114
+ : [...spec.primaryKey, col.name];
115
+ return { label: `togglePk(${col.name})`, revert: (s) => (s.primaryKey = prevPk.slice()) };
116
+ }
117
+ case 1: {
118
+ const col = pick();
119
+ const prev = col.nullable;
120
+ replaceCol(spec, col.name, { ...col, nullable: !prev });
121
+ return {
122
+ label: `flipNullable(${col.name})`,
123
+ revert: (s) => replaceCol(s, col.name, { ...col })
124
+ };
125
+ }
126
+ case 2: {
127
+ const col = pick();
128
+ const entry = TYPE_ENTRIES[Math.floor(rand() * TYPE_ENTRIES.length)]!;
129
+ const next = { ...col, ...entry.spec } as TableSpec['columns'][number];
130
+ replaceCol(spec, col.name, next);
131
+ return {
132
+ label: `changeType(${col.name} -> ${JSON.stringify(entry.spec)})`,
133
+ revert: (s) => replaceCol(s, col.name, { ...col })
134
+ };
135
+ }
136
+ case 3: {
137
+ const col = pick();
138
+ const prev = { hasDefault: col.hasDefault, defaultValue: col.defaultValue };
139
+ const raw = DEFAULT_POOL[Math.floor(rand() * DEFAULT_POOL.length)]!;
140
+ const next =
141
+ raw === undefined
142
+ ? { ...col, hasDefault: false, defaultValue: undefined }
143
+ : { ...col, hasDefault: true, defaultValue: raw };
144
+ replaceCol(spec, col.name, next as TableSpec['columns'][number]);
145
+ return {
146
+ label: `setDefault(${col.name}, ${String(raw)})`,
147
+ revert: (s) => replaceCol(s, col.name, { ...col, ...prev } as TableSpec['columns'][number])
148
+ };
149
+ }
150
+ case 4: {
151
+ const col = pick();
152
+ const prev = col.comment;
153
+ const next = rand() < 0.5 ? ' 新注释 ' : '';
154
+ replaceCol(spec, col.name, { ...col, comment: next } as TableSpec['columns'][number]);
155
+ return {
156
+ label: `setComment(${col.name})`,
157
+ revert: (s) => replaceCol(s, col.name, { ...col, comment: prev } as TableSpec['columns'][number])
158
+ };
159
+ }
160
+ case 5: {
161
+ const name = `zz_new_${counter.n++}`;
162
+ spec.columns.push({
163
+ name,
164
+ type: 'VARCHAR',
165
+ length: 64,
166
+ nullable: true,
167
+ hasDefault: false,
168
+ comment: ''
169
+ });
170
+ return {
171
+ label: `addCol(${name})`,
172
+ revert: (s) => {
173
+ s.columns = s.columns.filter((c) => c.name !== name);
174
+ }
175
+ };
176
+ }
177
+ case 6: {
178
+ const candidates = spec.columns.filter(
179
+ (c) => !spec.primaryKey.includes(c.name) && !c.name.startsWith('zz_new_')
180
+ );
181
+ if (candidates.length === 0) return mutate(spec, rand, counter);
182
+ const col = candidates[Math.floor(rand() * candidates.length)]!;
183
+ const idx = spec.columns.findIndex((c) => c.name === col.name);
184
+ spec.columns.splice(idx, 1);
185
+ return {
186
+ label: `removeCol(${col.name})`,
187
+ revert: (s) => {
188
+ s.columns.splice(Math.min(idx, s.columns.length), 0, col);
189
+ }
190
+ };
191
+ }
192
+ case 7: {
193
+ const col = pick();
194
+ const wasInPk = spec.primaryKey.includes(col.name);
195
+ const prevPk = spec.primaryKey.slice();
196
+ const newName = `${col.name}_v2`;
197
+ replaceCol(spec, col.name, { ...col, name: newName });
198
+ spec.primaryKey = spec.primaryKey.map((n) => (n === col.name ? newName : n));
199
+ return {
200
+ label: `renameCol(${col.name} -> ${newName})`,
201
+ revert: (s) => {
202
+ replaceCol(s, newName, { ...col });
203
+ s.primaryKey = prevPk.slice();
204
+ }
205
+ };
206
+ }
207
+ default: {
208
+ const col = pick();
209
+ const next = { ...col, autoIncrement: !col.autoIncrement };
210
+ replaceCol(spec, col.name, next);
211
+ return {
212
+ label: `toggleAutoIncrement(${col.name})`,
213
+ revert: (s) => replaceCol(s, col.name, { ...col })
214
+ };
215
+ }
216
+ }
217
+ }
218
+
219
+ describe('属性:往返幂等', () => {
220
+ it('任意随机 UI 操作序列全部撤销后,diff 必须为空(200 组)', () => {
221
+ for (let i = 0; i < 200; i++) {
222
+ const rand = mulberry32(SEED + i);
223
+ const entity = genEntity(rand);
224
+ const canon = canonicalizeTableEntity(entity);
225
+ const spec = toSpec(canon);
226
+ const counter = { n: 0 };
227
+ const mutations: Mutation[] = [];
228
+ const k = 1 + Math.floor(rand() * 8);
229
+ for (let j = 0; j < k; j++) mutations.push(mutate(spec, rand, counter));
230
+
231
+ const cs = diffTable(canon, canonicalizeTableSpec(spec));
232
+ if (!isEmptyChangeSet(cs)) {
233
+ // 有变更属正常;继续撤销后再验证
234
+ }
235
+ for (let j = mutations.length - 1; j >= 0; j--) mutations[j]!.revert(spec);
236
+
237
+ const csFinal = diffTable(canon, canonicalizeTableSpec(spec));
238
+ if (!isEmptyChangeSet(csFinal)) {
239
+ throw new Error(
240
+ `往返后 diff 非空 [seed=${SEED + i}] ops=${mutations.map((m) => m.label).join(' -> ')} :: ${JSON.stringify(csFinal)}`
241
+ );
242
+ }
243
+ }
244
+ });
245
+
246
+ it('「实体 → canonical → 回显」零 diff(Phase E 前端回显的契约,200 组)', () => {
247
+ for (let i = 0; i < 200; i++) {
248
+ const rand = mutaberryOf(SEED * 31 + i);
249
+ const entity = genEntity(rand);
250
+ const canon = canonicalizeTableEntity(entity);
251
+ const cs = diffTable(canon, canonicalizeTableSpec(toSpec(canon)));
252
+ if (!isEmptyChangeSet(cs)) {
253
+ throw new Error(
254
+ `回显产生假 diff [seed=${SEED * 31 + i}] :: ${JSON.stringify(entity)} :: ${JSON.stringify(cs)}`
255
+ );
256
+ }
257
+ }
258
+ });
259
+ });
260
+
261
+ function mutaberryOf(seed: number): () => number {
262
+ return mulberry32(seed);
263
+ }
264
+
265
+ describe('属性:单维度记账(每处变更恰好出现一次)', () => {
266
+ const cases = [1, 2, 3, 42, 777, 2026];
267
+
268
+ it('每个单点变更只落在它唯一的桶里', () => {
269
+ for (const i of cases) {
270
+ const rand = mulberry32(SEED * 7 + i);
271
+ const entity = genEntity(rand);
272
+ const canon = canonicalizeTableEntity(entity);
273
+ const spec = toSpec(canon);
274
+
275
+ let s: TableSpec;
276
+ const check = (cs: ReturnType<typeof diffTable>, expect_: (cs: ReturnType<typeof diffTable>) => void, label: string) => {
277
+ try {
278
+ expect_(cs);
279
+ } catch (e) {
280
+ throw new Error(`[seed=${SEED * 7 + i}] ${label} :: ${JSON.stringify(cs)}\n${(e as Error).message}`);
281
+ }
282
+ };
283
+
284
+ // -- togglePk:primaryKey 桶;若有列级联动,仅允许 autoIncrement 字段且列为主键成员变化者
285
+ s = { ...spec, columns: spec.columns.slice(), primaryKey: spec.primaryKey.slice() };
286
+ const target = s.columns[0]!;
287
+ const wasIn = s.primaryKey.includes(target.name);
288
+ s.primaryKey = wasIn
289
+ ? s.primaryKey.filter((n) => n !== target.name)
290
+ : [...s.primaryKey, target.name];
291
+ check(
292
+ diffTable(canon, canonicalizeTableSpec(s)),
293
+ (cs) => {
294
+ expect(cs.added).toEqual([]);
295
+ expect(cs.dropped).toEqual([]);
296
+ expect(cs.renamed).toEqual([]);
297
+ expect(cs.comment).toBeNull();
298
+ expect(cs.rename).toBeNull();
299
+ expect(cs.modified.every((mm) => mm.fields.every((f) => f === 'autoIncrement'))).toBe(true);
300
+ },
301
+ `togglePk(${target.name})`
302
+ );
303
+
304
+ // -- flipNullable:仅 modified.fields 含 'nullable'
305
+ s = { ...spec, columns: spec.columns.slice(), primaryKey: spec.primaryKey.slice() };
306
+ replaceCol(s, s.columns[0]!.name, { ...s.columns[0]!, nullable: !s.columns[0]!.nullable });
307
+ check(
308
+ diffTable(canon, canonicalizeTableSpec(s)),
309
+ (cs) => {
310
+ expect(cs.added).toEqual([]);
311
+ expect(cs.dropped).toEqual([]);
312
+ expect(cs.renamed).toEqual([]);
313
+ expect(cs.primaryKey).toBeNull();
314
+ cs.modified.forEach((mm) =>
315
+ expect(mm.fields.every((f) => f === 'nullable')).toBe(true)
316
+ );
317
+ },
318
+ 'flipNullable'
319
+ );
320
+
321
+ // -- changeType:仅 modified.fields 含 'type'(可联动 autoIncrement);选一个必然不同的类型
322
+ s = { ...spec, columns: spec.columns.slice(), primaryKey: spec.primaryKey.slice() };
323
+ {
324
+ const c0 = s.columns[0]!;
325
+ const nextType = c0.type === 'TEXT' && !c0.customType ? 'BIGINT' : 'TEXT';
326
+ replaceCol(s, c0.name, {
327
+ ...c0,
328
+ type: nextType,
329
+ length: undefined,
330
+ precision: undefined,
331
+ scale: undefined,
332
+ customType: undefined
333
+ } as TableSpec['columns'][number]);
334
+ }
335
+ check(
336
+ diffTable(canon, canonicalizeTableSpec(s)),
337
+ (cs) => {
338
+ expect(cs.added).toEqual([]);
339
+ expect(cs.dropped).toEqual([]);
340
+ expect(cs.renamed).toEqual([]);
341
+ expect(cs.primaryKey).toBeNull();
342
+ expect(cs.modified.length).toBe(1);
343
+ expect(cs.modified[0]!.fields[0]).toBe('type');
344
+ expect(cs.modified[0]!.fields.every((f) => f === 'type' || f === 'autoIncrement')).toBe(true);
345
+ },
346
+ 'changeType'
347
+ );
348
+
349
+ // -- rename:仅 renamed 桶
350
+ s = { ...spec, columns: spec.columns.slice(), primaryKey: spec.primaryKey.slice() };
351
+ const r0 = s.columns[0]!;
352
+ const r0InPk = s.primaryKey.includes(r0.name);
353
+ const r0New = `${r0.name}_v2`;
354
+ replaceCol(s, r0.name, { ...r0, name: r0New });
355
+ if (r0InPk) s.primaryKey = s.primaryKey.map((n) => (n === r0.name ? r0New : n));
356
+ check(
357
+ diffTable(canon, canonicalizeTableSpec(s)),
358
+ (cs) => {
359
+ expect(cs.added).toEqual([]);
360
+ expect(cs.dropped).toEqual([]);
361
+ expect(cs.modified).toEqual([]);
362
+ expect(cs.primaryKey).toBeNull();
363
+ expect(cs.renamed).toEqual([
364
+ { from: r0.name, to: r0New, definitionChanged: false, modifiedFields: [] }
365
+ ]);
366
+ },
367
+ `renameCol(${r0.name})`
368
+ );
369
+
370
+ // -- addCol / removeCol:仅 added / dropped
371
+ s = { ...spec, columns: spec.columns.slice(), primaryKey: spec.primaryKey.slice() };
372
+ s.columns.push({
373
+ name: 'zz_chk_add', type: 'VARCHAR', length: 8, nullable: true, hasDefault: false, comment: ''
374
+ });
375
+ check(
376
+ diffTable(canon, canonicalizeTableSpec(s)),
377
+ (cs) => {
378
+ expect(cs.added.map((c) => c.name)).toEqual(['zz_chk_add']);
379
+ expect(cs.dropped).toEqual([]);
380
+ expect(cs.modified).toEqual([]);
381
+ },
382
+ 'addCol'
383
+ );
384
+
385
+ s = { ...spec, columns: spec.columns.slice(), primaryKey: spec.primaryKey.slice() };
386
+ const removable = s.columns.find(
387
+ (c) => !s.primaryKey.includes(c.name) && !c.name.startsWith('zz_')
388
+ );
389
+ if (removable) {
390
+ s.columns = s.columns.filter((c) => c.name !== removable.name);
391
+ check(
392
+ diffTable(canon, canonicalizeTableSpec(s)),
393
+ (cs) => {
394
+ expect(cs.dropped).toEqual([removable.name]);
395
+ expect(cs.added).toEqual([]);
396
+ expect(cs.renamed).toEqual([]);
397
+ },
398
+ `removeCol(${removable.name})`
399
+ );
400
+ }
401
+ }
402
+ });
403
+ });
@@ -0,0 +1,50 @@
1
+ /**
2
+ * 归一化投影:把「数据库实体形状」和「前端表单形状」投影到同一个 CanonicalTable。
3
+ *
4
+ * 目标:表示差异不产生假 diff——
5
+ * 'varchar(255)' 与 { type:'VARCHAR', length:'255' } 等价;
6
+ * 0 / '0'、'' / DEFAULT ''、'NULL' / DEFAULT NULL / 无默认值 三态可区分且各自恒等;
7
+ * 注释前后空格、数值字面量的 '007'/'0.50' 等书写差异被消除。
8
+ *
9
+ * 不变式:canonicalize 是纯函数且幂等(对已是 canonical 形状的对象无副作用)。
10
+ * rawAutoIncrement 不做 clamp(保留原始意图,供"加主键时恢复自增"),
11
+ * effectiveAutoIncrement 按「自增必须依附主键」派生——两侧同一规则,比较恒稳。
12
+ */
13
+ import type { CanonicalColumn, CanonicalTable, DefaultKind, TableSpec } from '../../model/ddl-spec';
14
+ /** 数值字面量归一:'007'→'7','0.50'→'0.5','-0'→'0'(字符串实现,避免大数精度损失) */
15
+ export declare function normalizeNumericLiteral(s: string): string;
16
+ /**
17
+ * 默认值三态+表达式分类。
18
+ * @param raw 原始值(实体侧:driver 原样,可能是 0/''/'NULL'/null;表单侧:文本)
19
+ * @param hasDefault 表单侧显式标记;实体侧不传(undefined 表示未知,按 raw 推断)
20
+ */
21
+ export declare function classifyDefault(raw: unknown, hasDefault?: boolean): {
22
+ kind: DefaultKind;
23
+ text: string;
24
+ };
25
+ /** 类型归一:接受 { type:'varchar(255)', length:255 } 或 { type:'VARCHAR', length:'255' } 或 customType 全串 */
26
+ export declare function canonicalizeType(input: {
27
+ type?: string;
28
+ customType?: string;
29
+ length?: number | string | null;
30
+ precision?: number | string | null;
31
+ scale?: number | string | null;
32
+ }): {
33
+ baseType: string;
34
+ length?: number;
35
+ precision?: number;
36
+ scale?: number;
37
+ };
38
+ type LooseColumn = Record<string, any>;
39
+ /** 数据库实体侧(live 结构 / getColumns 结果)→ CanonicalTable */
40
+ export declare function canonicalizeTableEntity(table: {
41
+ name?: string;
42
+ comment?: string;
43
+ columns?: LooseColumn[];
44
+ }): CanonicalTable;
45
+ /** 前端目标侧(TableSpec)→ CanonicalTable */
46
+ export declare function canonicalizeTableSpec(spec: TableSpec): CanonicalTable;
47
+ /** 列定义(不含列名、不含主键)是否等价;返回发生变化的维度集合 */
48
+ export declare function diffColumnFields(before: CanonicalColumn, after: CanonicalColumn): Array<'type' | 'nullable' | 'default' | 'comment' | 'autoIncrement'>;
49
+ export {};
50
+ //# sourceMappingURL=canonical.d.ts.map