mysqlsyncr 1.1.12 → 1.2.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.
Files changed (62) hide show
  1. package/README.md +59 -0
  2. package/__tests__/acceptCurrent.test.js +117 -0
  3. package/__tests__/columnAttributes.test.js +101 -0
  4. package/__tests__/options.test.js +259 -0
  5. package/__tests__/parser.test.js +167 -166
  6. package/__tests__/parser2.test.js +155 -152
  7. package/__tests__/paths.test.js +21 -0
  8. package/__tests__/prompt.test.js +106 -0
  9. package/__tests__/sync.test.js +348 -0
  10. package/__tests__/templateDrift.test.js +72 -0
  11. package/__tests__/versions.test.js +138 -0
  12. package/dist/index.cjs +4059 -1733
  13. package/dist/index.js +4101 -1777
  14. package/package.json +66 -59
  15. package/rollup.config.js +2 -1
  16. package/src/DBStructureChecker.js +47 -0
  17. package/src/acceptCurrent.js +225 -0
  18. package/src/apply.js +254 -136
  19. package/src/cli.js +61 -34
  20. package/src/compare.js +106 -92
  21. package/src/differences.js +258 -212
  22. package/src/dump.js +63 -54
  23. package/src/getters.js +151 -139
  24. package/src/index.js +223 -169
  25. package/src/loggers.js +156 -139
  26. package/src/normalizers.js +28 -6
  27. package/src/options.js +133 -0
  28. package/src/parsers.js +82 -36
  29. package/src/paths.js +41 -0
  30. package/src/prompt.js +109 -0
  31. package/src/rebuild.js +106 -0
  32. package/src/rows.js +46 -0
  33. package/src/socketServer.js +106 -24
  34. package/src/templateDrift.js +93 -0
  35. package/src/versions.js +112 -0
  36. package/ui/README.md +55 -40
  37. package/ui/dist/spa/assets/KFOMCnqEu92Fr1ME7kSn66aGLdTylUAMQXC89YmC2DPNWuYjalmUiAw-k1h7X_-h.woff +0 -0
  38. package/ui/dist/spa/assets/KFOMCnqEu92Fr1ME7kSn66aGLdTylUAMQXC89YmC2DPNWuZtalmUiAw-B7du-70m.woff +0 -0
  39. package/ui/dist/spa/assets/KFOMCnqEu92Fr1ME7kSn66aGLdTylUAMQXC89YmC2DPNWuaabVmUiAw-CoAZ_DKt.woff +0 -0
  40. package/ui/dist/spa/assets/KFOMCnqEu92Fr1ME7kSn66aGLdTylUAMQXC89YmC2DPNWub2bVmUiAw-D0406B4n.woff +0 -0
  41. package/ui/dist/spa/assets/KFOMCnqEu92Fr1ME7kSn66aGLdTylUAMQXC89YmC2DPNWubEbFmUiAw-CnAg2DeQ.woff +0 -0
  42. package/ui/dist/spa/assets/KFOMCnqEu92Fr1ME7kSn66aGLdTylUAMQXC89YmC2DPNWubEbVmUiAw-BG9VWE5v.woff +0 -0
  43. package/ui/dist/spa/assets/MainLayout-DLH1Kfg-.js +1 -0
  44. package/ui/dist/spa/assets/axios-2hQ_j_Tz.js +1 -0
  45. package/ui/dist/spa/assets/flUhRq6tzZclQEJ-Vdg-IuiaDsNa-OUIwM9U8.woff +0 -0
  46. package/ui/dist/spa/assets/index-Cqrg985P.js +23 -0
  47. package/ui/dist/spa/assets/index-pOWtXlXQ.css +1 -0
  48. package/ui/dist/spa/index.html +2 -2
  49. package/ui/package.json +43 -42
  50. package/ui/src/components/DefinitionDiff.vue +169 -0
  51. package/ui/src/pages/MainComponent.vue +704 -78
  52. package/ui/dist/spa/assets/KFOkCnqEu92Fr1MmgVxIIzQ-Y2mpUUkw.woff +0 -0
  53. package/ui/dist/spa/assets/KFOlCnqEu92Fr1MmEU9fBBc--ViM7Ag4Q.woff +0 -0
  54. package/ui/dist/spa/assets/KFOlCnqEu92Fr1MmSU5fBBc--CrFzpgl6.woff +0 -0
  55. package/ui/dist/spa/assets/KFOlCnqEu92Fr1MmWUlfBBc--CW3_XjG_.woff +0 -0
  56. package/ui/dist/spa/assets/KFOlCnqEu92Fr1MmYUtfBBc--Dr8fnyGc.woff +0 -0
  57. package/ui/dist/spa/assets/KFOmCnqEu92Fr1Mu4mxM-DAYh6T4l.woff +0 -0
  58. package/ui/dist/spa/assets/MainLayout-CvQD3xKr.js +0 -1
  59. package/ui/dist/spa/assets/axios-C0XcclIb.js +0 -1
  60. package/ui/dist/spa/assets/flUhRq6tzZclQEJ-Vdg-IuiaDsNa-Dr0goTwe.woff +0 -0
  61. package/ui/dist/spa/assets/index-CwWo8xmP.js +0 -40
  62. package/ui/dist/spa/assets/index-kXpA8n8r.css +0 -5
