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.
- package/README.md +59 -0
- package/__tests__/acceptCurrent.test.js +117 -0
- package/__tests__/columnAttributes.test.js +101 -0
- package/__tests__/options.test.js +259 -0
- package/__tests__/parser.test.js +167 -166
- package/__tests__/parser2.test.js +155 -152
- package/__tests__/paths.test.js +21 -0
- package/__tests__/prompt.test.js +106 -0
- package/__tests__/sync.test.js +348 -0
- package/__tests__/templateDrift.test.js +72 -0
- package/__tests__/versions.test.js +138 -0
- package/dist/index.cjs +4059 -1733
- package/dist/index.js +4101 -1777
- package/package.json +66 -59
- package/rollup.config.js +2 -1
- package/src/DBStructureChecker.js +47 -0
- package/src/acceptCurrent.js +225 -0
- package/src/apply.js +254 -136
- package/src/cli.js +61 -34
- package/src/compare.js +106 -92
- package/src/differences.js +258 -212
- package/src/dump.js +63 -54
- package/src/getters.js +151 -139
- package/src/index.js +223 -169
- package/src/loggers.js +156 -139
- package/src/normalizers.js +28 -6
- package/src/options.js +133 -0
- package/src/parsers.js +82 -36
- package/src/paths.js +41 -0
- package/src/prompt.js +109 -0
- package/src/rebuild.js +106 -0
- package/src/rows.js +46 -0
- package/src/socketServer.js +106 -24
- package/src/templateDrift.js +93 -0
- package/src/versions.js +112 -0
- package/ui/README.md +55 -40
- package/ui/dist/spa/assets/KFOMCnqEu92Fr1ME7kSn66aGLdTylUAMQXC89YmC2DPNWuYjalmUiAw-k1h7X_-h.woff +0 -0
- package/ui/dist/spa/assets/KFOMCnqEu92Fr1ME7kSn66aGLdTylUAMQXC89YmC2DPNWuZtalmUiAw-B7du-70m.woff +0 -0
- package/ui/dist/spa/assets/KFOMCnqEu92Fr1ME7kSn66aGLdTylUAMQXC89YmC2DPNWuaabVmUiAw-CoAZ_DKt.woff +0 -0
- package/ui/dist/spa/assets/KFOMCnqEu92Fr1ME7kSn66aGLdTylUAMQXC89YmC2DPNWub2bVmUiAw-D0406B4n.woff +0 -0
- package/ui/dist/spa/assets/KFOMCnqEu92Fr1ME7kSn66aGLdTylUAMQXC89YmC2DPNWubEbFmUiAw-CnAg2DeQ.woff +0 -0
- package/ui/dist/spa/assets/KFOMCnqEu92Fr1ME7kSn66aGLdTylUAMQXC89YmC2DPNWubEbVmUiAw-BG9VWE5v.woff +0 -0
- package/ui/dist/spa/assets/MainLayout-DLH1Kfg-.js +1 -0
- package/ui/dist/spa/assets/axios-2hQ_j_Tz.js +1 -0
- package/ui/dist/spa/assets/flUhRq6tzZclQEJ-Vdg-IuiaDsNa-OUIwM9U8.woff +0 -0
- package/ui/dist/spa/assets/index-Cqrg985P.js +23 -0
- package/ui/dist/spa/assets/index-pOWtXlXQ.css +1 -0
- package/ui/dist/spa/index.html +2 -2
- package/ui/package.json +43 -42
- package/ui/src/components/DefinitionDiff.vue +169 -0
- package/ui/src/pages/MainComponent.vue +704 -78
- package/ui/dist/spa/assets/KFOkCnqEu92Fr1MmgVxIIzQ-Y2mpUUkw.woff +0 -0
- package/ui/dist/spa/assets/KFOlCnqEu92Fr1MmEU9fBBc--ViM7Ag4Q.woff +0 -0
- package/ui/dist/spa/assets/KFOlCnqEu92Fr1MmSU5fBBc--CrFzpgl6.woff +0 -0
- package/ui/dist/spa/assets/KFOlCnqEu92Fr1MmWUlfBBc--CW3_XjG_.woff +0 -0
- package/ui/dist/spa/assets/KFOlCnqEu92Fr1MmYUtfBBc--Dr8fnyGc.woff +0 -0
- package/ui/dist/spa/assets/KFOmCnqEu92Fr1Mu4mxM-DAYh6T4l.woff +0 -0
- package/ui/dist/spa/assets/MainLayout-CvQD3xKr.js +0 -1
- package/ui/dist/spa/assets/axios-C0XcclIb.js +0 -1
- package/ui/dist/spa/assets/flUhRq6tzZclQEJ-Vdg-IuiaDsNa-Dr0goTwe.woff +0 -0
- package/ui/dist/spa/assets/index-CwWo8xmP.js +0 -40
- package/ui/dist/spa/assets/index-kXpA8n8r.css +0 -5
package/README.md
CHANGED
|
@@ -63,6 +63,9 @@ mysqlsyncr <command> [options]
|
|
|
63
63
|
- `compare`: Compares the database structure with JSON dumps (dry run).
|
|
64
64
|
- `update`: Updates the database structure based on JSON dumps.
|
|
65
65
|
- `ui`: Opens a web-based database browser in your default system browser.
|
|
66
|
+
- `rebuild`: Rebuilds each table's columns and indexes in the dump's JSON from its `CREATE TABLE` statement, and lists what changes. Dry run unless `--write` is given. Needs no database connection.
|
|
67
|
+
|
|
68
|
+
Run `mysqlsyncr` with no command in a terminal and it asks for the command and its options (host, port, user, password, database and folder) instead. The password isn't shown as you type, and `update` asks for confirmation before changing anything.
|
|
66
69
|
|
|
67
70
|
### Configuration Options
|
|
68
71
|
|
|
@@ -79,6 +82,60 @@ You can configure the following options when running the commands:
|
|
|
79
82
|
| `database` | `-d` | Target database | `null` |
|
|
80
83
|
| `output` | `-o` | Output directory for dumping. Default is one level up from the current directory in a new folder called `db-dump` | `../db-dump` |
|
|
81
84
|
| `verbose` | `-v` | Enable verbose logging (boolean) | `false` |
|
|
85
|
+
| `write` | | With `rebuild`, write the rebuilt files (boolean) | `false` |
|
|
86
|
+
|
|
87
|
+
### Sync Options File
|
|
88
|
+
|
|
89
|
+
Put a `syncOptions.json` file in the dump directory (next to the database folders) to control table creation and seed rows. It's optional; without it everything behaves as before.
|
|
90
|
+
|
|
91
|
+
```json
|
|
92
|
+
{
|
|
93
|
+
"definer": "root@%",
|
|
94
|
+
"autoIncrement": false,
|
|
95
|
+
"databases": {
|
|
96
|
+
"Alerts": {
|
|
97
|
+
"AlarmRules": {
|
|
98
|
+
"autoIncrement": 1000,
|
|
99
|
+
"rows": [
|
|
100
|
+
{ "idAlarmRule": 1, "name": "Default rule" }
|
|
101
|
+
]
|
|
102
|
+
},
|
|
103
|
+
"Settings": {
|
|
104
|
+
"rowKey": ["settingName"],
|
|
105
|
+
"rows": [
|
|
106
|
+
{ "settingName": "emailEnabled", "value": "1" }
|
|
107
|
+
]
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
**`autoIncrement`** sets the table's `AUTO_INCREMENT=N` counter in `CREATE TABLE`. The `AUTO_INCREMENT` attribute on the id column is never changed. Set it at the top level as the default for every table, and per table to override it.
|
|
115
|
+
|
|
116
|
+
| Value | Effect |
|
|
117
|
+
|-------|--------|
|
|
118
|
+
| `true` | Keep the counter as dumped (default) |
|
|
119
|
+
| `false` | Remove the counter, so new tables start at 1 |
|
|
120
|
+
| A number | Start new tables at that number |
|
|
121
|
+
|
|
122
|
+
It applies when `dump` saves a table, when `update` creates a missing table, and when `rebuild` rewrites a template. The counter is always ignored when comparing tables.
|
|
123
|
+
|
|
124
|
+
**`rows`** are seed rows. `compare` reports each row that isn't in the table as `missing_row`, and `update` inserts it after any table and column changes. A row counts as present when a row with the same `rowKey` values exists, so existing rows are never changed. `rowKey` defaults to the table's primary key; every row must include the key columns.
|
|
125
|
+
|
|
126
|
+
**`definer`** sets the `DEFINER` of every stored procedure, view and trigger, written as `root@%` or `` `root`@`%` ``. The template's definers are replaced with it before comparing, so an object in the database with a different definer is reported and `update` recreates it with this one. `dump` and Keep Current in the UI also save it. The MySQL user running the sync needs the `SET_USER_ID` (or `SUPER`) privilege to create objects for another definer, and the definer user should exist.
|
|
127
|
+
|
|
128
|
+
### MySQL Versions
|
|
129
|
+
|
|
130
|
+
MySQL 5.7 and 8.0 print some definitions differently. These are formatting, not structure, so they're ignored when comparing tables, procedures, triggers and views:
|
|
131
|
+
|
|
132
|
+
- Integer display widths: 5.7 prints `int(11)`, 8.0.19 and later print `int`. `tinyint(1)` and `ZEROFILL` types keep their width on both.
|
|
133
|
+
- `YEAR(4)` is printed as `year` from 8.0.19.
|
|
134
|
+
- `utf8` is printed as `utf8mb3` from 8.0.24 (fully from 8.0.30).
|
|
135
|
+
|
|
136
|
+
`dump` records the server's version in `dumpInfo.json`. For templates dumped before that, the version is inferred from the SQL. `compare`, `update` and the UI show the template's and the server's versions and note what differs between them.
|
|
137
|
+
|
|
138
|
+
When applying a template from 8.0 to a 5.7 server, the `utf8mb4_0900_*` collations 5.7 doesn't have are replaced with `utf8mb4_unicode_ci` (`utf8mb4_bin` for accent- or case-sensitive ones), and each substitution is reported as a warning. Applying also sets `explicit_defaults_for_timestamp` for the session, as 8.0 does by default. Expression defaults such as `DEFAULT (...)` need 8.0.13 or later and fail on 5.7.
|
|
82
139
|
|
|
83
140
|
### Example Usage
|
|
84
141
|
|
|
@@ -181,6 +238,8 @@ Contributions are welcome! If you would like to contribute to MySQLSyncr, please
|
|
|
181
238
|
4. Push your changes to your forked repository.
|
|
182
239
|
5. Open a pull request to the main repository.
|
|
183
240
|
|
|
241
|
+
Before committing, run `npm test` and `npm run build`, which rebuilds `dist/` from `src/`. If you changed the UI (`ui/src`), also rebuild `ui/dist` with `npm run build` in `ui/`. That needs Node.js 22.22.0 or later; see [ui/README.md](ui/README.md). Running or installing mysqlsyncr has no such requirement.
|
|
242
|
+
|
|
184
243
|
|
|
185
244
|
## License
|
|
186
245
|
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import fs from 'fs';
|
|
2
|
+
import os from 'os';
|
|
3
|
+
import path from 'path';
|
|
4
|
+
import { replaceCreateSQLElement, acceptCurrentIntoTemplate } from '../src/acceptCurrent';
|
|
5
|
+
import { parseCreateTableSQL } from '../src/parsers';
|
|
6
|
+
import { findDifferences } from '../src/differences';
|
|
7
|
+
|
|
8
|
+
const templateSQL = `CREATE TABLE \`E2Data\` (
|
|
9
|
+
\`IID\` varchar(25) DEFAULT NULL,
|
|
10
|
+
\`DeviceId\` varchar(45) NOT NULL,
|
|
11
|
+
\`oldColumn\` int DEFAULT NULL,
|
|
12
|
+
PRIMARY KEY (\`IID\`),
|
|
13
|
+
KEY \`IndexIID\` (\`IID\`)
|
|
14
|
+
) ENGINE=InnoDB DEFAULT CHARSET=latin1`;
|
|
15
|
+
|
|
16
|
+
const liveSQL = `CREATE TABLE \`E2Data\` (
|
|
17
|
+
\`IID\` varchar(25) DEFAULT NULL,
|
|
18
|
+
\`DeviceId\` varchar(75) NOT NULL,
|
|
19
|
+
\`newColumn\` int NOT NULL DEFAULT '0',
|
|
20
|
+
PRIMARY KEY (\`IID\`),
|
|
21
|
+
KEY \`IndexIID\` (\`IID\`,\`DeviceId\`),
|
|
22
|
+
KEY \`idx_new\` (\`newColumn\`)
|
|
23
|
+
) ENGINE=InnoDB AUTO_INCREMENT=55 DEFAULT CHARSET=latin1`;
|
|
24
|
+
|
|
25
|
+
describe('replaceCreateSQLElement', () => {
|
|
26
|
+
test('replaces a changed column line', () => {
|
|
27
|
+
const result = replaceCreateSQLElement(templateSQL, liveSQL, 'col:DeviceId');
|
|
28
|
+
expect(result).toContain(' `DeviceId` varchar(75) NOT NULL,\n');
|
|
29
|
+
expect(result.split('\n')).toHaveLength(templateSQL.split('\n').length);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
test('removes a column the database does not have', () => {
|
|
33
|
+
expect(replaceCreateSQLElement(templateSQL, liveSQL, 'col:oldColumn')).not.toContain('oldColumn');
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
test('adds a column after the one before it in the database', () => {
|
|
37
|
+
const result = replaceCreateSQLElement(templateSQL, liveSQL, 'col:newColumn');
|
|
38
|
+
expect(result).toContain(" `DeviceId` varchar(45) NOT NULL,\n `newColumn` int NOT NULL DEFAULT '0',\n `oldColumn`");
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test('adds, replaces and removes indexes, keeping commas valid', () => {
|
|
42
|
+
let result = replaceCreateSQLElement(templateSQL, liveSQL, 'idx:idx_new');
|
|
43
|
+
expect(result).toContain(' KEY `IndexIID` (`IID`),\n KEY `idx_new` (`newColumn`)\n) ENGINE');
|
|
44
|
+
result = replaceCreateSQLElement(result, liveSQL, 'idx:IndexIID');
|
|
45
|
+
expect(result).toContain('KEY `IndexIID` (`IID`,`DeviceId`),');
|
|
46
|
+
result = replaceCreateSQLElement(result, templateSQL, 'idx:idx_new');
|
|
47
|
+
expect(result).toContain(' KEY `IndexIID` (`IID`,`DeviceId`)\n) ENGINE');
|
|
48
|
+
expect(parseCreateTableSQL(result).indexes.map(i => i.Name)).toEqual(['PRIMARY', 'IndexIID']);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test('leaves the AUTO_INCREMENT counter of the template alone', () => {
|
|
52
|
+
expect(replaceCreateSQLElement(templateSQL, liveSQL, 'col:DeviceId')).not.toContain('AUTO_INCREMENT=');
|
|
53
|
+
});
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
describe('acceptCurrentIntoTemplate', () => {
|
|
57
|
+
// Answers the queries getDatabaseStructure makes for a database holding liveSQL
|
|
58
|
+
const liveConnection = () => ({
|
|
59
|
+
query: async (sql) => {
|
|
60
|
+
if (sql.startsWith('SHOW FULL TABLES WHERE')) return [{ Tables_in_db: 'E2Data' }];
|
|
61
|
+
if (sql.startsWith('SHOW CREATE TABLE')) return [{ 'Create Table': liveSQL }];
|
|
62
|
+
return [];
|
|
63
|
+
}
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
const writeTemplate = () => {
|
|
67
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'mysqlsyncr-accept-'));
|
|
68
|
+
fs.mkdirSync(path.join(dir, 'currentCOMPortData', 'tables'), { recursive: true });
|
|
69
|
+
const parsed = parseCreateTableSQL(templateSQL);
|
|
70
|
+
const template = {
|
|
71
|
+
tables: {
|
|
72
|
+
E2Data: { columns: parsed.columns, indexes: parsed.indexes, constraints: [], triggers: [], createSQL: templateSQL },
|
|
73
|
+
Dropped: { columns: [], indexes: [], constraints: [], triggers: [], createSQL: 'CREATE TABLE `Dropped` (\n `a` int\n)' }
|
|
74
|
+
},
|
|
75
|
+
procedures: []
|
|
76
|
+
};
|
|
77
|
+
fs.writeFileSync(path.join(dir, 'currentCOMPortData', 'currentCOMPortData.json'), JSON.stringify(template));
|
|
78
|
+
fs.writeFileSync(path.join(dir, 'currentCOMPortData', 'tables', 'Dropped.sql'), 'x');
|
|
79
|
+
return dir;
|
|
80
|
+
};
|
|
81
|
+
const readTemplate = (dir) => JSON.parse(fs.readFileSync(path.join(dir, 'currentCOMPortData', 'currentCOMPortData.json'), 'utf-8'));
|
|
82
|
+
|
|
83
|
+
test('keeping every difference leaves none', async () => {
|
|
84
|
+
const dir = writeTemplate();
|
|
85
|
+
const live = { tables: { E2Data: { ...parseCreateTableSQL(liveSQL), triggers: [], createSQL: liveSQL } }, procedures: [] };
|
|
86
|
+
const diffs = findDifferences(readTemplate(dir), live);
|
|
87
|
+
expect(diffs.map(d => d.type).sort()).toEqual(['extra_field', 'extra_index', 'extra_index', 'missing_field', 'missing_table', 'mismatched_field'].sort());
|
|
88
|
+
|
|
89
|
+
await acceptCurrentIntoTemplate(liveConnection(), dir, 'currentCOMPortData', diffs);
|
|
90
|
+
|
|
91
|
+
const template = readTemplate(dir);
|
|
92
|
+
expect(findDifferences(template, live)).toEqual([]);
|
|
93
|
+
expect(template.tables.Dropped).toBeUndefined();
|
|
94
|
+
expect(fs.existsSync(path.join(dir, 'currentCOMPortData', 'tables', 'Dropped.sql'))).toBe(false);
|
|
95
|
+
expect(fs.readFileSync(path.join(dir, 'currentCOMPortData', 'tables', 'E2Data.sql'), 'utf-8')).toBe(`${template.tables.E2Data.createSQL}\n`);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
test('only the chosen difference changes', async () => {
|
|
99
|
+
const dir = writeTemplate();
|
|
100
|
+
await acceptCurrentIntoTemplate(liveConnection(), dir, 'currentCOMPortData', [
|
|
101
|
+
{ type: 'mismatched_field', info: 'Type', tableName: 'E2Data', field: { Field: 'DeviceId', Type: 'varchar(45)', NotNull: true } }
|
|
102
|
+
]);
|
|
103
|
+
const table = readTemplate(dir).tables.E2Data;
|
|
104
|
+
expect(table.columns.map(c => c.Field)).toEqual(['IID', 'DeviceId', 'oldColumn']);
|
|
105
|
+
expect(table.columns[1].Type).toBe('varchar(75)');
|
|
106
|
+
expect(table.indexes.find(i => i.Name === 'IndexIID').ColumnName).toEqual(['IID']);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
test('saves what it can and reports the rest', async () => {
|
|
110
|
+
const dir = writeTemplate();
|
|
111
|
+
await expect(acceptCurrentIntoTemplate(liveConnection(), dir, 'currentCOMPortData', [
|
|
112
|
+
{ type: 'missing_row', tableName: 'E2Data', key: { IID: 'x' }, row: { IID: 'x' } },
|
|
113
|
+
{ type: 'missing_table', tableName: 'Dropped' }
|
|
114
|
+
])).rejects.toMatchObject({ errors: [{ difference: 'missing_row E2Data' }] });
|
|
115
|
+
expect(readTemplate(dir).tables.Dropped).toBeUndefined();
|
|
116
|
+
});
|
|
117
|
+
});
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import { parseCreateTableSQL } from '../src/parsers';
|
|
2
|
+
|
|
3
|
+
// Regression tests for column attributes rendered between the type and the
|
|
4
|
+
// NULL/DEFAULT clauses. Before the fix these columns parsed with no Default and
|
|
5
|
+
// no NotNull, which produced phantom `mismatched_field` differences whenever the
|
|
6
|
+
// two sides of a compare rendered COLLATE differently. The parser also records
|
|
7
|
+
// the collation, charset, unsigned and generated expression, so a column can be
|
|
8
|
+
// restated without losing them.
|
|
9
|
+
|
|
10
|
+
const columnOf = (sql, name) =>
|
|
11
|
+
parseCreateTableSQL(sql).columns.find(c => c.Field === name);
|
|
12
|
+
|
|
13
|
+
describe('Column attributes between type and DEFAULT', () => {
|
|
14
|
+
|
|
15
|
+
test('COLLATE does not swallow DEFAULT NULL', () => {
|
|
16
|
+
const input = `CREATE TABLE \`E2Alarms\` (
|
|
17
|
+
\`IID\` varchar(25) COLLATE utf8mb4_unicode_ci DEFAULT NULL,
|
|
18
|
+
PRIMARY KEY (\`IID\`)
|
|
19
|
+
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`;
|
|
20
|
+
|
|
21
|
+
expect(columnOf(input, 'IID')).toEqual({
|
|
22
|
+
Field: 'IID',
|
|
23
|
+
Type: 'varchar(25)',
|
|
24
|
+
Collate: 'utf8mb4_unicode_ci',
|
|
25
|
+
Default: 'NULL'
|
|
26
|
+
});
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
test('a collated column parses the same with or without the COLLATE clause, apart from the collation', () => {
|
|
30
|
+
const withCollate = `CREATE TABLE \`T\` (
|
|
31
|
+
\`point\` varchar(45) COLLATE utf8mb4_unicode_ci DEFAULT NULL
|
|
32
|
+
)`;
|
|
33
|
+
const withoutCollate = `CREATE TABLE \`T\` (
|
|
34
|
+
\`point\` varchar(45) DEFAULT NULL
|
|
35
|
+
)`;
|
|
36
|
+
|
|
37
|
+
const { Collate, ...rest } = columnOf(withCollate, 'point');
|
|
38
|
+
expect(Collate).toBe('utf8mb4_unicode_ci');
|
|
39
|
+
expect(rest).toEqual(columnOf(withoutCollate, 'point'));
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test('CHARACTER SET and COLLATE together do not swallow NOT NULL or DEFAULT', () => {
|
|
43
|
+
const input = `CREATE TABLE \`T\` (
|
|
44
|
+
\`name\` varchar(45) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'x'
|
|
45
|
+
)`;
|
|
46
|
+
|
|
47
|
+
expect(columnOf(input, 'name')).toEqual({
|
|
48
|
+
Field: 'name',
|
|
49
|
+
Type: 'varchar(45)',
|
|
50
|
+
Charset: 'utf8mb4',
|
|
51
|
+
Collate: 'utf8mb4_unicode_ci',
|
|
52
|
+
NotNull: true,
|
|
53
|
+
Default: 'x'
|
|
54
|
+
});
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
test('unsigned does not swallow NOT NULL, DEFAULT or AUTO_INCREMENT, and stays part of the type', () => {
|
|
58
|
+
const input = `CREATE TABLE \`T\` (
|
|
59
|
+
\`qty\` int(11) unsigned NOT NULL DEFAULT '0',
|
|
60
|
+
\`id\` bigint(20) unsigned NOT NULL AUTO_INCREMENT
|
|
61
|
+
)`;
|
|
62
|
+
|
|
63
|
+
expect(columnOf(input, 'qty')).toEqual({
|
|
64
|
+
Field: 'qty',
|
|
65
|
+
Type: 'int(11) unsigned',
|
|
66
|
+
NotNull: true,
|
|
67
|
+
Default: '0'
|
|
68
|
+
});
|
|
69
|
+
expect(columnOf(input, 'id')).toEqual({
|
|
70
|
+
Field: 'id',
|
|
71
|
+
Type: 'bigint(20) unsigned',
|
|
72
|
+
NotNull: true,
|
|
73
|
+
AutoIncrement: true
|
|
74
|
+
});
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
test('a collated column with no DEFAULT still parses without one', () => {
|
|
78
|
+
const input = `CREATE TABLE \`T\` (
|
|
79
|
+
\`alarmString\` text COLLATE utf8mb4_unicode_ci
|
|
80
|
+
)`;
|
|
81
|
+
|
|
82
|
+
expect(columnOf(input, 'alarmString')).toEqual({
|
|
83
|
+
Field: 'alarmString',
|
|
84
|
+
Type: 'text',
|
|
85
|
+
Collate: 'utf8mb4_unicode_ci'
|
|
86
|
+
});
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
test('a collated generated column is not given a DEFAULT', () => {
|
|
90
|
+
const input = `CREATE TABLE \`T\` (
|
|
91
|
+
\`computed_tablename\` varchar(255) COLLATE utf8mb4_unicode_ci GENERATED ALWAYS AS (concat(_utf8mb4'E2Data||',\`IID\`,_utf8mb4'||',\`point\`)) STORED
|
|
92
|
+
)`;
|
|
93
|
+
|
|
94
|
+
expect(columnOf(input, 'computed_tablename')).toEqual({
|
|
95
|
+
Field: 'computed_tablename',
|
|
96
|
+
Type: 'varchar(255)',
|
|
97
|
+
Collate: 'utf8mb4_unicode_ci',
|
|
98
|
+
Generated: "(concat(_utf8mb4'E2Data||',`IID`,_utf8mb4'||',`point`)) STORED"
|
|
99
|
+
});
|
|
100
|
+
});
|
|
101
|
+
});
|
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
import fs from 'fs';
|
|
2
|
+
import os from 'os';
|
|
3
|
+
import path from 'path';
|
|
4
|
+
import { applyAutoIncrementOption, getTableOptions, loadSyncOptions, OPTIONS_FILE } from '../src/options';
|
|
5
|
+
import { findDifferences } from '../src/differences';
|
|
6
|
+
import { findMissingRows } from '../src/rows';
|
|
7
|
+
import { applyDifferences } from '../src/apply';
|
|
8
|
+
import { rebuildTemplate } from '../src/rebuild';
|
|
9
|
+
import { parseCreateTableSQL } from '../src/parsers';
|
|
10
|
+
|
|
11
|
+
const createSQL = `CREATE TABLE \`AlarmRules\` (
|
|
12
|
+
\`idAlarmRule\` int NOT NULL AUTO_INCREMENT,
|
|
13
|
+
\`name\` varchar(45) NOT NULL DEFAULT 'System Generated',
|
|
14
|
+
PRIMARY KEY (\`idAlarmRule\`)
|
|
15
|
+
) ENGINE=InnoDB AUTO_INCREMENT=400101 DEFAULT CHARSET=latin1`;
|
|
16
|
+
|
|
17
|
+
const tempDir = () => fs.mkdtempSync(path.join(os.tmpdir(), 'mysqlsyncr-'));
|
|
18
|
+
|
|
19
|
+
describe('applyAutoIncrementOption', () => {
|
|
20
|
+
test('true keeps the counter', () => {
|
|
21
|
+
expect(applyAutoIncrementOption(createSQL, true)).toBe(createSQL);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
test('false removes the counter but not the column attribute', () => {
|
|
25
|
+
const result = applyAutoIncrementOption(createSQL, false);
|
|
26
|
+
expect(result).not.toContain('AUTO_INCREMENT=');
|
|
27
|
+
expect(result).toContain('`idAlarmRule` int NOT NULL AUTO_INCREMENT,');
|
|
28
|
+
expect(result).toContain(') ENGINE=InnoDB DEFAULT CHARSET=latin1');
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
test('a number sets the counter', () => {
|
|
32
|
+
expect(applyAutoIncrementOption(createSQL, 1000)).toContain(') ENGINE=InnoDB AUTO_INCREMENT=1000 DEFAULT CHARSET=latin1');
|
|
33
|
+
expect(applyAutoIncrementOption('CREATE TABLE `t` (\n `id` int\n);', 5)).toBe('CREATE TABLE `t` (\n `id` int\n) AUTO_INCREMENT=5;');
|
|
34
|
+
});
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
describe('sync options file', () => {
|
|
38
|
+
test('a missing file means no options', async () => {
|
|
39
|
+
expect(await loadSyncOptions(tempDir())).toEqual({});
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test('table options override the default', () => {
|
|
43
|
+
const options = { autoIncrement: false, databases: { Alerts: { AlarmRules: { autoIncrement: 1000 } } } };
|
|
44
|
+
expect(getTableOptions(options, 'Alerts', 'AlarmRules').autoIncrement).toBe(1000);
|
|
45
|
+
expect(getTableOptions(options, 'Alerts', 'Other').autoIncrement).toBe(false);
|
|
46
|
+
expect(getTableOptions({}, 'Alerts', 'Other').autoIncrement).toBe(true);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test('rejects invalid values', async () => {
|
|
50
|
+
const dir = tempDir();
|
|
51
|
+
fs.writeFileSync(path.join(dir, OPTIONS_FILE), JSON.stringify({ databases: { Alerts: { AlarmRules: { autoIncrement: 'yes' } } } }));
|
|
52
|
+
await expect(loadSyncOptions(dir)).rejects.toThrow('Alerts.AlarmRules.autoIncrement');
|
|
53
|
+
fs.writeFileSync(path.join(dir, OPTIONS_FILE), JSON.stringify({ databases: { Alerts: { AlarmRules: { rows: { id: 1 } } } } }));
|
|
54
|
+
await expect(loadSyncOptions(dir)).rejects.toThrow('rows must be an array');
|
|
55
|
+
});
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
describe('missing_table with the autoIncrement option', () => {
|
|
59
|
+
const template = { tables: { AlarmRules: { ...parseCreateTableSQL(createSQL), triggers: [], createSQL } }, procedures: [] };
|
|
60
|
+
const empty = { tables: {}, procedures: [] };
|
|
61
|
+
|
|
62
|
+
test('creates the table without the dumped counter', () => {
|
|
63
|
+
const [diff] = findDifferences(template, empty, () => ({ autoIncrement: false }));
|
|
64
|
+
expect(diff.type).toBe('missing_table');
|
|
65
|
+
expect(diff.createSQL).not.toContain('AUTO_INCREMENT=');
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
test('tables differing only by counter match', () => {
|
|
69
|
+
const live = { tables: { AlarmRules: { ...template.tables.AlarmRules, createSQL: createSQL.replace('400101', '7') } }, procedures: [] };
|
|
70
|
+
expect(findDifferences(template, live)).toEqual([]);
|
|
71
|
+
});
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
describe('seed rows', () => {
|
|
75
|
+
const expected = { tables: { AlarmRules: parseCreateTableSQL(createSQL) } };
|
|
76
|
+
const options = { databases: { Alerts: { AlarmRules: { rows: [{ idAlarmRule: 1, name: 'Default' }, { idAlarmRule: 2, name: 'Other' }] } } } };
|
|
77
|
+
|
|
78
|
+
test('reports only rows whose primary key is not found', async () => {
|
|
79
|
+
const queries = [];
|
|
80
|
+
const connection = {
|
|
81
|
+
query: async (sql, values) => {
|
|
82
|
+
queries.push({ sql, values });
|
|
83
|
+
return values[0] === 1 ? [{ 1: 1 }] : [];
|
|
84
|
+
}
|
|
85
|
+
};
|
|
86
|
+
const diffs = await findMissingRows(connection, 'Alerts', expected, expected, options);
|
|
87
|
+
expect(queries[0]).toEqual({ sql: 'SELECT 1 FROM `Alerts`.`AlarmRules` WHERE `idAlarmRule` = ? LIMIT 1', values: [1] });
|
|
88
|
+
expect(diffs).toEqual([{ type: 'missing_row', tableName: 'AlarmRules', key: { idAlarmRule: 2 }, row: { idAlarmRule: 2, name: 'Other' } }]);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
test('every row is missing when the table is missing, without querying it', async () => {
|
|
92
|
+
const connection = { query: jest.fn() };
|
|
93
|
+
const diffs = await findMissingRows(connection, 'Alerts', expected, { tables: {} }, options);
|
|
94
|
+
expect(diffs).toHaveLength(2);
|
|
95
|
+
expect(connection.query).not.toHaveBeenCalled();
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
test('uses rowKey and requires it on each row', async () => {
|
|
99
|
+
const keyed = { databases: { Alerts: { AlarmRules: { rowKey: ['name'], rows: [{ name: 'Default' }] } } } };
|
|
100
|
+
const connection = { query: async () => [] };
|
|
101
|
+
const [diff] = await findMissingRows(connection, 'Alerts', expected, expected, keyed);
|
|
102
|
+
expect(diff.key).toEqual({ name: 'Default' });
|
|
103
|
+
const bad = { databases: { Alerts: { AlarmRules: { rows: [{ name: 'Default' }] } } } };
|
|
104
|
+
await expect(findMissingRows(connection, 'Alerts', expected, expected, bad)).rejects.toThrow('missing rowKey column(s) idAlarmRule');
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
test('rows are inserted after the table is created', async () => {
|
|
108
|
+
const statements = [];
|
|
109
|
+
const connection = { query: async (sql, values) => { if (!sql.startsWith('USE') && !sql.startsWith('SELECT VERSION') && !sql.startsWith('SET SESSION')) statements.push({ sql, values }); return []; } };
|
|
110
|
+
await applyDifferences(connection, 'Alerts', [
|
|
111
|
+
{ type: 'missing_row', tableName: 'AlarmRules', key: { idAlarmRule: 1 }, row: { idAlarmRule: 1, name: "it's" } },
|
|
112
|
+
{ type: 'missing_table', tableName: 'AlarmRules', createSQL: 'CREATE TABLE ...' }
|
|
113
|
+
]);
|
|
114
|
+
expect(statements).toEqual([
|
|
115
|
+
{ sql: 'CREATE TABLE ...', values: undefined },
|
|
116
|
+
{ sql: 'INSERT INTO `AlarmRules` (`idAlarmRule`, `name`) VALUES (?, ?);', values: [1, "it's"] }
|
|
117
|
+
]);
|
|
118
|
+
});
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
describe('rebuildTemplate', () => {
|
|
122
|
+
const writeTemplate = (dir, table) => {
|
|
123
|
+
fs.mkdirSync(path.join(dir, 'Alerts', 'tables'), { recursive: true });
|
|
124
|
+
fs.writeFileSync(path.join(dir, 'Alerts', 'Alerts.json'), JSON.stringify({ tables: { AlarmRules: table } }));
|
|
125
|
+
fs.writeFileSync(path.join(dir, 'Alerts', 'tables', 'AlarmRules.sql'), `${table.createSQL}\n`);
|
|
126
|
+
};
|
|
127
|
+
// what the old parser produced: the default cut at the space
|
|
128
|
+
const oldTable = () => ({
|
|
129
|
+
columns: [
|
|
130
|
+
{ Field: 'idAlarmRule', Type: 'int', NotNull: true, AutoIncrement: true },
|
|
131
|
+
{ Field: 'name', Type: 'varchar(45)', NotNull: true, Default: 'System' }
|
|
132
|
+
],
|
|
133
|
+
indexes: [{ ColumnName: ['idAlarmRule'], Primary: true, Name: 'PRIMARY' }],
|
|
134
|
+
triggers: [],
|
|
135
|
+
createSQL
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
test('dry run reports changes without writing', async () => {
|
|
139
|
+
const dir = tempDir();
|
|
140
|
+
writeTemplate(dir, oldTable());
|
|
141
|
+
const before = fs.readFileSync(path.join(dir, 'Alerts', 'Alerts.json'), 'utf-8');
|
|
142
|
+
const { changes: report } = await rebuildTemplate(dir);
|
|
143
|
+
expect(report['Alerts.AlarmRules']).toEqual([
|
|
144
|
+
'column name: JSON {"Type":"varchar(45)","NotNull":true,"Default":"System"} -> createSQL {"Type":"varchar(45)","NotNull":true,"Default":"System Generated"}'
|
|
145
|
+
]);
|
|
146
|
+
expect(fs.readFileSync(path.join(dir, 'Alerts', 'Alerts.json'), 'utf-8')).toBe(before);
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
test('--write rebuilds the JSON and applies the autoIncrement option', async () => {
|
|
150
|
+
const dir = tempDir();
|
|
151
|
+
writeTemplate(dir, oldTable());
|
|
152
|
+
fs.writeFileSync(path.join(dir, OPTIONS_FILE), JSON.stringify({ autoIncrement: false }));
|
|
153
|
+
await rebuildTemplate(dir, { write: true });
|
|
154
|
+
const table = JSON.parse(fs.readFileSync(path.join(dir, 'Alerts', 'Alerts.json'), 'utf-8')).tables.AlarmRules;
|
|
155
|
+
expect(table.columns[1].Default).toBe('System Generated');
|
|
156
|
+
expect(table.createSQL).not.toContain('AUTO_INCREMENT=');
|
|
157
|
+
expect(table.triggers).toEqual([]);
|
|
158
|
+
expect(fs.readFileSync(path.join(dir, 'Alerts', 'tables', 'AlarmRules.sql'), 'utf-8')).not.toContain('AUTO_INCREMENT=');
|
|
159
|
+
expect((await rebuildTemplate(dir)).changes).toEqual({});
|
|
160
|
+
});
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
describe('definer option', () => {
|
|
164
|
+
const { formatDefiner, applyDefiner, applyDefinerOption } = require('../src/options');
|
|
165
|
+
const procedure = 'CREATE DEFINER=`neelands`@`localhost` PROCEDURE `P`()\nBEGIN\n SELECT 1;\nEND';
|
|
166
|
+
|
|
167
|
+
test('accepts the usual ways of writing user@host', () => {
|
|
168
|
+
expect(formatDefiner('root@%')).toBe('`root`@`%`');
|
|
169
|
+
expect(formatDefiner("'root'@'localhost'")).toBe('`root`@`localhost`');
|
|
170
|
+
expect(formatDefiner('DEFINER=`root`@`%`')).toBe('`root`@`%`');
|
|
171
|
+
expect(formatDefiner('root')).toBeNull();
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
test('rejects an invalid definer in the options file', async () => {
|
|
175
|
+
const dir = tempDir();
|
|
176
|
+
fs.writeFileSync(path.join(dir, OPTIONS_FILE), JSON.stringify({ definer: 'root' }));
|
|
177
|
+
await expect(loadSyncOptions(dir)).rejects.toThrow('definer must look like');
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
test('replaces only the DEFINER clause', () => {
|
|
181
|
+
expect(applyDefiner(procedure, 'root@%')).toBe(procedure.replace('`neelands`@`localhost`', '`root`@`%`'));
|
|
182
|
+
const view = 'CREATE ALGORITHM=UNDEFINED DEFINER=`a`@`b` SQL SECURITY DEFINER VIEW `v` AS select 1';
|
|
183
|
+
expect(applyDefiner(view, 'root@%')).toBe('CREATE ALGORITHM=UNDEFINED DEFINER=`root`@`%` SQL SECURITY DEFINER VIEW `v` AS select 1');
|
|
184
|
+
expect(applyDefiner(procedure, undefined)).toBe(procedure);
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
test('applies to procedures, views and triggers without changing the original', () => {
|
|
188
|
+
const structure = {
|
|
189
|
+
tables: { t: { columns: [], indexes: [], triggers: [{ Name: 'trg', Definition: 'CREATE DEFINER=`x`@`y` TRIGGER `trg` BEFORE INSERT ON `t` FOR EACH ROW SET @a = 1' }] } },
|
|
190
|
+
procedures: [{ Name: 'P', Definition: procedure }],
|
|
191
|
+
views: { v: 'CREATE DEFINER=`x`@`y` VIEW `v` AS select 1' }
|
|
192
|
+
};
|
|
193
|
+
const result = applyDefinerOption(structure, 'root@%');
|
|
194
|
+
expect(result.procedures[0].Definition).toContain('DEFINER=`root`@`%`');
|
|
195
|
+
expect(result.views.v).toContain('DEFINER=`root`@`%`');
|
|
196
|
+
expect(result.tables.t.triggers[0].Definition).toContain('DEFINER=`root`@`%`');
|
|
197
|
+
expect(structure.procedures[0].Definition).toBe(procedure);
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
test('a database object with another definer is reported, and one with the option definer is not', () => {
|
|
201
|
+
const template = applyDefinerOption({ tables: {}, procedures: [{ Name: 'P', Definition: procedure }] }, 'root@%');
|
|
202
|
+
const differentDefiner = { tables: {}, procedures: [{ Name: 'P', Definition: procedure }] };
|
|
203
|
+
const sameDefiner = { tables: {}, procedures: [{ Name: 'P', Definition: applyDefiner(procedure, 'root@%') }] };
|
|
204
|
+
const [diff] = findDifferences(template, differentDefiner);
|
|
205
|
+
expect(diff).toMatchObject({ type: 'mismatched_procedure', Definition: applyDefiner(procedure, 'root@%') });
|
|
206
|
+
expect(findDifferences(template, sameDefiner)).toEqual([]);
|
|
207
|
+
});
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
describe('trigger comparison', () => {
|
|
211
|
+
const table = (definition) => ({
|
|
212
|
+
tables: { t: { columns: [], indexes: [], triggers: [{ Name: 'trg', Definition: definition }], createSQL: 'CREATE TABLE `t` (\n `a` int\n)' } },
|
|
213
|
+
procedures: []
|
|
214
|
+
});
|
|
215
|
+
const trigger = 'CREATE DEFINER=`root`@`%` TRIGGER `trg` BEFORE INSERT ON `t` FOR EACH ROW SET NEW.a = 1';
|
|
216
|
+
|
|
217
|
+
test('a changed trigger body is reported with both definitions', () => {
|
|
218
|
+
const diffs = findDifferences(table(trigger), table(trigger.replace('= 1', '= 2')));
|
|
219
|
+
expect(diffs).toHaveLength(1);
|
|
220
|
+
expect(diffs[0]).toMatchObject({ type: 'mismatched_trigger', trigger: { Definition: trigger }, currentTrigger: { Definition: trigger.replace('= 1', '= 2') } });
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
test('whitespace-only changes are not', () => {
|
|
224
|
+
expect(findDifferences(table(trigger), table(trigger.replace(' FOR EACH', '\n FOR EACH')))).toEqual([]);
|
|
225
|
+
});
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
describe('rebuildTemplate and tables/*.sql', () => {
|
|
229
|
+
const setup = (sqlFile) => {
|
|
230
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'mysqlsyncr-'));
|
|
231
|
+
fs.mkdirSync(path.join(dir, 'Alerts', 'tables'), { recursive: true });
|
|
232
|
+
const clean = createSQL.replace(' AUTO_INCREMENT=400101', '');
|
|
233
|
+
const parsed = parseCreateTableSQL(clean);
|
|
234
|
+
fs.writeFileSync(path.join(dir, 'Alerts', 'Alerts.json'), JSON.stringify({ tables: { AlarmRules: { columns: parsed.columns, indexes: parsed.indexes, constraints: [], triggers: [], createSQL: clean } } }));
|
|
235
|
+
fs.writeFileSync(path.join(dir, OPTIONS_FILE), JSON.stringify({ autoIncrement: false }));
|
|
236
|
+
fs.writeFileSync(path.join(dir, 'Alerts', 'tables', 'AlarmRules.sql'), sqlFile);
|
|
237
|
+
return { dir, clean, read: () => fs.readFileSync(path.join(dir, 'Alerts', 'tables', 'AlarmRules.sql'), 'utf-8'), json: () => fs.readFileSync(path.join(dir, 'Alerts', 'Alerts.json'), 'utf-8') };
|
|
238
|
+
};
|
|
239
|
+
|
|
240
|
+
test('only the counter is removed from a .sql file, keeping notes written in it', async () => {
|
|
241
|
+
const notes = '\n-- Notes about these columns, written by hand\n';
|
|
242
|
+
const { dir, clean, read, json } = setup(`${createSQL};\n${notes}`);
|
|
243
|
+
const jsonBefore = json();
|
|
244
|
+
expect((await rebuildTemplate(dir)).changes).toEqual({ 'Alerts.AlarmRules': ['tables/AlarmRules.sql: AUTO_INCREMENT counter changed by the autoIncrement option'] });
|
|
245
|
+
await rebuildTemplate(dir, { write: true });
|
|
246
|
+
expect(read()).toBe(`${clean};\n${notes}`);
|
|
247
|
+
expect(json()).toBe(jsonBefore);
|
|
248
|
+
expect(await rebuildTemplate(dir)).toMatchObject({ changes: {} });
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
test('other differences in a .sql file are reported, not overwritten', async () => {
|
|
252
|
+
const edited = createSQL.replace(' AUTO_INCREMENT=400101', '').replace(' PRIMARY KEY', ' `extra` int DEFAULT NULL,\n PRIMARY KEY');
|
|
253
|
+
const { dir, read } = setup(`${edited}\n`);
|
|
254
|
+
const { changes, notes } = await rebuildTemplate(dir, { write: true });
|
|
255
|
+
expect(changes).toEqual({});
|
|
256
|
+
expect(notes['Alerts.AlarmRules']).toContain('differs from the createSQL');
|
|
257
|
+
expect(read()).toBe(`${edited}\n`);
|
|
258
|
+
});
|
|
259
|
+
});
|