mysqlsyncr 1.1.10 → 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 +246 -186
- 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 +4070 -1730
- package/dist/index.js +4106 -1768
- package/package.json +66 -59
- package/rollup.config.js +25 -24
- package/src/DBStructureChecker.js +47 -0
- package/src/acceptCurrent.js +225 -0
- package/src/apply.js +481 -348
- 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 -170
- package/src/loggers.js +156 -139
- package/src/normalizers.js +28 -6
- package/src/options.js +133 -0
- package/src/parsers.js +197 -145
- 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 +311 -229
- 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/src/parsers.js
CHANGED
|
@@ -1,145 +1,197 @@
|
|
|
1
|
-
export const parseCreateTableSQL = (createTableSQL) => {
|
|
2
|
-
if(!createTableSQL) return null;
|
|
3
|
-
const name = createTableSQL.match(/CREATE TABLE\s+`([^`]+)`/)[1];
|
|
4
|
-
const columns = [];
|
|
5
|
-
const indexes = [];
|
|
6
|
-
const constraints = [];
|
|
7
|
-
let engine = null;
|
|
8
|
-
let charset = null;
|
|
9
|
-
let collate = null;
|
|
10
|
-
|
|
11
|
-
// Split the SQL statement into lines and filter out empty lines
|
|
12
|
-
const lines = createTableSQL.split('\n').map(line => line.trim()).filter(line => line.length > 0);
|
|
13
|
-
|
|
14
|
-
// Regular expression to match
|
|
15
|
-
const
|
|
16
|
-
|
|
17
|
-
//
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
const
|
|
32
|
-
const
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
if
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
let
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
1
|
+
export const parseCreateTableSQL = (createTableSQL) => {
|
|
2
|
+
if(!createTableSQL) return null;
|
|
3
|
+
const name = createTableSQL.match(/CREATE TABLE\s+`([^`]+)`/)[1];
|
|
4
|
+
const columns = [];
|
|
5
|
+
const indexes = [];
|
|
6
|
+
const constraints = [];
|
|
7
|
+
let engine = null;
|
|
8
|
+
let charset = null;
|
|
9
|
+
let collate = null;
|
|
10
|
+
|
|
11
|
+
// Split the SQL statement into lines and filter out empty lines
|
|
12
|
+
const lines = createTableSQL.split('\n').map(line => line.trim()).filter(line => line.length > 0);
|
|
13
|
+
|
|
14
|
+
// Regular expression to match index definitions
|
|
15
|
+
const indexRegex_Old = /(PRIMARY|UNIQUE)?\s*KEY\s*`([^`]+)`\s*\(([^)]+)\)|PRIMARY KEY\s*\(([^)]+)\)/;
|
|
16
|
+
|
|
17
|
+
// Updated to support indexes for text fields
|
|
18
|
+
// 'KEY `idx_e2alarms_alarmstring` (`alarmString`(255))' -- would cut off the final bracket and wouldnt work.
|
|
19
|
+
const indexRegex = /(PRIMARY|UNIQUE)?\s*KEY\s*`([^`]+)`\s*\(((?:`[^`]+`(?:\(\d+\))?(?:\s*,\s*)?)+)\)|PRIMARY KEY\s*\(((?:`[^`]+`(?:\(\d+\))?(?:\s*,\s*)?)+)\)/;
|
|
20
|
+
|
|
21
|
+
// Regular expression to match table options (ENGINE, CHARSET, COLLATE)
|
|
22
|
+
const optionsRegex = /ENGINE=(\w+)\s+DEFAULT\s+CHARSET=(\w+)(\s+COLLATE=(\w+))?/;
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
// Process each line
|
|
26
|
+
for (const line of lines) {
|
|
27
|
+
if (line.includes('CREATE TABLE')) {
|
|
28
|
+
continue;
|
|
29
|
+
}
|
|
30
|
+
// Column definitions are the only lines that start with a backticked name
|
|
31
|
+
const column = parseColumnDefinition(line);
|
|
32
|
+
const indexMatch = indexRegex.exec(line);
|
|
33
|
+
|
|
34
|
+
const constraintMatch = parseConstraint(line);
|
|
35
|
+
if (column) {
|
|
36
|
+
columns.push(column);
|
|
37
|
+
} else if (indexMatch && !constraintMatch) {
|
|
38
|
+
|
|
39
|
+
const isPrimary = line.includes("PRIMARY KEY"); // Check for PRIMARY in both patterns
|
|
40
|
+
const isUnique = line.includes("UNIQUE KEY"); // Only set Unique if it matches
|
|
41
|
+
|
|
42
|
+
const keyName = isPrimary ? null : indexMatch[2]; // Set KeyName to null if it's a primary key
|
|
43
|
+
const columnNames = indexMatch[3]
|
|
44
|
+
? indexMatch[3].split(',').map(col => col.trim().replace(/`/g, '')) // Remove backticks
|
|
45
|
+
: indexMatch[4].split(',').map(col => col.trim().replace(/`/g, '')); // Remove backticks
|
|
46
|
+
const index = {
|
|
47
|
+
ColumnName: columnNames
|
|
48
|
+
};
|
|
49
|
+
if (isPrimary) index.Primary = true, index.Name = "PRIMARY"
|
|
50
|
+
if (isUnique) index.Unique = true;
|
|
51
|
+
if (keyName) index.Name = keyName;
|
|
52
|
+
indexes.push(index);
|
|
53
|
+
|
|
54
|
+
} else if (constraintMatch) {
|
|
55
|
+
constraints.push(constraintMatch);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Handle table options
|
|
61
|
+
const optionsMatch = optionsRegex.exec(createTableSQL);
|
|
62
|
+
if (optionsMatch) {
|
|
63
|
+
engine = optionsMatch[1];
|
|
64
|
+
charset = optionsMatch[2];
|
|
65
|
+
collate = optionsMatch[4] || null; // Collate may not be present
|
|
66
|
+
}
|
|
67
|
+
return { name, columns, indexes, constraints, engine, charset, collate };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export const parseConstraint = (sql) => {
|
|
71
|
+
if(!sql) return null;
|
|
72
|
+
const regex = /FOREIGN KEY \((`[^`]+`)\) REFERENCES (`[^`]+`)\s?\((`[^`]+`)\)(?:\sON DELETE (NO|CASCADE|SET NULL|SET DEFAULT))?(?:\sON UPDATE (NO ACTION|CASCADE|SET NULL|SET DEFAULT))?/i;
|
|
73
|
+
const match = sql.match(regex);
|
|
74
|
+
|
|
75
|
+
if (!match) {
|
|
76
|
+
return null;
|
|
77
|
+
}
|
|
78
|
+
let onDelete = null;
|
|
79
|
+
let onUpdate = null;
|
|
80
|
+
if(sql.includes('ON DELETE CASCADE')){
|
|
81
|
+
onDelete = 'CASCADE';
|
|
82
|
+
}else if(sql.includes('ON DELETE SET NULL')){
|
|
83
|
+
onDelete = 'SET NULL';
|
|
84
|
+
}else if(sql.includes('ON DELETE SET DEFAULT')){
|
|
85
|
+
onDelete = 'SET DEFAULT';
|
|
86
|
+
}else{
|
|
87
|
+
onDelete = 'NO ACTION';
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
if(sql.includes('ON UPDATE CASCADE')){
|
|
91
|
+
onUpdate = 'CASCADE';
|
|
92
|
+
}else if(sql.includes('ON UPDATE SET NULL')){
|
|
93
|
+
onUpdate = 'SET NULL';
|
|
94
|
+
}else if(sql.includes('ON UPDATE SET DEFAULT')){
|
|
95
|
+
onUpdate = 'SET DEFAULT';
|
|
96
|
+
}else{
|
|
97
|
+
onUpdate = 'NO ACTION';
|
|
98
|
+
}
|
|
99
|
+
let columnName = match[1] || ''
|
|
100
|
+
columnName = columnName.replace(/`/g, '')
|
|
101
|
+
let table = match[2] || ''
|
|
102
|
+
table = table.replace(/`/g, '')
|
|
103
|
+
let column = match[3] || ''
|
|
104
|
+
column = column.replace(/`/g, '')
|
|
105
|
+
return {
|
|
106
|
+
Name: "AlertRule", // Replace with actual constraint name if needed
|
|
107
|
+
Type: "FOREIGN KEY",
|
|
108
|
+
ColumnName: [columnName], // Column in the current table
|
|
109
|
+
References: {
|
|
110
|
+
Table: table, // Referenced table
|
|
111
|
+
Column: column, // Referenced column
|
|
112
|
+
},
|
|
113
|
+
OnDelete: onDelete,
|
|
114
|
+
OnUpdate: onUpdate,
|
|
115
|
+
};
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
// A quoted SQL string, allowing '' and backslash escapes inside it
|
|
119
|
+
const quotedStringRegex = /'(?:[^'\\]|\\.|'')*'/g;
|
|
120
|
+
|
|
121
|
+
const unquote = (quoted) => quoted.slice(1, -1).replace(/''/g, "'").replace(/\\(.)/g, '$1');
|
|
122
|
+
|
|
123
|
+
// Type with its parameters: int, float(10,2), enum('a','b'), varchar(45); then unsigned/zerofill
|
|
124
|
+
const columnTypeRegex = /^([a-zA-Z]+(?:\((?:'(?:[^'\\]|\\.|'')*'|[^)'])*\))?)((?:\s+(?:unsigned|zerofill)\b)*)/i;
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Parse one column line of a SHOW CREATE TABLE statement, e.g.
|
|
128
|
+
* `IID` varchar(25) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT 'x',
|
|
129
|
+
* Returns null if the line isn't a column definition.
|
|
130
|
+
*/
|
|
131
|
+
export const parseColumnDefinition = (line) => {
|
|
132
|
+
const lineMatch = line.trim().match(/^`([^`]+)`\s+(.*?),?$/);
|
|
133
|
+
if (!lineMatch) return null;
|
|
134
|
+
const typeMatch = lineMatch[2].match(columnTypeRegex);
|
|
135
|
+
if (!typeMatch) return null;
|
|
136
|
+
|
|
137
|
+
const column = {
|
|
138
|
+
Field: lineMatch[1],
|
|
139
|
+
// unsigned/zerofill are part of the type: a column restated without them changes
|
|
140
|
+
Type: typeMatch[1] + typeMatch[2].toLowerCase()
|
|
141
|
+
};
|
|
142
|
+
|
|
143
|
+
// Swap quoted strings for numbered placeholders so keywords inside a
|
|
144
|
+
// COMMENT or DEFAULT value aren't matched as attributes
|
|
145
|
+
const strings = [];
|
|
146
|
+
const quoted = [];
|
|
147
|
+
const rest = lineMatch[2].slice(typeMatch[0].length).replace(quotedStringRegex, (text) => {
|
|
148
|
+
strings.push(unquote(text));
|
|
149
|
+
quoted.push(text);
|
|
150
|
+
return `'${strings.length - 1}'`;
|
|
151
|
+
});
|
|
152
|
+
// Put the original quoted text back, e.g. b'0' or (_utf8mb4'[]')
|
|
153
|
+
const restoreQuoted = (text) => text.replace(/'(\d+)'/g, (_, i) => quoted[i]);
|
|
154
|
+
|
|
155
|
+
if (/\bNOT NULL\b/i.test(rest)) {
|
|
156
|
+
column.NotNull = true;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const defaultMatch = rest.match(/\bDEFAULT\s+('\d+'|\((?:[^()]|\([^()]*\))*\)|[^\s,]+)/i);
|
|
160
|
+
if (defaultMatch) {
|
|
161
|
+
// A plain string default is stored unquoted; anything else (NULL,
|
|
162
|
+
// CURRENT_TIMESTAMP, b'0', an expression) is kept as written
|
|
163
|
+
const whole = defaultMatch[1].match(/^'(\d+)'$/);
|
|
164
|
+
column.Default = whole ? strings[whole[1]] : restoreQuoted(defaultMatch[1]);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
const generatedMatch = rest.match(/\bGENERATED ALWAYS AS\s*(\((?:[^()]|\((?:[^()]|\([^()]*\))*\))*\))\s*(STORED|VIRTUAL)?/i);
|
|
168
|
+
if (generatedMatch) {
|
|
169
|
+
column.Generated = `${restoreQuoted(generatedMatch[1])}${generatedMatch[2] ? ` ${generatedMatch[2].toUpperCase()}` : ''}`;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
if (/\bAUTO_INCREMENT\b/i.test(rest)) {
|
|
173
|
+
column.AutoIncrement = true;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
const onUpdateMatch = rest.match(/\bON UPDATE\s+(CURRENT_TIMESTAMP(?:\(\d*\))?)/i);
|
|
177
|
+
if (onUpdateMatch) {
|
|
178
|
+
column.OnUpdate = onUpdateMatch[1];
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
const charsetMatch = rest.match(/\bCHARACTER SET\s+(\w+)/i);
|
|
182
|
+
if (charsetMatch) {
|
|
183
|
+
column.Charset = charsetMatch[1];
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
const collateMatch = rest.match(/\bCOLLATE\s+(\w+)/i);
|
|
187
|
+
if (collateMatch) {
|
|
188
|
+
column.Collate = collateMatch[1];
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
const commentMatch = rest.match(/\bCOMMENT\s+'(\d+)'/i);
|
|
192
|
+
if (commentMatch) {
|
|
193
|
+
column.Comment = strings[commentMatch[1]];
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
return column;
|
|
197
|
+
};
|
package/src/paths.js
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import fs from 'fs';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import { fileURLToPath } from 'url';
|
|
4
|
+
|
|
5
|
+
// The mysqlsyncr package folder, from src/ or the dist/ build. The CJS build
|
|
6
|
+
// rewrites import.meta.url from __filename; a global __dirname can't be trusted
|
|
7
|
+
// (node -e sets it to '.')
|
|
8
|
+
export const packageRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
9
|
+
|
|
10
|
+
export const isInsidePackage = (dir) => {
|
|
11
|
+
const relative = path.relative(packageRoot, path.resolve(dir));
|
|
12
|
+
return relative === '' || (!relative.startsWith('..') && !path.isAbsolute(relative));
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Throw if dir is the mysqlsyncr package folder or inside it. Dumps and
|
|
17
|
+
* templates kept there are lost when the package is reinstalled or updated.
|
|
18
|
+
*/
|
|
19
|
+
export const assertOutsidePackage = (dir) => {
|
|
20
|
+
if (dir && isInsidePackage(dir)) {
|
|
21
|
+
throw new Error(`${path.resolve(dir)} is inside the mysqlsyncr package folder (${packageRoot}). Choose a folder outside it with --output.`);
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Why dir can't be read as a template: it doesn't exist, isn't a folder, or has
|
|
27
|
+
* no <database>/<database>.json in it. Null when it's usable.
|
|
28
|
+
*/
|
|
29
|
+
export const templateFolderProblem = (dir) => {
|
|
30
|
+
const resolved = path.resolve(dir);
|
|
31
|
+
let stat;
|
|
32
|
+
try {
|
|
33
|
+
stat = fs.statSync(resolved);
|
|
34
|
+
} catch {
|
|
35
|
+
return `${resolved} doesn't exist.`;
|
|
36
|
+
}
|
|
37
|
+
if (!stat.isDirectory()) return `${resolved} isn't a folder.`;
|
|
38
|
+
const hasDatabase = fs.readdirSync(resolved, { withFileTypes: true })
|
|
39
|
+
.some(entry => entry.isDirectory() && fs.existsSync(path.join(resolved, entry.name, `${entry.name}.json`)));
|
|
40
|
+
return hasDatabase ? null : `${resolved} has no template in it (no <database>/<database>.json). Run dump first, or choose the template folder.`;
|
|
41
|
+
}
|
package/src/prompt.js
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import readline from 'readline';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import { isInsidePackage, packageRoot, templateFolderProblem } from './paths.js';
|
|
4
|
+
|
|
5
|
+
const COMMANDS = [
|
|
6
|
+
{ name: 'compare', description: 'Compare the database structure with JSON dumps (dry run)' },
|
|
7
|
+
{ name: 'update', description: 'Update the database structure based on JSON dumps' },
|
|
8
|
+
{ name: 'dump', description: 'Dump the database structure to files' },
|
|
9
|
+
{ name: 'ui', description: 'Open the UI' },
|
|
10
|
+
{ name: 'rebuild', description: "Rebuild the dump's JSON from each table's CREATE TABLE" }
|
|
11
|
+
];
|
|
12
|
+
|
|
13
|
+
// Commands that connect to MySQL from the command line (the UI asks for its own connection)
|
|
14
|
+
export const DATABASE_COMMANDS = ['compare', 'update', 'dump'];
|
|
15
|
+
|
|
16
|
+
// Commands that read an existing template, so its folder must already hold one
|
|
17
|
+
const TEMPLATE_COMMANDS = ['compare', 'update', 'rebuild'];
|
|
18
|
+
|
|
19
|
+
const PASSWORD_LABEL = 'MySQL password (typing is hidden, Enter for none)';
|
|
20
|
+
|
|
21
|
+
const createPrompter = ({ input = process.stdin, output = process.stdout } = {}) => {
|
|
22
|
+
const rl = readline.createInterface({ input, output, terminal: true });
|
|
23
|
+
|
|
24
|
+
// Lets a password be typed without echoing it
|
|
25
|
+
let muted = false;
|
|
26
|
+
rl._writeToOutput = (text) => {
|
|
27
|
+
if (!muted) rl.output.write(text);
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
const question = (text) => new Promise(resolve => rl.question(text, resolve));
|
|
31
|
+
const ask = async (label, defaultValue) => {
|
|
32
|
+
const hint = defaultValue !== undefined && defaultValue !== null && defaultValue !== '' ? ` (${defaultValue})` : '';
|
|
33
|
+
const answer = (await question(`${label}${hint}: `)).trim();
|
|
34
|
+
return answer === '' ? defaultValue : answer;
|
|
35
|
+
};
|
|
36
|
+
const askHidden = async (label) => {
|
|
37
|
+
rl.output.write(`${label}: `);
|
|
38
|
+
muted = true;
|
|
39
|
+
const answer = await question('');
|
|
40
|
+
muted = false;
|
|
41
|
+
rl.output.write('\n');
|
|
42
|
+
return answer;
|
|
43
|
+
};
|
|
44
|
+
const confirm = async (label) => /^y(es)?$/i.test(await ask(`${label} (y/N)`));
|
|
45
|
+
const say = (text) => output.write(`${text}\n`);
|
|
46
|
+
return { ask, askHidden, confirm, say, close: () => rl.close() };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Ask for the MySQL password when a command was given without --password.
|
|
51
|
+
*/
|
|
52
|
+
export const promptForPassword = async (argv, streams) => {
|
|
53
|
+
const prompter = createPrompter(streams);
|
|
54
|
+
try {
|
|
55
|
+
return await prompter.askHidden(`${PASSWORD_LABEL} for ${argv.user}@${argv.host}`);
|
|
56
|
+
} finally {
|
|
57
|
+
prompter.close();
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Ask for the command and its options when mysqlsyncr is run with no arguments.
|
|
63
|
+
* Fills in argv and returns the chosen command, or null if the user cancels.
|
|
64
|
+
*/
|
|
65
|
+
export const promptForOptions = async (argv, streams) => {
|
|
66
|
+
const { ask, askHidden, confirm, say, close } = createPrompter(streams);
|
|
67
|
+
|
|
68
|
+
try {
|
|
69
|
+
say('No command given. Choose one:');
|
|
70
|
+
COMMANDS.forEach((command, i) => say(` ${i + 1}) ${command.name.padEnd(8)} ${command.description}`));
|
|
71
|
+
const choice = String(await ask('Command', '1'));
|
|
72
|
+
const command = COMMANDS[Number(choice) - 1]?.name ?? COMMANDS.find(c => c.name === choice.toLowerCase())?.name;
|
|
73
|
+
if (!command) {
|
|
74
|
+
console.error(`Unknown command: ${choice}`);
|
|
75
|
+
return null;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
if (DATABASE_COMMANDS.includes(command)) {
|
|
79
|
+
argv.host = await ask('MySQL host', argv.host);
|
|
80
|
+
argv.port = Number(await ask('MySQL port', argv.port));
|
|
81
|
+
argv.user = await ask('MySQL user', argv.user);
|
|
82
|
+
argv.password = await askHidden(PASSWORD_LABEL);
|
|
83
|
+
argv.database = (await ask('Database (blank for all)', argv.database ?? '')) || null;
|
|
84
|
+
}
|
|
85
|
+
if (command !== 'ui') {
|
|
86
|
+
argv.output = await ask(command === 'dump' ? 'Folder to dump to' : 'Template folder', argv.output);
|
|
87
|
+
for (;;) {
|
|
88
|
+
let problem = null;
|
|
89
|
+
if (!argv.output) problem = '';
|
|
90
|
+
else if (isInsidePackage(argv.output)) problem = `That folder is inside the mysqlsyncr package folder (${packageRoot}). Choose one outside it.`;
|
|
91
|
+
else if (TEMPLATE_COMMANDS.includes(command)) problem = templateFolderProblem(argv.output);
|
|
92
|
+
if (problem === null) break;
|
|
93
|
+
if (problem) say(problem);
|
|
94
|
+
argv.output = await ask('Folder');
|
|
95
|
+
}
|
|
96
|
+
if (command === 'dump') say(`Dumping to ${path.resolve(argv.output)}`);
|
|
97
|
+
}
|
|
98
|
+
if (command === 'rebuild') {
|
|
99
|
+
argv.write = await confirm('Write the rebuilt files? No lists the changes only');
|
|
100
|
+
}
|
|
101
|
+
if (command === 'update' && !await confirm(`This will change the structure of ${argv.database ?? 'every database'} on ${argv.host}. Continue?`)) {
|
|
102
|
+
say('Cancelled.');
|
|
103
|
+
return null;
|
|
104
|
+
}
|
|
105
|
+
return command;
|
|
106
|
+
} finally {
|
|
107
|
+
close();
|
|
108
|
+
}
|
|
109
|
+
}
|
package/src/rebuild.js
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import fs from 'fs/promises';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import { parseCreateTableSQL } from './parsers.js';
|
|
4
|
+
import { normalizedDefault } from './differences.js';
|
|
5
|
+
import { loadSyncOptions, getTableOptions, applyAutoIncrementOption } from './options.js';
|
|
6
|
+
import { assertOutsidePackage } from './paths.js';
|
|
7
|
+
import { splitSqlFile } from './templateDrift.js';
|
|
8
|
+
|
|
9
|
+
const describeColumn = (col) => JSON.stringify({ Type: col.Type, NotNull: !!col.NotNull, Default: normalizedDefault(col) });
|
|
10
|
+
const describeIndexes = (indexes) => (indexes || [])
|
|
11
|
+
.map(i => `${i.Name}${i.Primary || i.Name === 'PRIMARY' ? ' PRIMARY' : ''}${i.Unique ? ' UNIQUE' : ''} (${i.ColumnName.join(', ')})`)
|
|
12
|
+
.sort();
|
|
13
|
+
|
|
14
|
+
// What would change in one table's JSON if it were rebuilt from its createSQL
|
|
15
|
+
const tableChanges = (table, rebuilt, createSQL) => {
|
|
16
|
+
const changes = [];
|
|
17
|
+
if (createSQL !== table.createSQL) {
|
|
18
|
+
changes.push('createSQL: AUTO_INCREMENT counter changed by the autoIncrement option');
|
|
19
|
+
}
|
|
20
|
+
for (const col of table.columns || []) {
|
|
21
|
+
const rebuiltCol = rebuilt.columns.find(c => c.Field === col.Field);
|
|
22
|
+
if (!rebuiltCol) {
|
|
23
|
+
changes.push(`column ${col.Field}: in the JSON but not in createSQL (removed)`);
|
|
24
|
+
} else if (describeColumn(col) !== describeColumn(rebuiltCol)) {
|
|
25
|
+
changes.push(`column ${col.Field}: JSON ${describeColumn(col)} -> createSQL ${describeColumn(rebuiltCol)}`);
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
for (const col of rebuilt.columns) {
|
|
29
|
+
if (!(table.columns || []).some(c => c.Field === col.Field)) {
|
|
30
|
+
changes.push(`column ${col.Field}: in createSQL but not in the JSON (added)`);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
const before = describeIndexes(table.indexes);
|
|
34
|
+
const after = describeIndexes(rebuilt.indexes);
|
|
35
|
+
for (const index of before.filter(i => !after.includes(i))) changes.push(`index ${index}: removed`);
|
|
36
|
+
for (const index of after.filter(i => !before.includes(i))) changes.push(`index ${index}: added`);
|
|
37
|
+
return changes;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Rebuild each table's columns/indexes/constraints in a template from its
|
|
42
|
+
* createSQL with the current parser, applying the autoIncrement option to the
|
|
43
|
+
* createSQL. Returns { changes, notes }: changes is { 'db.table': [change, ...] }
|
|
44
|
+
* for tables that would change, notes is { 'db.table': note } for things to check
|
|
45
|
+
* by hand. Only writes the files when write is true.
|
|
46
|
+
*/
|
|
47
|
+
export const rebuildTemplate = async (dir, { write = false } = {}) => {
|
|
48
|
+
if (write) assertOutsidePackage(dir);
|
|
49
|
+
const syncOptions = await loadSyncOptions(dir);
|
|
50
|
+
const report = {};
|
|
51
|
+
const notes = {};
|
|
52
|
+
const entries = await fs.readdir(dir, { withFileTypes: true });
|
|
53
|
+
|
|
54
|
+
for (const entry of entries.filter(e => e.isDirectory())) {
|
|
55
|
+
const database = entry.name;
|
|
56
|
+
const jsonPath = path.join(dir, database, `${database}.json`);
|
|
57
|
+
let structure;
|
|
58
|
+
try {
|
|
59
|
+
structure = JSON.parse(await fs.readFile(jsonPath, 'utf-8'));
|
|
60
|
+
} catch (err) {
|
|
61
|
+
if (err.code === 'ENOENT') continue;
|
|
62
|
+
throw new Error(`Could not read ${jsonPath}: ${err.message}`);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
let changed = false;
|
|
66
|
+
for (const [tableName, table] of Object.entries(structure.tables || {})) {
|
|
67
|
+
if (!table.createSQL) continue;
|
|
68
|
+
const createSQL = applyAutoIncrementOption(table.createSQL, getTableOptions(syncOptions, database, tableName).autoIncrement);
|
|
69
|
+
const rebuilt = parseCreateTableSQL(createSQL);
|
|
70
|
+
const changes = tableChanges(table, rebuilt, createSQL);
|
|
71
|
+
|
|
72
|
+
// tables/<table>.sql should match createSQL, but it can hold notes or edits made
|
|
73
|
+
// by hand, so only the AUTO_INCREMENT option is applied to it; any other
|
|
74
|
+
// difference is reported, never overwritten
|
|
75
|
+
const sqlPath = path.join(dir, database, 'tables', `${tableName}.sql`);
|
|
76
|
+
const sqlFile = await fs.readFile(sqlPath, 'utf-8').catch(() => null);
|
|
77
|
+
const fixedSqlFile = sqlFile === null ? null : applyAutoIncrementOption(sqlFile, getTableOptions(syncOptions, database, tableName).autoIncrement);
|
|
78
|
+
const sqlCounterChanged = sqlFile !== null && fixedSqlFile !== sqlFile;
|
|
79
|
+
if (sqlCounterChanged) changes.push(`tables/${tableName}.sql: AUTO_INCREMENT counter changed by the autoIncrement option`);
|
|
80
|
+
// Notes written after the statement don't count
|
|
81
|
+
const sameSQL = (a, b) => a.replace(/\r\n/g, '\n').replace(/;?\s*$/, '') === b.replace(/\r\n/g, '\n').replace(/;?\s*$/, '');
|
|
82
|
+
const sqlFileDrifted = fixedSqlFile !== null && !sameSQL(splitSqlFile(fixedSqlFile).statement, createSQL);
|
|
83
|
+
if (changes.length === 0 && !sqlFileDrifted) continue;
|
|
84
|
+
|
|
85
|
+
if (changes.length > 0) report[`${database}.${tableName}`] = changes;
|
|
86
|
+
if (sqlFileDrifted) {
|
|
87
|
+
notes[`${database}.${tableName}`] = `tables/${tableName}.sql differs from the createSQL in ${database}.json (left as is; compare uses the JSON). Pick which to keep in the UI.`;
|
|
88
|
+
}
|
|
89
|
+
if (sqlCounterChanged && write) {
|
|
90
|
+
await fs.writeFile(sqlPath, fixedSqlFile, 'utf-8');
|
|
91
|
+
}
|
|
92
|
+
if (changes.length === 0 || changes.every(change => change.startsWith('tables/'))) continue; // the JSON is already right
|
|
93
|
+
|
|
94
|
+
changed = true;
|
|
95
|
+
table.columns = rebuilt.columns;
|
|
96
|
+
table.indexes = rebuilt.indexes;
|
|
97
|
+
table.constraints = rebuilt.constraints;
|
|
98
|
+
table.createSQL = createSQL;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
if (changed && write) {
|
|
102
|
+
await fs.writeFile(jsonPath, JSON.stringify(structure, null, 4), 'utf-8');
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
return { changes: report, notes };
|
|
106
|
+
}
|
package/src/rows.js
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
const primaryKeyColumns = (table) => table?.indexes
|
|
2
|
+
?.find(i => i.Primary || i.Name === 'PRIMARY')
|
|
3
|
+
?.ColumnName.map(col => col.replace(/\(\d+\)$/, ''));
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Seed rows from the sync options that aren't in the database yet, as
|
|
7
|
+
* missing_row differences. A row counts as present when a row with the same
|
|
8
|
+
* rowKey values exists, whatever its other columns hold. Rows for a table that
|
|
9
|
+
* doesn't exist yet are all missing; they're applied after the table is created.
|
|
10
|
+
*/
|
|
11
|
+
export const findMissingRows = async (connection, database, expected, current, syncOptions) => {
|
|
12
|
+
const differences = [];
|
|
13
|
+
for (const [tableName, tableOptions] of Object.entries(syncOptions?.databases?.[database] || {})) {
|
|
14
|
+
if (!tableOptions?.rows?.length) continue;
|
|
15
|
+
|
|
16
|
+
const key = tableOptions.rowKey ?? primaryKeyColumns(expected.tables?.[tableName]) ?? primaryKeyColumns(current.tables?.[tableName]);
|
|
17
|
+
if (!key) {
|
|
18
|
+
throw new Error(`${database}.${tableName}: rows need a rowKey because the table has no primary key`);
|
|
19
|
+
}
|
|
20
|
+
const tableExists = !!current.tables?.[tableName];
|
|
21
|
+
|
|
22
|
+
for (const row of tableOptions.rows) {
|
|
23
|
+
const missingKeyColumns = key.filter(col => !(col in row));
|
|
24
|
+
if (missingKeyColumns.length > 0) {
|
|
25
|
+
throw new Error(`${database}.${tableName}: a row is missing rowKey column(s) ${missingKeyColumns.join(', ')}: ${JSON.stringify(row)}`);
|
|
26
|
+
}
|
|
27
|
+
const rowKey = Object.fromEntries(key.map(col => [col, row[col]]));
|
|
28
|
+
|
|
29
|
+
if (tableExists) {
|
|
30
|
+
const where = key.map(col => row[col] === null ? `\`${col}\` IS NULL` : `\`${col}\` = ?`).join(' AND ');
|
|
31
|
+
const values = key.filter(col => row[col] !== null).map(col => row[col]);
|
|
32
|
+
let found;
|
|
33
|
+
try {
|
|
34
|
+
found = await connection.query(`SELECT 1 FROM \`${database}\`.\`${tableName}\` WHERE ${where} LIMIT 1`, values);
|
|
35
|
+
} catch (err) {
|
|
36
|
+
// e.g. a key column the table doesn't have yet; checked again once the structure is fixed
|
|
37
|
+
console.error(`Could not check seed rows for ${database}.${tableName}: ${err.message}`);
|
|
38
|
+
break;
|
|
39
|
+
}
|
|
40
|
+
if (found.length > 0) continue;
|
|
41
|
+
}
|
|
42
|
+
differences.push({ type: 'missing_row', tableName, key: rowKey, row });
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
return differences;
|
|
46
|
+
}
|