@@ -0,0 +1,21 @@
1
+ import path from 'path';
2
+ import { packageRoot, isInsidePackage, assertOutsidePackage } from '../src/paths';
3
+
4
+ describe('package folder check', () => {
5
+ test('finds the package root', () => {
6
+ expect(path.basename(path.join(packageRoot, 'package.json'))).toBe('package.json');
7
+ expect(require(path.join(packageRoot, 'package.json')).name).toBe('mysqlsyncr');
8
+ });
9
+
10
+ test('the package folder and anything under it are inside', () => {
11
+ expect(isInsidePackage(packageRoot)).toBe(true);
12
+ expect(isInsidePackage(path.join(packageRoot, 'db-dump'))).toBe(true);
13
+ expect(() => assertOutsidePackage(path.join(packageRoot, 'src', 'x'))).toThrow('inside the mysqlsyncr package folder');
14
+ });
15
+
16
+ test('sibling and parent folders are outside', () => {
17
+ expect(isInsidePackage(path.join(packageRoot, '..', 'db-dump'))).toBe(false);
18
+ expect(isInsidePackage(packageRoot + '-dumps')).toBe(false);
19
+ expect(isInsidePackage(path.dirname(packageRoot))).toBe(false);
20
+ });
21
+ });
@@ -0,0 +1,106 @@
1
+ import fs from 'fs';
2
+ import os from 'os';
3
+ import path from 'path';
4
+ import { PassThrough } from 'stream';
5
+ import { promptForOptions, promptForPassword } from '../src/prompt';
6
+
7
+ const defaults = () => ({ host: 'localhost', port: 3306, user: 'root', password: undefined, database: null, output: '../db-dump', write: false });
8
+
9
+ // A folder holding a one-database template, as compare/update/rebuild need
10
+ const templateDir = () => {
11
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'mysqlsyncr-prompt-'));
12
+ fs.mkdirSync(path.join(dir, 'Alerts'));
13
+ fs.writeFileSync(path.join(dir, 'Alerts', 'Alerts.json'), '{"tables":{}}');
14
+ return dir;
15
+ };
16
+
17
+ // Answers the prompts one line at a time, each once its question has been written
18
+ const answer = async (answers, ask) => {
19
+ const input = new PassThrough();
20
+ const output = new PassThrough();
21
+ let written = '';
22
+ let next = 0;
23
+ output.on('data', (chunk) => {
24
+ written += chunk.toString();
25
+ if (/[:)] $/.test(written) && next < answers.length) {
26
+ written = '';
27
+ const line = answers[next++];
28
+ setImmediate(() => input.write(`${line}\r`));
29
+ }
30
+ });
31
+ let transcript = '';
32
+ output.on('data', (chunk) => { transcript += chunk.toString(); });
33
+ const result = await ask({ input, output });
34
+ return { result, transcript };
35
+ };
36
+
37
+ const run = async (answers, argv = defaults()) => {
38
+ const { result, transcript } = await answer(answers, (streams) => promptForOptions(argv, streams));
39
+ return { command: result, argv, transcript };
40
+ };
41
+
42
+ describe('promptForOptions', () => {
43
+ test('compare: asks for the connection, keeping defaults for blank answers', async () => {
44
+ const dir = templateDir();
45
+ const { command, argv, transcript } = await run(['1', 'db.example', '', 'admin', 'secret', '', dir]);
46
+ expect(command).toBe('compare');
47
+ expect(argv).toMatchObject({ host: 'db.example', port: 3306, user: 'admin', password: 'secret', database: null, output: dir });
48
+ expect(transcript).not.toContain('secret');
49
+ expect(transcript).toContain('typing is hidden');
50
+ });
51
+
52
+ test('a blank password is no password', async () => {
53
+ const { argv } = await run(['compare', '', '', '', '', '', templateDir()]);
54
+ expect(argv.password).toBe('');
55
+ });
56
+
57
+ test('accepts the command name and a single database; dump may use a new folder', async () => {
58
+ const { command, argv } = await run(['dump', '', '', '', '', 'Alerts', '/new/dump/folder']);
59
+ expect(command).toBe('dump');
60
+ expect(argv).toMatchObject({ database: 'Alerts', output: '/new/dump/folder' });
61
+ });
62
+
63
+ test('update needs confirming', async () => {
64
+ expect((await run(['update', '', '', '', '', '', templateDir(), 'n'])).command).toBeNull();
65
+ expect((await run(['update', '', '', '', '', '', templateDir(), 'y'])).command).toBe('update');
66
+ });
67
+
68
+ test('rebuild asks only for the folder and whether to write', async () => {
69
+ const dir = templateDir();
70
+ const { command, argv } = await run(['5', dir, 'y']);
71
+ expect(command).toBe('rebuild');
72
+ expect(argv).toMatchObject({ output: dir, write: true, host: 'localhost' });
73
+ });
74
+
75
+ test('ui asks nothing else', async () => {
76
+ expect((await run(['ui'])).command).toBe('ui');
77
+ });
78
+ });
79
+
80
+ describe('folder checks', () => {
81
+ test('asks again until the folder is outside the package', async () => {
82
+ const dir = templateDir();
83
+ const { command, argv, transcript } = await run(['rebuild', './templates', '', dir, 'n']);
84
+ expect(command).toBe('rebuild');
85
+ expect(argv.output).toBe(dir);
86
+ expect(transcript).toContain('inside the mysqlsyncr package folder');
87
+ });
88
+
89
+ test('compare asks again when the folder is missing or has no template', async () => {
90
+ const dir = templateDir();
91
+ const empty = fs.mkdtempSync(path.join(os.tmpdir(), 'mysqlsyncr-empty-'));
92
+ const { argv, transcript } = await run(['compare', '', '', '', '', '', path.join(dir, 'missing'), empty, dir]);
93
+ expect(argv.output).toBe(dir);
94
+ expect(transcript).toContain("doesn't exist");
95
+ expect(transcript).toContain('has no template in it');
96
+ });
97
+ });
98
+
99
+ describe('promptForPassword', () => {
100
+ test('asks without echoing, naming the user and host', async () => {
101
+ const { result, transcript } = await answer(['hunter2'], (streams) => promptForPassword({ user: 'root', host: 'localhost' }, streams));
102
+ expect(result).toBe('hunter2');
103
+ expect(transcript).toContain('for root@localhost');
104
+ expect(transcript).not.toContain('hunter2');
105
+ });
106
+ });
@@ -0,0 +1,348 @@
1
+ import { parseCreateTableSQL, parseColumnDefinition } from '../src/parsers';
2
+ import { findDifferences } from '../src/differences';
3
+ import { applyDifferences, columnDefinition } from '../src/apply';
4
+
5
+ // A one-table structure the way getDatabaseStructure returns it
6
+ const liveStructure = (createSQL) => {
7
+ const parsed = parseCreateTableSQL(createSQL);
8
+ return {
9
+ tables: { [parsed.name]: { columns: parsed.columns, indexes: parsed.indexes, constraints: parsed.constraints, triggers: [], createSQL } },
10
+ procedures: []
11
+ };
12
+ };
13
+
14
+ // A one-table template: hand-written JSON columns/indexes alongside its createSQL
15
+ const templateStructure = (createSQL, columns, indexes) => ({
16
+ tables: { [parseCreateTableSQL(createSQL).name]: { columns, indexes, triggers: [], createSQL } },
17
+ procedures: []
18
+ });
19
+
20
+ // Records every statement instead of running it; statements matching failOn throw
21
+ const mockConnection = (failOn = []) => {
22
+ const statements = [];
23
+ return {
24
+ statements,
25
+ query: async (sql) => {
26
+ if (sql.startsWith('USE') || sql.startsWith('SELECT VERSION') || sql.startsWith('SET SESSION')) return [];
27
+ if (sql.startsWith('SHOW COLUMNS')) return [{ Field: 'alarmString' }];
28
+ statements.push(sql);
29
+ const failure = failOn.find(f => sql.includes(f));
30
+ if (failure) throw new Error(`failed on ${failure}`);
31
+ return [];
32
+ }
33
+ };
34
+ };
35
+
36
+ describe('parseColumnDefinition', () => {
37
+ test('reads DEFAULT after COLLATE', () => {
38
+ expect(parseColumnDefinition('`IID` varchar(25) COLLATE utf8mb4_unicode_ci DEFAULT NULL,')).toEqual({
39
+ Field: 'IID', Type: 'varchar(25)', Collate: 'utf8mb4_unicode_ci', Default: 'NULL'
40
+ });
41
+ });
42
+
43
+ test('reads DEFAULT after a two-parameter type', () => {
44
+ expect(parseColumnDefinition('`currentValue` float(10,2) DEFAULT NULL,')).toEqual({
45
+ Field: 'currentValue', Type: 'float(10,2)', Default: 'NULL'
46
+ });
47
+ });
48
+
49
+ test('keeps enum values in the type', () => {
50
+ expect(parseColumnDefinition("`changeType` enum('INSERT','UPDATE','DELETE') NOT NULL,")).toEqual({
51
+ Field: 'changeType', Type: "enum('INSERT','UPDATE','DELETE')", NotNull: true
52
+ });
53
+ });
54
+
55
+ test('keeps spaces and escaped quotes in a quoted default', () => {
56
+ expect(parseColumnDefinition("`modifiedBy` varchar(32) NOT NULL DEFAULT 'System Generated',").Default).toBe('System Generated');
57
+ expect(parseColumnDefinition("`note` varchar(32) DEFAULT 'it''s, ok',").Default).toBe("it's, ok");
58
+ });
59
+
60
+ test('ignores keywords inside a comment', () => {
61
+ expect(parseColumnDefinition("`notes` text COMMENT 'NOT NULL DEFAULT x, ON UPDATE CURRENT_TIMESTAMP',")).toEqual({
62
+ Field: 'notes', Type: 'text', Comment: 'NOT NULL DEFAULT x, ON UPDATE CURRENT_TIMESTAMP'
63
+ });
64
+ });
65
+
66
+ test('reads ON UPDATE', () => {
67
+ expect(parseColumnDefinition('`dateUpdated` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,')).toEqual({
68
+ Field: 'dateUpdated', Type: 'timestamp', NotNull: true, Default: 'CURRENT_TIMESTAMP', OnUpdate: 'CURRENT_TIMESTAMP'
69
+ });
70
+ });
71
+
72
+ test('returns null for index lines', () => {
73
+ expect(parseColumnDefinition('KEY `idx_IID` (`IID`,`point`),')).toBeNull();
74
+ expect(parseColumnDefinition('PRIMARY KEY (`alarmID`),')).toBeNull();
75
+ });
76
+ });
77
+
78
+ describe('findDifferences on tables already matching the template', () => {
79
+ test('columns with COLLATE before DEFAULT', () => {
80
+ const createSQL = `CREATE TABLE \`E2Alarms\` (
81
+ \`alarmID\` bigint NOT NULL,
82
+ \`IID\` varchar(25) COLLATE utf8mb4_unicode_ci DEFAULT NULL,
83
+ PRIMARY KEY (\`alarmID\`)
84
+ ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`;
85
+ const template = templateStructure(createSQL + ';', [
86
+ { Field: 'alarmID', Type: 'bigint', NotNull: true },
87
+ { Field: 'IID', Type: 'varchar(25)', Default: 'NULL' }
88
+ ], [{ ColumnName: ['alarmID'], Primary: true, Name: 'PRIMARY' }]);
89
+ expect(findDifferences(template, liveStructure(createSQL))).toEqual([]);
90
+ });
91
+
92
+ test('enum type, quoted default with a space, and indexes with "Unique": false', () => {
93
+ const createSQL = `CREATE TABLE \`SystemCodesHistory\` (
94
+ \`idSystemCodesHistory\` int NOT NULL AUTO_INCREMENT,
95
+ \`assetID\` int NOT NULL,
96
+ \`changeType\` enum('INSERT','UPDATE','DELETE') NOT NULL,
97
+ \`modifiedBy\` varchar(32) NOT NULL DEFAULT 'System Generated',
98
+ PRIMARY KEY (\`idSystemCodesHistory\`),
99
+ KEY \`idx_systemcodeshistory_assetID\` (\`assetID\`)
100
+ ) ENGINE=InnoDB DEFAULT CHARSET=latin1`;
101
+ const template = templateStructure(createSQL + ';', [
102
+ { Field: 'idSystemCodesHistory', Type: 'int', NotNull: true, AutoIncrement: true },
103
+ { Field: 'assetID', Type: 'int', NotNull: true },
104
+ { Field: 'changeType', Type: "enum('INSERT','UPDATE','DELETE')", NotNull: true },
105
+ { Field: 'modifiedBy', Type: 'varchar(32)', NotNull: true, Default: 'System Generated' }
106
+ ], [
107
+ { ColumnName: ['idSystemCodesHistory'], Unique: false, Name: 'PRIMARY' },
108
+ { ColumnName: ['assetID'], Unique: false, Name: 'idx_systemcodeshistory_assetID' }
109
+ ]);
110
+ expect(findDifferences(template, liveStructure(createSQL))).toEqual([]);
111
+ });
112
+
113
+ test('nullable columns with and without an explicit DEFAULT NULL', () => {
114
+ // MySQL never prints DEFAULT NULL for TEXT columns
115
+ const createSQL = `CREATE TABLE \`data_alarms\` (
116
+ \`id\` int NOT NULL AUTO_INCREMENT,
117
+ \`confirm_notes\` text COMMENT 'optional notes',
118
+ \`notificationStartTime\` time DEFAULT NULL,
119
+ PRIMARY KEY (\`id\`)
120
+ ) ENGINE=InnoDB DEFAULT CHARSET=latin1`;
121
+ const template = templateStructure(createSQL + ';', [
122
+ { Field: 'id', Type: 'int', NotNull: true, AutoIncrement: true },
123
+ { Field: 'confirm_notes', Type: 'text', Default: 'NULL' },
124
+ { Field: 'notificationStartTime', Type: 'time', NotNull: false }
125
+ ], [{ ColumnName: ['id'], Primary: true, Name: 'PRIMARY' }]);
126
+ expect(findDifferences(template, liveStructure(createSQL))).toEqual([]);
127
+ });
128
+
129
+ test('prefix index recorded without its length in the JSON', () => {
130
+ const createSQL = `CREATE TABLE \`E2Alarms\` (
131
+ \`alarmID\` bigint NOT NULL,
132
+ \`alarmString\` mediumtext,
133
+ PRIMARY KEY (\`alarmID\`),
134
+ KEY \`idx_e2alarms_alarmstring\` (\`alarmString\`(255))
135
+ ) ENGINE=InnoDB DEFAULT CHARSET=latin1`;
136
+ const template = templateStructure(createSQL + ';', [
137
+ { Field: 'alarmID', Type: 'bigint', NotNull: true },
138
+ { Field: 'alarmString', Type: 'mediumtext' }
139
+ ], [
140
+ { ColumnName: ['alarmID'], Primary: true, Name: 'PRIMARY' },
141
+ { ColumnName: ['alarmString'], Name: 'idx_e2alarms_alarmstring' }
142
+ ]);
143
+ expect(findDifferences(template, liveStructure(createSQL))).toEqual([]);
144
+ });
145
+ });
146
+
147
+ describe('findDifferences on tables that differ', () => {
148
+ test('a missing prefix index keeps its length from the createSQL', () => {
149
+ const templateSQL = `CREATE TABLE \`E2Alarms\` (
150
+ \`alarmID\` bigint NOT NULL,
151
+ \`alarmString\` mediumtext,
152
+ PRIMARY KEY (\`alarmID\`),
153
+ KEY \`idx_e2alarms_alarmstring\` (\`alarmString\`(255))
154
+ ) ENGINE=InnoDB DEFAULT CHARSET=latin1;`;
155
+ const liveSQL = `CREATE TABLE \`E2Alarms\` (
156
+ \`alarmID\` bigint NOT NULL,
157
+ \`alarmString\` mediumtext,
158
+ PRIMARY KEY (\`alarmID\`)
159
+ ) ENGINE=InnoDB DEFAULT CHARSET=latin1`;
160
+ const template = templateStructure(templateSQL, [
161
+ { Field: 'alarmID', Type: 'bigint', NotNull: true },
162
+ { Field: 'alarmString', Type: 'mediumtext' }
163
+ ], [
164
+ { ColumnName: ['alarmID'], Primary: true, Name: 'PRIMARY' },
165
+ { ColumnName: ['alarmString'], Name: 'idx_e2alarms_alarmstring' }
166
+ ]);
167
+ const diffs = findDifferences(template, liveStructure(liveSQL));
168
+ expect(diffs).toHaveLength(1);
169
+ expect(diffs[0]).toMatchObject({ type: 'missing_index', index: { ColumnName: ['alarmString(255)'] } });
170
+ });
171
+
172
+ test('a missing column picks up ON UPDATE from the createSQL', () => {
173
+ const templateSQL = `CREATE TABLE \`Alarms_Log\` (
174
+ \`idAlarmLog\` int NOT NULL AUTO_INCREMENT,
175
+ \`dateUpdated\` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
176
+ PRIMARY KEY (\`idAlarmLog\`)
177
+ ) ENGINE=InnoDB DEFAULT CHARSET=latin1`;
178
+ const liveSQL = `CREATE TABLE \`Alarms_Log\` (
179
+ \`idAlarmLog\` int NOT NULL AUTO_INCREMENT,
180
+ PRIMARY KEY (\`idAlarmLog\`)
181
+ ) ENGINE=InnoDB DEFAULT CHARSET=latin1`;
182
+ const template = templateStructure(templateSQL, [
183
+ { Field: 'idAlarmLog', Type: 'int', NotNull: true, AutoIncrement: true },
184
+ { Field: 'dateUpdated', Type: 'timestamp', NotNull: true, Default: 'CURRENT_TIMESTAMP' }
185
+ ], [{ ColumnName: ['idAlarmLog'], Primary: true, Name: 'PRIMARY' }]);
186
+ const diffs = findDifferences(template, liveStructure(liveSQL));
187
+ expect(diffs).toHaveLength(1);
188
+ expect(diffs[0]).toMatchObject({ type: 'missing_field', field: { Field: 'dateUpdated', OnUpdate: 'CURRENT_TIMESTAMP' } });
189
+ });
190
+
191
+ test('detects a nullability change', () => {
192
+ const templateSQL = `CREATE TABLE \`t\` (
193
+ \`a\` int NOT NULL
194
+ ) ENGINE=InnoDB;`;
195
+ const template = templateStructure(templateSQL, [{ Field: 'a', Type: 'int', NotNull: true }], []);
196
+ const notNullLive = liveStructure(templateSQL.replace(';', ' DEFAULT CHARSET=latin1'));
197
+ expect(findDifferences(template, notNullLive)).toEqual([]);
198
+ const nullableLive = liveStructure(templateSQL.replace('int NOT NULL', 'int DEFAULT NULL'));
199
+ expect(findDifferences(template, nullableLive).map(d => d.info)).toContain('Null');
200
+ });
201
+
202
+ test('does not reorder composite index columns', () => {
203
+ const createSQL = `CREATE TABLE \`t\` (
204
+ \`b\` int NOT NULL,
205
+ \`a\` int NOT NULL,
206
+ KEY \`idx_ba\` (\`b\`,\`a\`)
207
+ ) ENGINE=InnoDB`;
208
+ const template = templateStructure(createSQL, [
209
+ { Field: 'b', Type: 'int', NotNull: true },
210
+ { Field: 'a', Type: 'int', NotNull: true }
211
+ ], [{ ColumnName: ['b', 'a'], Name: 'idx_ba' }]);
212
+ const live = liveStructure(createSQL.replace('`idx_ba`', '`other`'));
213
+ findDifferences(template, live);
214
+ expect(template.tables.t.indexes[0].ColumnName).toEqual(['b', 'a']);
215
+ expect(live.tables.t.indexes[0].ColumnName).toEqual(['b', 'a']);
216
+ });
217
+ });
218
+
219
+ describe('columnDefinition', () => {
220
+ test('NOT NULL without a default gets no DEFAULT NULL', () => {
221
+ expect(columnDefinition({ Field: 'DeviceId', Type: 'varchar(45)', NotNull: true })).toBe('varchar(45) NOT NULL');
222
+ });
223
+
224
+ test('nullable timestamp is explicitly NULL', () => {
225
+ expect(columnDefinition({ Field: 'lastValueUpdated', Type: 'timestamp', Default: 'NULL' })).toBe('timestamp NULL DEFAULT NULL');
226
+ });
227
+
228
+ test('CURRENT_TIMESTAMP default with ON UPDATE', () => {
229
+ expect(columnDefinition({ Field: 'dateUpdated', Type: 'timestamp', NotNull: true, Default: 'CURRENT_TIMESTAMP', OnUpdate: 'CURRENT_TIMESTAMP' }))
230
+ .toBe('timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP');
231
+ });
232
+
233
+ test('quotes string defaults', () => {
234
+ expect(columnDefinition({ Field: 'modifiedBy', Type: 'varchar(32)', NotNull: true, Default: "System's Generated" }))
235
+ .toBe("varchar(32) NOT NULL DEFAULT 'System''s Generated'");
236
+ });
237
+
238
+ test('keeps the live collation and comment when the template has none recorded', () => {
239
+ expect(columnDefinition({ Field: 'IID', Type: 'varchar(25)', Default: 'NULL' }, { Field: 'IID', Type: 'varchar(25)', Collate: 'utf8mb4_unicode_ci', Comment: 'note' }))
240
+ .toBe("varchar(25) COLLATE utf8mb4_unicode_ci NULL DEFAULT NULL COMMENT 'note'");
241
+ });
242
+
243
+ test('a template without ON UPDATE removes it from the live column', () => {
244
+ expect(columnDefinition({ Field: 'd', Type: 'timestamp', Default: 'CURRENT_TIMESTAMP', OnUpdate: null }, { Field: 'd', Type: 'timestamp', OnUpdate: 'CURRENT_TIMESTAMP' }))
245
+ .toBe('timestamp NULL DEFAULT CURRENT_TIMESTAMP');
246
+ });
247
+ });
248
+
249
+ describe('applyDifferences', () => {
250
+ test('generates valid SQL for the store diffs', async () => {
251
+ const connection = mockConnection();
252
+ await applyDifferences(connection, 'Alerts', [
253
+ { type: 'missing_field', tableName: 'Alarms_Log', field: { Field: 'dateUpdated', Type: 'timestamp', NotNull: true, Default: 'CURRENT_TIMESTAMP', OnUpdate: 'CURRENT_TIMESTAMP' } },
254
+ { type: 'mismatched_field', info: 'Type', tableName: 'E2Data', field: { Field: 'DeviceId', Type: 'varchar(45)', NotNull: true }, currentField: { Field: 'DeviceId', Type: 'varchar(75)', NotNull: true } },
255
+ { type: 'missing_index', tableName: 'E2Alarms', indexName: 'idx_e2alarms_alarmstring', index: { ColumnName: ['alarmString(255)'], Name: 'idx_e2alarms_alarmstring' } },
256
+ { type: 'mismatched_index', from: 'primary', tableName: 'SystemCodesHistory', indexName: 'PRIMARY', index: { ColumnName: ['idSystemCodesHistory'], Name: 'PRIMARY' } }
257
+ ]);
258
+ expect(connection.statements).toEqual([
259
+ 'ALTER TABLE `Alarms_Log` ADD COLUMN `dateUpdated` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP;',
260
+ 'ALTER TABLE `E2Data` CHANGE `DeviceId` `DeviceId` varchar(45) NOT NULL;',
261
+ 'ALTER TABLE `E2Alarms` ADD INDEX `idx_e2alarms_alarmstring` (`alarmString`(255));',
262
+ 'ALTER TABLE `SystemCodesHistory` DROP PRIMARY KEY, ADD PRIMARY KEY (`idSystemCodesHistory`);'
263
+ ]);
264
+ });
265
+
266
+ test('tries every difference, then returns the failures', async () => {
267
+ const connection = mockConnection(['`a`']);
268
+ const diffs = [
269
+ { type: 'missing_field', tableName: 't', field: { Field: 'a', Type: 'int' } },
270
+ { type: 'missing_field', tableName: 't', field: { Field: 'b', Type: 'int' } }
271
+ ];
272
+ expect(await applyDifferences(connection, 'db', diffs)).toEqual({
273
+ attempted: 2,
274
+ failures: [{ type: 'missing_field', tableName: 't', description: 'missing_field t.a', message: 'failed on `a`' }],
275
+ warnings: []
276
+ });
277
+ expect(connection.statements).toHaveLength(2);
278
+ });
279
+ });
280
+
281
+ describe('mismatched procedures', () => {
282
+ test('include the database definition alongside the template one', () => {
283
+ const expected = { tables: {}, procedures: [{ Name: 'P', Definition: 'CREATE PROCEDURE `P`() SELECT 1' }] };
284
+ const current = { tables: {}, procedures: [{ Name: 'P', Definition: 'CREATE PROCEDURE `P`() SELECT 2' }] };
285
+ expect(findDifferences(expected, current)).toEqual([{
286
+ type: 'mismatched_procedure',
287
+ Name: 'P',
288
+ Definition: 'CREATE PROCEDURE `P`() SELECT 1',
289
+ currentDefinition: 'CREATE PROCEDURE `P`() SELECT 2'
290
+ }]);
291
+ });
292
+ });
293
+
294
+ describe('templates written by older versions', () => {
295
+ const createSQL = `CREATE TABLE \`SystemCodesHistory\` (
296
+ \`id\` int NOT NULL AUTO_INCREMENT,
297
+ \`changeType\` enum('INSERT','UPDATE','DELETE') NOT NULL,
298
+ \`modifiedBy\` varchar(32) NOT NULL DEFAULT 'System Generated',
299
+ \`recallTime\` decimal(10,2) NOT NULL DEFAULT '60.00',
300
+ \`assetId\` int NOT NULL,
301
+ PRIMARY KEY (\`id\`),
302
+ CONSTRAINT \`fk_asset\` FOREIGN KEY (\`assetId\`) REFERENCES \`Alerts\`.\`Assets\` (\`idAsset\`)
303
+ ) ENGINE=InnoDB DEFAULT CHARSET=latin1`;
304
+ // What the old parser stored for that createSQL
305
+ const legacyColumns = [
306
+ { Field: 'id', Type: 'int', NotNull: true, AutoIncrement: true },
307
+ { Field: 'changeType', Type: 'enum' },
308
+ { Field: 'modifiedBy', Type: 'varchar(32)', NotNull: true, Default: 'System' },
309
+ { Field: 'recallTime', Type: 'decimal(10,2)' },
310
+ { Field: 'assetId', Type: 'int', NotNull: true },
311
+ { Field: 'fk_asset', Type: 'FOREIGN' }
312
+ ];
313
+
314
+ test('show no differences against a database that matches the createSQL', () => {
315
+ const template = templateStructure(createSQL + ';', legacyColumns, [{ ColumnName: ['id'], Primary: true, Name: 'PRIMARY' }]);
316
+ expect(findDifferences(template, liveStructure(createSQL))).toEqual([]);
317
+ });
318
+
319
+ test('the createSQL is what counts, not edits made only to the JSON', () => {
320
+ const edited = legacyColumns.map(c => c.Field === 'modifiedBy' ? { ...c, Default: 'Someone Else' } : c);
321
+ const template = templateStructure(createSQL + ';', edited, [{ ColumnName: ['id'], Primary: true, Name: 'PRIMARY' }]);
322
+ expect(findDifferences(template, liveStructure(createSQL))).toEqual([]);
323
+ });
324
+ });
325
+
326
+ describe('columns the old parser lost attributes from', () => {
327
+ test('quoted parts of defaults and generated expressions are kept', () => {
328
+ expect(parseColumnDefinition("`flag` bit(1) NOT NULL DEFAULT b'0',").Default).toBe("b'0'");
329
+ expect(parseColumnDefinition("`tags` json DEFAULT (_utf8mb4'[]'),").Default).toBe("(_utf8mb4'[]')");
330
+ expect(parseColumnDefinition("`c` varchar(20) GENERATED ALWAYS AS (concat(_utf8mb4'a',`b`)) STORED,").Generated).toBe("(concat(_utf8mb4'a',`b`)) STORED");
331
+ expect(parseColumnDefinition("`n` int unsigned NOT NULL DEFAULT '0',").Type).toBe('int unsigned');
332
+ });
333
+
334
+ test('column SQL keeps bit literals, unsigned and generated expressions', () => {
335
+ expect(columnDefinition({ Field: 'flag', Type: 'bit(1)', NotNull: true, Default: "b'0'" })).toBe("bit(1) NOT NULL DEFAULT b'0'");
336
+ expect(columnDefinition({ Field: 'n', Type: 'int unsigned', NotNull: true, Default: '0' })).toBe("int unsigned NOT NULL DEFAULT '0'");
337
+ expect(columnDefinition(
338
+ { Field: 'c', Type: 'varchar(20)', Default: 'NULL' },
339
+ { Field: 'c', Type: 'varchar(20)', Generated: "(concat(_utf8mb4'a',`b`)) STORED" }
340
+ )).toBe("varchar(20) GENERATED ALWAYS AS (concat(_utf8mb4'a',`b`)) STORED NULL");
341
+ });
342
+
343
+ test('integer display widths from older MySQL versions still match, unsigned included', () => {
344
+ const template = templateStructure('CREATE TABLE `t` (\n `a` int unsigned NOT NULL\n);', [{ Field: 'a', Type: 'int unsigned', NotNull: true }], []);
345
+ expect(findDifferences(template, liveStructure('CREATE TABLE `t` (\n `a` int(10) unsigned NOT NULL\n)'))).toEqual([]);
346
+ expect(findDifferences(template, liveStructure('CREATE TABLE `t` (\n `a` int(10) NOT NULL\n)')).map(d => d.info)).toEqual(['Type']);
347
+ });
348
+ });
@@ -0,0 +1,72 @@
1
+ import fs from 'fs';
2
+ import os from 'os';
3
+ import path from 'path';
4
+ import { splitSqlFile, findTemplateDrift, resolveTemplateDrift } from '../src/templateDrift';
5
+ import { parseCreateTableSQL } from '../src/parsers';
6
+
7
+ const jsonSQL = `CREATE TABLE \`CurrentValues\` (
8
+ \`mapplicID\` varchar(150) NOT NULL,
9
+ \`lastValueUpdated\` timestamp NULL DEFAULT NULL,
10
+ PRIMARY KEY (\`mapplicID\`)
11
+ ) ENGINE=InnoDB DEFAULT CHARSET=latin1`;
12
+ const olderSQL = jsonSQL.replace(' `lastValueUpdated` timestamp NULL DEFAULT NULL,\n', '');
13
+ const notes = '\n-- Notes about these columns, written by hand\n-- that must survive either choice\n';
14
+
15
+ const setup = (sqlFile) => {
16
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'mysqlsyncr-drift-'));
17
+ fs.mkdirSync(path.join(dir, 'currentCOMPortData', 'tables'), { recursive: true });
18
+ const parsed = parseCreateTableSQL(jsonSQL);
19
+ fs.writeFileSync(path.join(dir, 'currentCOMPortData', 'currentCOMPortData.json'),
20
+ JSON.stringify({ tables: { CurrentValues: { columns: parsed.columns, indexes: parsed.indexes, constraints: [], triggers: [{ Name: 't' }], createSQL: jsonSQL } } }));
21
+ fs.writeFileSync(path.join(dir, 'currentCOMPortData', 'tables', 'CurrentValues.sql'), sqlFile);
22
+ return {
23
+ dir,
24
+ sqlFile: () => fs.readFileSync(path.join(dir, 'currentCOMPortData', 'tables', 'CurrentValues.sql'), 'utf-8'),
25
+ table: () => JSON.parse(fs.readFileSync(path.join(dir, 'currentCOMPortData', 'currentCOMPortData.json'), 'utf-8')).tables.CurrentValues
26
+ };
27
+ };
28
+
29
+ describe('splitSqlFile', () => {
30
+ test('separates the statement from notes after it', () => {
31
+ expect(splitSqlFile(`${olderSQL};\n${notes}`)).toEqual({ before: '', statement: olderSQL, semicolon: true, after: notes });
32
+ });
33
+ });
34
+
35
+ describe('findTemplateDrift', () => {
36
+ test('notes after the statement are not a difference', async () => {
37
+ const { dir } = setup(`${jsonSQL};\n${notes}`);
38
+ expect(await findTemplateDrift(dir)).toEqual([]);
39
+ });
40
+
41
+ test('a different statement is reported with both versions', async () => {
42
+ const { dir } = setup(`${olderSQL}\n`);
43
+ expect(await findTemplateDrift(dir)).toEqual([
44
+ { database: 'currentCOMPortData', table: 'CurrentValues', file: 'tables/CurrentValues.sql', json: jsonSQL, sql: olderSQL }
45
+ ]);
46
+ });
47
+ });
48
+
49
+ describe('resolveTemplateDrift', () => {
50
+ test('keeping the JSON rewrites the statement in the .sql file but keeps its notes', async () => {
51
+ const { dir, sqlFile } = setup(`${olderSQL};\n${notes}`);
52
+ await resolveTemplateDrift(dir, 'currentCOMPortData', 'CurrentValues', 'json');
53
+ expect(sqlFile()).toBe(`${jsonSQL};\n${notes}`);
54
+ expect(await findTemplateDrift(dir)).toEqual([]);
55
+ });
56
+
57
+ test('keeping the .sql file updates the JSON createSQL, columns and indexes', async () => {
58
+ const { dir, sqlFile, table } = setup(`${olderSQL};\n${notes}`);
59
+ await resolveTemplateDrift(dir, 'currentCOMPortData', 'CurrentValues', 'sql');
60
+ expect(table().createSQL).toBe(olderSQL);
61
+ expect(table().columns.map(c => c.Field)).toEqual(['mapplicID']);
62
+ expect(table().triggers).toEqual([{ Name: 't' }]);
63
+ expect(sqlFile()).toBe(`${olderSQL};\n${notes}`);
64
+ expect(await findTemplateDrift(dir)).toEqual([]);
65
+ });
66
+
67
+ test('refuses an unknown choice or a file for another table', async () => {
68
+ const { dir } = setup(olderSQL.replace('`CurrentValues`', '`Other`'));
69
+ await expect(resolveTemplateDrift(dir, 'currentCOMPortData', 'CurrentValues', 'both')).rejects.toThrow("keep must be 'json' or 'sql'");
70
+ await expect(resolveTemplateDrift(dir, 'currentCOMPortData', 'CurrentValues', 'sql')).rejects.toThrow("doesn't hold a CREATE TABLE for CurrentValues");
71
+ });
72
+ });