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
package/src/loggers.js CHANGED
@@ -1,140 +1,157 @@
1
- let isVerbose = false; // Global variable to manage verbosity
2
-
3
- export const setVerbose = (verbose) => {
4
- isVerbose = verbose; // Function to set the verbosity level
5
- };
6
-
7
- export const logger = (...args) => {
8
- if (isVerbose) {
9
- console.log(...args); // Log all arguments if verbose is enabled
10
- }
11
- };
12
-
13
- export const logDifferences = (differences, database, dryRun) => {
14
- const formattedDifferences = differences.map(diff => {
15
- switch (diff.type) {
16
- case 'missing_procedure':
17
- return {
18
- Type: diff.type,
19
- Name: diff.Name,
20
- Details: `Procedure ${diff.Name} is missing`,
21
- Action: 'Procedure is missing',
22
- };
23
- case 'mismatched_procedure':
24
-
25
- return {
26
- Type: diff.type,
27
- Name: diff.Name,
28
- Details: `Mismatched procedure ${diff.Name}`,
29
- Action: 'Mismatched procedure',
30
- };
31
- case 'missing_field':
32
- return {
33
- Type: diff.type,
34
- Name: diff.tableName,
35
- Details: `Field ${diff.field.Field} is missing`,
36
- Action: 'Field is missing',
37
- };
38
- case 'missing_table':
39
- return {
40
- Type: diff.type,
41
- Name: diff.tableName,
42
- Details: `Table ${diff.tableName} is missing`,
43
- Action: 'Table is missing',
44
- };
45
- case 'extra_field':
46
- return {
47
- Type: diff.type,
48
- Name: diff.tableName,
49
- Details: `Field ${diff.field.Field} is extra`,
50
- Action: 'Field is extra',
51
- };
52
- case 'extra_table':
53
- return {
54
- Type: diff.type,
55
- Name: diff.tableName,
56
- Details: `Table ${diff.tableName} is extra`,
57
- Action: 'Table is extra',
58
- };
59
- case 'missing_view':
60
- return {
61
- Type: diff.type,
62
- Name: diff.viewName,
63
- Details: `View ${diff.viewName} is missing`,
64
- Action: 'View is missing',
65
- };
66
- case 'extra_index':
67
- return {
68
- Type: diff.type,
69
- Name: diff.tableName,
70
- Details: `Index ${diff.index.Name} is extra`,
71
- Action: 'Index is extra',
72
- };
73
- case 'missing_index':
74
- return {
75
- Type: diff.type,
76
- TableName: diff.tableName,
77
- Name: diff.index.Name,
78
- Details: `Index ${diff.index.Name} is missing`,
79
- Action: 'Index is missing',
80
- };
81
-
82
- case 'mismatched_index':
83
- return {
84
- Type: diff.type,
85
- TableName: diff.tableName,
86
- Name: diff.index.Name,
87
- From: diff.from,
88
- Details: `Index ${diff.index.Name} is mismatched`,
89
- Action: 'Index is mismatched',
90
- };
91
-
92
- case 'extra_trigger':
93
- return {
94
- Type: diff.type,
95
- Name: diff.trigger.Name,
96
- Details: `Trigger ${diff.trigger.Name} is extra`,
97
- Action: 'Trigger is extra',
98
- };
99
- case 'mismatched_trigger':
100
- return {
101
- Type: diff.type,
102
- Name: diff.trigger.Name,
103
- Details: `Trigger ${diff.trigger.Name} is mismatched`,
104
- Action: 'Trigger is mismatched',
105
- };
106
- case 'missing_trigger':
107
- console.log('missing_trigger', diff)
108
- return {
109
- Type: diff.type,
110
- Name: diff.trigger.Name,
111
- Details: `Trigger ${diff.trigger.Name} is missing`,
112
- Action: 'Trigger is missing',
113
- };
114
- default:
115
- return {
116
- Type: diff.type,
117
- Details: `Unhandled difference type: ${diff.type}`,
118
- };
119
- }
120
- });
121
-
122
- // Filter out any undefined or null entries
123
- const filteredDifferences = formattedDifferences.filter(diff => diff);
124
-
125
- // Log the differences in a table format
126
- if (filteredDifferences.length > 0) {
127
- if (dryRun) {
128
- logger(`Dry Run: Differences for database ${database}:`);
129
- } else {
130
- logger(`Differences applied for database ${database}:`);
131
- }
132
- console.table(filteredDifferences);
133
- } else {
134
- if (dryRun) {
135
- logger(`Dry Run: No differences found. Database: ${database}`);
136
- } else {
137
- logger(`No differences found. Database: ${database}`);
138
- }
139
- }
1
+ let isVerbose = false; // Global variable to manage verbosity
2
+
3
+ export const setVerbose = (verbose) => {
4
+ isVerbose = verbose; // Function to set the verbosity level
5
+ };
6
+
7
+ export const logger = (...args) => {
8
+ if (isVerbose) {
9
+ console.log(...args); // Log all arguments if verbose is enabled
10
+ }
11
+ };
12
+
13
+ export const logDifferences = (differences, database, dryRun) => {
14
+ const formattedDifferences = differences.map(diff => {
15
+ switch (diff.type) {
16
+ case 'missing_procedure':
17
+ return {
18
+ Type: diff.type,
19
+ Name: diff.Name,
20
+ Details: `Procedure ${diff.Name} is missing`,
21
+ Action: 'Procedure is missing',
22
+ };
23
+ case 'mismatched_procedure':
24
+
25
+ return {
26
+ Type: diff.type,
27
+ Name: diff.Name,
28
+ Details: `Mismatched procedure ${diff.Name}`,
29
+ Action: 'Mismatched procedure',
30
+ };
31
+ case 'missing_field':
32
+ return {
33
+ Type: diff.type,
34
+ Name: diff.tableName,
35
+ Details: `Field ${diff.field.Field} is missing`,
36
+ Action: 'Field is missing',
37
+ };
38
+ case 'mismatched_field': {
39
+ // `info` names the check that failed; Null is stored as NotNull.
40
+ const property = diff.info === 'Null' ? 'NotNull' : diff.info;
41
+ return {
42
+ Type: diff.type,
43
+ Name: diff.tableName,
44
+ Details: `Field ${diff.field.Field} ${diff.info} is ${JSON.stringify(diff.currentField?.[property])}, expected ${JSON.stringify(diff.field?.[property])}`,
45
+ Action: 'Field is mismatched',
46
+ };
47
+ }
48
+ case 'missing_table':
49
+ return {
50
+ Type: diff.type,
51
+ Name: diff.tableName,
52
+ Details: `Table ${diff.tableName} is missing`,
53
+ Action: 'Table is missing',
54
+ };
55
+ case 'extra_field':
56
+ return {
57
+ Type: diff.type,
58
+ Name: diff.tableName,
59
+ Details: `Field ${diff.field.Field} is extra`,
60
+ Action: 'Field is extra',
61
+ };
62
+ case 'extra_table':
63
+ return {
64
+ Type: diff.type,
65
+ Name: diff.tableName,
66
+ Details: `Table ${diff.tableName} is extra`,
67
+ Action: 'Table is extra',
68
+ };
69
+ case 'missing_view':
70
+ return {
71
+ Type: diff.type,
72
+ Name: diff.viewName,
73
+ Details: `View ${diff.viewName} is missing`,
74
+ Action: 'View is missing',
75
+ };
76
+ case 'extra_index':
77
+ return {
78
+ Type: diff.type,
79
+ Name: diff.tableName,
80
+ Details: `Index ${diff.index.Name} is extra`,
81
+ Action: 'Index is extra',
82
+ };
83
+ case 'missing_index':
84
+ return {
85
+ Type: diff.type,
86
+ TableName: diff.tableName,
87
+ Name: diff.index.Name,
88
+ Details: `Index ${diff.index.Name} is missing`,
89
+ Action: 'Index is missing',
90
+ };
91
+
92
+ case 'mismatched_index':
93
+ return {
94
+ Type: diff.type,
95
+ TableName: diff.tableName,
96
+ Name: diff.index.Name,
97
+ From: diff.from,
98
+ Details: `Index ${diff.index.Name} is mismatched`,
99
+ Action: 'Index is mismatched',
100
+ };
101
+
102
+ case 'extra_trigger':
103
+ return {
104
+ Type: diff.type,
105
+ Name: diff.trigger.Name,
106
+ Details: `Trigger ${diff.trigger.Name} is extra`,
107
+ Action: 'Trigger is extra',
108
+ };
109
+ case 'mismatched_trigger':
110
+ return {
111
+ Type: diff.type,
112
+ Name: diff.trigger.Name,
113
+ Details: `Trigger ${diff.trigger.Name} is mismatched`,
114
+ Action: 'Trigger is mismatched',
115
+ };
116
+ case 'missing_trigger':
117
+ logger('missing_trigger', diff)
118
+ return {
119
+ Type: diff.type,
120
+ Name: diff.trigger.Name,
121
+ Details: `Trigger ${diff.trigger.Name} is missing`,
122
+ Action: 'Trigger is missing',
123
+ };
124
+ case 'missing_row':
125
+ return {
126
+ Type: diff.type,
127
+ Name: diff.tableName,
128
+ Details: `Row ${JSON.stringify(diff.key)} is missing`,
129
+ Action: 'Row is missing',
130
+ };
131
+ default:
132
+ return {
133
+ Type: diff.type,
134
+ Details: `Unhandled difference type: ${diff.type}`,
135
+ };
136
+ }
137
+ });
138
+
139
+ // Filter out any undefined or null entries
140
+ const filteredDifferences = formattedDifferences.filter(diff => diff);
141
+
142
+ // Log the differences in a table format
143
+ if (filteredDifferences.length > 0) {
144
+ if (dryRun) {
145
+ logger(`Dry Run: Differences for database ${database}:`);
146
+ } else {
147
+ logger(`Differences applied for database ${database}:`);
148
+ }
149
+ console.table(filteredDifferences);
150
+ } else {
151
+ if (dryRun) {
152
+ logger(`Dry Run: No differences found. Database: ${database}`);
153
+ } else {
154
+ logger(`No differences found. Database: ${database}`);
155
+ }
156
+ }
140
157
  }
@@ -1,6 +1,28 @@
1
- export const normalizeSQLDefinition = (definition) => {
2
- return definition
3
- .replace(/\s+/g, ' ') // Replace multiple whitespace with a single space
4
- .replace(/'/g, "\\'") // Escape single quotes
5
- .trim(); // Trim leading and trailing whitespace
6
- }
1
+ /**
2
+ * Undo formatting that differs between MySQL versions but doesn't change the
3
+ * structure, so a template dumped from one version compares cleanly with
4
+ * another:
5
+ * - integer display widths: int(11) in 5.7 is int from 8.0.19, except
6
+ * tinyint(1) and ZEROFILL types, which keep them
7
+ * - year(4) in 5.7 is year from 8.0.19
8
+ * - utf8 is shown as utf8mb3 from 8.0.24 (fully from 8.0.30)
9
+ * Applies to column types and to procedure, trigger and view definitions.
10
+ */
11
+ export const normalizeVersionFormatting = (sql) => {
12
+ if (typeof sql !== 'string') return sql;
13
+ return sql
14
+ .replace(/\b(tinyint|smallint|mediumint|int|integer|bigint)\((\d+)\)(\s+unsigned)?(?!\s+(?:unsigned\s+)?zerofill)/gi,
15
+ (match, type, width, unsigned = '') => type.toLowerCase() === 'tinyint' && width === '1' && !unsigned ? match : `${type}${unsigned}`)
16
+ .replace(/\byear\(4\)/gi, 'year')
17
+ .replace(/\butf8mb3(?=\b|_)/gi, 'utf8');
18
+ }
19
+
20
+ // A column type compared across MySQL versions
21
+ export const normalizeColumnType = (type) => normalizeVersionFormatting(type);
22
+
23
+ export const normalizeSQLDefinition = (definition) => {
24
+ return normalizeVersionFormatting(definition)
25
+ .replace(/\s+/g, ' ') // Replace multiple whitespace with a single space
26
+ .replace(/'/g, "\\'") // Escape single quotes
27
+ .trim(); // Trim leading and trailing whitespace
28
+ }
package/src/options.js ADDED
@@ -0,0 +1,133 @@
1
+ import fs from 'fs/promises';
2
+ import path from 'path';
3
+
4
+ /**
5
+ * Optional file in the dump/template directory:
6
+ *
7
+ * {
8
+ * "definer": "root@%", // DEFINER for every procedure, view and trigger
9
+ * "autoIncrement": false, // default for every table
10
+ * "databases": {
11
+ * "Alerts": {
12
+ * "AlarmRules": {
13
+ * "autoIncrement": 1000, // overrides the default for this table
14
+ * "rowKey": ["idAlarmRule"], // columns that identify a row; defaults to the primary key
15
+ * "rows": [{ "idAlarmRule": 1, "name": "Default" }]
16
+ * }
17
+ * }
18
+ * }
19
+ * }
20
+ *
21
+ * autoIncrement controls the table's AUTO_INCREMENT=N counter in CREATE TABLE
22
+ * (not the AUTO_INCREMENT column attribute):
23
+ * true keep the counter as dumped (the default)
24
+ * false remove it, so new tables start at 1
25
+ * N start new tables at N
26
+ *
27
+ * rows are inserted when no row with the same rowKey values exists; existing rows are never changed.
28
+ *
29
+ * definer replaces the DEFINER of every procedure, view and trigger in the
30
+ * template, so one whose database definer differs is reported and recreated
31
+ * with it. Written as root@%, `root`@`%` or 'root'@'%'.
32
+ */
33
+ export const OPTIONS_FILE = 'syncOptions.json';
34
+
35
+ export const loadSyncOptions = async (dir) => {
36
+ const filePath = path.join(dir, OPTIONS_FILE);
37
+ let content;
38
+ try {
39
+ content = await fs.readFile(filePath, 'utf-8');
40
+ } catch (err) {
41
+ if (err.code === 'ENOENT') return {};
42
+ throw err;
43
+ }
44
+ let options;
45
+ try {
46
+ options = JSON.parse(content);
47
+ } catch (err) {
48
+ throw new Error(`Invalid JSON in ${filePath}: ${err.message}`);
49
+ }
50
+ validateSyncOptions(options, filePath);
51
+ return options;
52
+ }
53
+
54
+ const isValidAutoIncrement = (value) => value === undefined || typeof value === 'boolean' || (Number.isInteger(value) && value > 0);
55
+
56
+ const validateSyncOptions = (options, filePath) => {
57
+ if (options.definer !== undefined && formatDefiner(options.definer) === null) {
58
+ throw new Error(`${filePath}: definer must look like root@% or \`root\`@\`%\``);
59
+ }
60
+ if (!isValidAutoIncrement(options.autoIncrement)) {
61
+ throw new Error(`${filePath}: autoIncrement must be true, false or a positive integer`);
62
+ }
63
+ for (const [database, tables] of Object.entries(options.databases || {})) {
64
+ for (const [table, tableOptions] of Object.entries(tables || {})) {
65
+ const where = `${filePath}: ${database}.${table}`;
66
+ if (!isValidAutoIncrement(tableOptions.autoIncrement)) {
67
+ throw new Error(`${where}.autoIncrement must be true, false or a positive integer`);
68
+ }
69
+ if (tableOptions.rows !== undefined && (!Array.isArray(tableOptions.rows) || tableOptions.rows.some(row => !row || typeof row !== 'object' || Array.isArray(row)))) {
70
+ throw new Error(`${where}.rows must be an array of objects`);
71
+ }
72
+ if (tableOptions.rowKey !== undefined && (!Array.isArray(tableOptions.rowKey) || tableOptions.rowKey.length === 0)) {
73
+ throw new Error(`${where}.rowKey must be a non-empty array of column names`);
74
+ }
75
+ }
76
+ }
77
+ }
78
+
79
+ export const getTableOptions = (syncOptions, database, table) => ({
80
+ autoIncrement: syncOptions?.autoIncrement ?? true,
81
+ ...syncOptions?.databases?.[database]?.[table]
82
+ });
83
+
84
+ export const removeAutoIncrementCounter = (createSQL) => createSQL.replace(/\s+AUTO_INCREMENT=\d+/i, '');
85
+
86
+ // Apply a table's autoIncrement option to its CREATE TABLE statement
87
+ export const applyAutoIncrementOption = (createSQL, autoIncrement = true) => {
88
+ if (!createSQL || autoIncrement === true) return createSQL;
89
+ const withoutCounter = removeAutoIncrementCounter(createSQL);
90
+ if (autoIncrement === false) return withoutCounter;
91
+ if (/\)\s*ENGINE=\w+/i.test(withoutCounter)) {
92
+ return withoutCounter.replace(/(\)\s*ENGINE=\w+)/i, `$1 AUTO_INCREMENT=${autoIncrement}`);
93
+ }
94
+ return withoutCounter.replace(/\)(\s*;?\s*)$/, `) AUTO_INCREMENT=${autoIncrement}$1`);
95
+ }
96
+
97
+ // root@%, `root`@`%`, 'root'@'%' or DEFINER=... -> `root`@`%`; null if it isn't a user@host
98
+ export const formatDefiner = (definer) => {
99
+ if (typeof definer !== 'string') return null;
100
+ const match = definer.trim().replace(/^DEFINER\s*=\s*/i, '')
101
+ .match(/^(?:`([^`]+)`|'([^']+)'|([^@`'\s]+))@(?:`([^`]+)`|'([^']+)'|([^@`'\s]+))$/);
102
+ if (!match) return null;
103
+ const user = match[1] ?? match[2] ?? match[3];
104
+ const host = match[4] ?? match[5] ?? match[6];
105
+ return `\`${user}\`@\`${host}\``;
106
+ }
107
+
108
+ // The DEFINER clause in a CREATE PROCEDURE/VIEW/TRIGGER header
109
+ const definerClauseRegex = /DEFINER\s*=\s*(?:`[^`]*`|'[^']*'|[^\s@]+)@(?:`[^`]*`|'[^']*'|[^\s]+)/i;
110
+
111
+ // Replace the DEFINER in one definition; unchanged when definer isn't set or the definition has none
112
+ export const applyDefiner = (definition, definer) => {
113
+ const formatted = definer ? formatDefiner(definer) : null;
114
+ if (!formatted || typeof definition !== 'string') return definition;
115
+ return definition.replace(definerClauseRegex, `DEFINER=${formatted}`);
116
+ }
117
+
118
+ // A copy of a database structure with the definer option applied to its procedures, views and triggers
119
+ export const applyDefinerOption = (structure, definer) => {
120
+ if (!definer || !structure) return structure;
121
+ const tables = Object.fromEntries(Object.entries(structure.tables || {}).map(([name, table]) => [name, {
122
+ ...table,
123
+ triggers: (table.triggers || []).map(trigger => ({ ...trigger, Definition: applyDefiner(trigger.Definition, definer) }))
124
+ }]));
125
+ const result = { ...structure, tables };
126
+ if (structure.procedures) {
127
+ result.procedures = structure.procedures.map(procedure => ({ ...procedure, Definition: applyDefiner(procedure.Definition, definer) }));
128
+ }
129
+ if (structure.views) {
130
+ result.views = Object.fromEntries(Object.entries(structure.views).map(([name, view]) => [name, applyDefiner(view, definer)]));
131
+ }
132
+ return result;
133
+ }
package/src/parsers.js CHANGED
@@ -11,9 +11,6 @@ export const parseCreateTableSQL = (createTableSQL) => {
11
11
  // Split the SQL statement into lines and filter out empty lines
12
12
  const lines = createTableSQL.split('\n').map(line => line.trim()).filter(line => line.length > 0);
13
13
 
14
- // Regular expression to match column definitions
15
- const columnRegex = /`([^`]+)`\s+([^\s,]+)(\s+NOT NULL|\s+NULL)?(\s+DEFAULT\s+([^,\s]+))?(\s+AUTO_INCREMENT)?/;
16
-
17
14
  // Regular expression to match index definitions
18
15
  const indexRegex_Old = /(PRIMARY|UNIQUE)?\s*KEY\s*`([^`]+)`\s*\(([^)]+)\)|PRIMARY KEY\s*\(([^)]+)\)/;
19
16
 
@@ -30,35 +27,12 @@ export const parseCreateTableSQL = (createTableSQL) => {
30
27
  if (line.includes('CREATE TABLE')) {
31
28
  continue;
32
29
  }
33
- // Check if the line contains a column definition
34
- const columnMatch = columnRegex.exec(line);
30
+ // Column definitions are the only lines that start with a backticked name
31
+ const column = parseColumnDefinition(line);
35
32
  const indexMatch = indexRegex.exec(line);
36
- const indexMatch_Old = indexRegex_Old.exec(line);
37
33
 
38
34
  const constraintMatch = parseConstraint(line);
39
- if (columnMatch && !indexMatch && !constraintMatch) {
40
- const columnType = parseColumnType(line);
41
- const column = {
42
- Field: columnMatch[1],
43
- Type: columnType
44
- };
45
-
46
- // Set Null property based on NOT NULL presence
47
- if (columnMatch[3] && columnMatch[3].includes('NOT NULL')) {
48
- column.NotNull = true; // Set to false if NOT NULL is present
49
- }
50
-
51
- // Set Default property if it exists
52
- if (columnMatch[5]) {
53
- // console.log('columnMatch[5]', columnMatch[5], columnMatch[5].replace(/'/g, ''))
54
- column.Default = columnMatch[5].replace(/'/g, '');
55
- }
56
-
57
- // Set AutoIncrement property if it exists
58
- if (columnMatch[6]) {
59
- column.AutoIncrement = true; // Set to true if AUTO_INCREMENT is present
60
- }
61
-
35
+ if (column) {
62
36
  columns.push(column);
63
37
  } else if (indexMatch && !constraintMatch) {
64
38
 
@@ -141,11 +115,83 @@ export const parseConstraint = (sql) => {
141
115
  };
142
116
  };
143
117
 
144
- const parseColumnType = (line) => {
145
- // Match type pattern including parameters, handling both single and double parameter types
146
- const typeRegex = /`[^`]+`\s+([a-zA-Z]+(?:\([0-9]+(?:,[0-9]+)?\))?)/i;
147
- const match = line.match(typeRegex);
148
-
149
- if (!match) return null;
150
- return match[1]; // Returns the full type including parameters
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;
151
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
+ }