@hesed/mysql 0.6.1 → 0.6.2

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 CHANGED
@@ -26,7 +26,7 @@ $ npm install -g @hesed/mysql
26
26
  $ mq COMMAND
27
27
  running command...
28
28
  $ mq (--version)
29
- @hesed/mysql/0.6.1 linux-x64 node-v24.18.0
29
+ @hesed/mysql/0.6.2 linux-x64 node-v24.20.0
30
30
  $ mq --help [COMMAND]
31
31
  USAGE
32
32
  $ mq COMMAND
@@ -82,7 +82,7 @@ EXAMPLES
82
82
  $ mq mysql auth add -p prod
83
83
  ```
84
84
 
85
- _See code: [src/commands/mysql/auth/add.ts](https://github.com/hesedcasa/mysql/blob/v0.6.1/src/commands/mysql/auth/add.ts)_
85
+ _See code: [src/commands/mysql/auth/add.ts](https://github.com/hesedcasa/mysql/blob/v0.6.2/src/commands/mysql/auth/add.ts)_
86
86
 
87
87
  ## `mq mysql auth delete`
88
88
 
@@ -107,7 +107,7 @@ EXAMPLES
107
107
  $ mq mysql auth delete -p prod
108
108
  ```
109
109
 
110
- _See code: [src/commands/mysql/auth/delete.ts](https://github.com/hesedcasa/mysql/blob/v0.6.1/src/commands/mysql/auth/delete.ts)_
110
+ _See code: [src/commands/mysql/auth/delete.ts](https://github.com/hesedcasa/mysql/blob/v0.6.2/src/commands/mysql/auth/delete.ts)_
111
111
 
112
112
  ## `mq mysql auth list`
113
113
 
@@ -127,7 +127,7 @@ EXAMPLES
127
127
  $ mq mysql auth list
128
128
  ```
129
129
 
130
- _See code: [src/commands/mysql/auth/list.ts](https://github.com/hesedcasa/mysql/blob/v0.6.1/src/commands/mysql/auth/list.ts)_
130
+ _See code: [src/commands/mysql/auth/list.ts](https://github.com/hesedcasa/mysql/blob/v0.6.2/src/commands/mysql/auth/list.ts)_
131
131
 
132
132
  ## `mq mysql auth profile`
133
133
 
@@ -152,7 +152,7 @@ EXAMPLES
152
152
  $ mq mysql auth profile --default test
153
153
  ```
154
154
 
155
- _See code: [src/commands/mysql/auth/profile.ts](https://github.com/hesedcasa/mysql/blob/v0.6.1/src/commands/mysql/auth/profile.ts)_
155
+ _See code: [src/commands/mysql/auth/profile.ts](https://github.com/hesedcasa/mysql/blob/v0.6.2/src/commands/mysql/auth/profile.ts)_
156
156
 
157
157
  ## `mq mysql auth test`
158
158
 
@@ -177,7 +177,7 @@ EXAMPLES
177
177
  $ mq mysql auth test -p prod
178
178
  ```
179
179
 
180
- _See code: [src/commands/mysql/auth/test.ts](https://github.com/hesedcasa/mysql/blob/v0.6.1/src/commands/mysql/auth/test.ts)_
180
+ _See code: [src/commands/mysql/auth/test.ts](https://github.com/hesedcasa/mysql/blob/v0.6.2/src/commands/mysql/auth/test.ts)_
181
181
 
182
182
  ## `mq mysql auth update`
183
183
 
@@ -211,7 +211,7 @@ EXAMPLES
211
211
  $ mq mysql auth update -p test
212
212
  ```
213
213
 
214
- _See code: [src/commands/mysql/auth/update.ts](https://github.com/hesedcasa/mysql/blob/v0.6.1/src/commands/mysql/auth/update.ts)_
214
+ _See code: [src/commands/mysql/auth/update.ts](https://github.com/hesedcasa/mysql/blob/v0.6.2/src/commands/mysql/auth/update.ts)_
215
215
 
216
216
  ## `mq mysql databases`
217
217
 
@@ -236,7 +236,7 @@ EXAMPLES
236
236
  $ mq mysql databases -p staging
237
237
  ```
238
238
 
239
- _See code: [src/commands/mysql/databases.ts](https://github.com/hesedcasa/mysql/blob/v0.6.1/src/commands/mysql/databases.ts)_
239
+ _See code: [src/commands/mysql/databases.ts](https://github.com/hesedcasa/mysql/blob/v0.6.2/src/commands/mysql/databases.ts)_
240
240
 
241
241
  ## `mq mysql describe-table TABLE`
242
242
 
@@ -265,7 +265,7 @@ EXAMPLES
265
265
  $ mq mysql describe-table orders --toon -p prod
266
266
  ```
267
267
 
268
- _See code: [src/commands/mysql/describe-table.ts](https://github.com/hesedcasa/mysql/blob/v0.6.1/src/commands/mysql/describe-table.ts)_
268
+ _See code: [src/commands/mysql/describe-table.ts](https://github.com/hesedcasa/mysql/blob/v0.6.2/src/commands/mysql/describe-table.ts)_
269
269
 
270
270
  ## `mq mysql explain QUERY`
271
271
 
@@ -294,7 +294,7 @@ EXAMPLES
294
294
  $ mq mysql explain "SELECT * FROM orders JOIN users ON orders.user_id = users.id" --json
295
295
  ```
296
296
 
297
- _See code: [src/commands/mysql/explain.ts](https://github.com/hesedcasa/mysql/blob/v0.6.1/src/commands/mysql/explain.ts)_
297
+ _See code: [src/commands/mysql/explain.ts](https://github.com/hesedcasa/mysql/blob/v0.6.2/src/commands/mysql/explain.ts)_
298
298
 
299
299
  ## `mq mysql indexes TABLE`
300
300
 
@@ -323,7 +323,7 @@ EXAMPLES
323
323
  $ mq mysql indexes orders --json -p prod
324
324
  ```
325
325
 
326
- _See code: [src/commands/mysql/indexes.ts](https://github.com/hesedcasa/mysql/blob/v0.6.1/src/commands/mysql/indexes.ts)_
326
+ _See code: [src/commands/mysql/indexes.ts](https://github.com/hesedcasa/mysql/blob/v0.6.2/src/commands/mysql/indexes.ts)_
327
327
 
328
328
  ## `mq mysql query QUERY`
329
329
 
@@ -355,7 +355,7 @@ EXAMPLES
355
355
  $ mq mysql query "DELETE FROM sessions" -p prod --skip-confirmation
356
356
  ```
357
357
 
358
- _See code: [src/commands/mysql/query.ts](https://github.com/hesedcasa/mysql/blob/v0.6.1/src/commands/mysql/query.ts)_
358
+ _See code: [src/commands/mysql/query.ts](https://github.com/hesedcasa/mysql/blob/v0.6.2/src/commands/mysql/query.ts)_
359
359
 
360
360
  ## `mq mysql tables`
361
361
 
@@ -380,5 +380,5 @@ EXAMPLES
380
380
  $ mq mysql tables -p local
381
381
  ```
382
382
 
383
- _See code: [src/commands/mysql/tables.ts](https://github.com/hesedcasa/mysql/blob/v0.6.1/src/commands/mysql/tables.ts)_
383
+ _See code: [src/commands/mysql/tables.ts](https://github.com/hesedcasa/mysql/blob/v0.6.2/src/commands/mysql/tables.ts)_
384
384
  <!-- commandsstop -->
@@ -45,7 +45,7 @@ export class MySQLUtil {
45
45
  catch (error) {
46
46
  const errorMessage = error instanceof Error ? error.message : String(error);
47
47
  return {
48
- error: `ERROR: ${errorMessage}`,
48
+ error: errorMessage,
49
49
  success: false,
50
50
  };
51
51
  }
@@ -110,7 +110,7 @@ export class MySQLUtil {
110
110
  catch (error) {
111
111
  const errorMessage = error instanceof Error ? error.message : String(error);
112
112
  return {
113
- error: `ERROR: ${errorMessage}`,
113
+ error: errorMessage,
114
114
  success: false,
115
115
  };
116
116
  }
@@ -129,7 +129,7 @@ export class MySQLUtil {
129
129
  catch (error) {
130
130
  const errorMessage = error instanceof Error ? error.message : String(error);
131
131
  return {
132
- error: `ERROR: ${errorMessage}`,
132
+ error: errorMessage,
133
133
  success: false,
134
134
  };
135
135
  }
@@ -149,7 +149,7 @@ export class MySQLUtil {
149
149
  catch (error) {
150
150
  const errorMessage = error instanceof Error ? error.message : String(error);
151
151
  return {
152
- error: `ERROR: ${errorMessage}`,
152
+ error: errorMessage,
153
153
  success: false,
154
154
  };
155
155
  }
@@ -158,6 +158,14 @@ export class MySQLUtil {
158
158
  try {
159
159
  const [rows] = await this.runQuery(profileName, 'SHOW TABLES');
160
160
  const rowsArray = rows;
161
+ // SHOW TABLES names its one column after the database, so the key has to
162
+ // be read off a row — an empty schema has none.
163
+ if (rowsArray.length === 0) {
164
+ return {
165
+ data: { result: 'No tables found in this database', tables: [] },
166
+ success: true,
167
+ };
168
+ }
161
169
  const tableKey = Object.keys(rowsArray[0])[0];
162
170
  const tables = rowsArray.map((row) => row[tableKey]);
163
171
  return {
@@ -171,7 +179,7 @@ export class MySQLUtil {
171
179
  catch (error) {
172
180
  const errorMessage = error instanceof Error ? error.message : String(error);
173
181
  return {
174
- error: `ERROR: ${errorMessage}`,
182
+ error: errorMessage,
175
183
  success: false,
176
184
  };
177
185
  }
@@ -190,7 +198,7 @@ export class MySQLUtil {
190
198
  catch (error) {
191
199
  const errorMessage = error instanceof Error ? error.message : String(error);
192
200
  return {
193
- error: `ERROR: ${errorMessage}`,
201
+ error: errorMessage,
194
202
  success: false,
195
203
  };
196
204
  }
@@ -211,7 +219,7 @@ export class MySQLUtil {
211
219
  catch (error) {
212
220
  const errorMessage = error instanceof Error ? error.message : String(error);
213
221
  return {
214
- error: `ERROR: ${errorMessage}`,
222
+ error: errorMessage,
215
223
  success: false,
216
224
  };
217
225
  }
@@ -1,8 +1,149 @@
1
+ // Escapes a config-supplied operation so it can be embedded in a pattern.
2
+ function escapeForPattern(word) {
3
+ return word.replaceAll(/[$()*+.?[\\\]^{|}]/g, String.raw `\$&`);
4
+ }
5
+ // String literals ('...', "...") and quoted identifiers (`...`).
6
+ const QUOTE_CHARS = new Set(['"', "'", '`']);
7
+ // Returns the index just past the quoted run that starts at `start`, so the
8
+ // caller can copy a string literal or quoted identifier through untouched. An
9
+ // unterminated quote swallows the rest of the query, which keeps any keyword it
10
+ // hides visible to the safety checks.
11
+ function findQuoteEnd(query, start) {
12
+ const quote = query[start];
13
+ for (let index = start + 1; index < query.length; index += 1) {
14
+ // Backslash escapes apply inside string literals but not inside `identifiers`.
15
+ if (quote !== '`' && query[index] === '\\') {
16
+ index += 1;
17
+ continue;
18
+ }
19
+ if (query[index] === quote) {
20
+ // A doubled quote is an escaped quote, not the end of the run.
21
+ if (query[index + 1] === quote) {
22
+ index += 1;
23
+ continue;
24
+ }
25
+ return index + 1;
26
+ }
27
+ }
28
+ return query.length;
29
+ }
30
+ // Walks the query, yielding each stretch of it alongside the text MySQL would
31
+ // actually execute there. Code and quoted runs come through untouched; an
32
+ // ordinary comment collapses to a single space, so keyword matching sees
33
+ // `DROP /* here */ DATABASE` for what MySQL sees: `DROP DATABASE`.
34
+ //
35
+ // The scan is quote aware — `--`, `#` and `/*` inside a string literal or a
36
+ // quoted identifier are data, not the start of a comment.
37
+ //
38
+ // A `/*! ... */` version comment keeps its body, because MySQL executes it —
39
+ // only the `/*!`, its leading version digits and the closing `*/` become
40
+ // whitespace, so `DROP /*!40000 */ DATABASE` reads as `DROP DATABASE` here too.
41
+ // MySQL 8.4 consumes a five- or six-digit version (`/*!080411 */` runs), and
42
+ // any other digit run it executes as part of the body, where it can only turn
43
+ // the statement into a syntax error — so every leading digit goes, whatever the
44
+ // server's rule for them is.
45
+ // A `/*+ ... */` hint comment goes entirely, like any other comment: its body
46
+ // is hint syntax, never SQL MySQL would run.
47
+ //
48
+ // `quoted` marks a string literal or quoted identifier, so a caller that cares
49
+ // about clause structure rather than raw text can blank it out.
50
+ function* scanQuery(query) {
51
+ let index = 0;
52
+ while (index < query.length) {
53
+ const char = query[index];
54
+ if (QUOTE_CHARS.has(char)) {
55
+ const end = findQuoteEnd(query, index);
56
+ yield { executable: query.slice(index, end), quoted: true, start: index };
57
+ index = end;
58
+ continue;
59
+ }
60
+ if (char === '/' && query[index + 1] === '*') {
61
+ const close = query.indexOf('*/', index + 2);
62
+ const end = close === -1 ? query.length : close + 2;
63
+ const body = query.slice(index + 3, close === -1 ? query.length : close);
64
+ const versionComment = query[index + 2] === '!';
65
+ yield { executable: versionComment ? ` ${body.replace(/^\d+/u, ' ')} ` : ' ', quoted: false, start: index };
66
+ index = end;
67
+ continue;
68
+ }
69
+ // `--` only opens a comment when whitespace (or the end of the query)
70
+ // follows it; `a--b` is two minus signs.
71
+ const dashComment = char === '-' && query[index + 1] === '-' && /^\s*$/u.test(query[index + 2] ?? '');
72
+ if (char === '#' || dashComment) {
73
+ const newline = query.indexOf('\n', index);
74
+ yield { executable: ' ', quoted: false, start: index };
75
+ index = newline === -1 ? query.length : newline;
76
+ continue;
77
+ }
78
+ yield { executable: char, quoted: false, start: index };
79
+ index += 1;
80
+ }
81
+ }
82
+ // Replaces every non-executable MySQL comment with a single space, and every
83
+ // string literal or quoted identifier too when `blankQuoted` is set.
84
+ function stripComments(query, blankQuoted = false) {
85
+ let stripped = '';
86
+ for (const { executable, quoted } of scanQuery(query)) {
87
+ stripped += blankQuoted && quoted ? ' ' : executable;
88
+ }
89
+ return stripped;
90
+ }
91
+ // Index of the `;` that terminates the query's single statement — the last one
92
+ // with nothing but whitespace and comments after it — or -1 when the query is
93
+ // not terminated. A `;` inside a string literal or a comment is not a
94
+ // terminator, because MySQL does not execute it as one.
95
+ function findTrailingTerminator(query) {
96
+ let terminator = -1;
97
+ for (const { executable, start } of scanQuery(query)) {
98
+ if (executable === ';') {
99
+ terminator = start;
100
+ }
101
+ else if (/\S/u.test(executable)) {
102
+ // Executable text after a `;` means that `;` separated statements rather
103
+ // than ending the query.
104
+ terminator = -1;
105
+ }
106
+ }
107
+ return terminator;
108
+ }
109
+ // Strips comments, then trims and upper-cases what is left, giving the checks
110
+ // below a single view of the SQL MySQL would actually execute. Quoted text
111
+ // stays, so a keyword hidden in a string literal is still visible.
112
+ function normalize(query) {
113
+ return stripComments(query).trim().toUpperCase();
114
+ }
115
+ // The same view with every string literal and quoted identifier blanked out.
116
+ // Clause detection asks a structural question — does this statement have a
117
+ // WHERE, does it have a LIMIT — and `SELECT 'LIMIT 5' FROM metrics` has neither,
118
+ // so keeping the quoted text there would drop the row cap off a query that is
119
+ // in fact unbounded. The blacklist and confirmation checks ask the opposite
120
+ // question and keep quoted text on purpose.
121
+ function normalizeClauses(query) {
122
+ return stripComments(query, true).trim().toUpperCase();
123
+ }
124
+ // Tests whether an operation appears in an already-normalized query as whole
125
+ // words, allowing any run of whitespace between the words of a multi-word
126
+ // operation ("DROP DATABASE", "DROP\nDATABASE"). Comments are gone by this
127
+ // point, so a comment between those words no longer hides the operation.
128
+ //
129
+ // The scan deliberately covers the whole query rather than just the leading
130
+ // keyword: a destructive keyword is worth flagging wherever it appears. Word
131
+ // boundaries stop the reverse mistake, where `nowhere_stats` reads as a WHERE
132
+ // clause or `limit_reached` as a LIMIT. A keyword inside a string literal still
133
+ // matches, which errs toward asking for confirmation rather than skipping it —
134
+ // and keeps `PREPARE s FROM 'DROP DATABASE x'` in reach of the blacklist.
135
+ function containsOperation(normalizedQuery, operation) {
136
+ const pattern = operation
137
+ .trim()
138
+ .split(/\s+/)
139
+ .map((word) => escapeForPattern(word))
140
+ .join(String.raw `\s+`);
141
+ return new RegExp(String.raw `\b${pattern}\b`, 'u').test(normalizedQuery);
142
+ }
1
143
  export function checkBlacklist(query, blacklistedOperations) {
2
- const normalizedQuery = query.trim().toUpperCase();
144
+ const normalizedQuery = normalize(query);
3
145
  for (const operation of blacklistedOperations) {
4
- const normalizedOp = operation.toUpperCase();
5
- if (normalizedQuery.includes(normalizedOp)) {
146
+ if (containsOperation(normalizedQuery, operation.toUpperCase())) {
6
147
  return {
7
148
  allowed: false,
8
149
  reason: `Operation "${operation}" is blacklisted and not allowed`,
@@ -12,10 +153,9 @@ export function checkBlacklist(query, blacklistedOperations) {
12
153
  return { allowed: true };
13
154
  }
14
155
  export function requiresConfirmation(query, confirmationOperations) {
15
- const normalizedQuery = query.trim().toUpperCase();
156
+ const normalizedQuery = normalize(query);
16
157
  for (const operation of confirmationOperations) {
17
- const normalizedOp = operation.toUpperCase();
18
- if (normalizedQuery.startsWith(normalizedOp) || normalizedQuery.includes(` ${normalizedOp} `)) {
158
+ if (containsOperation(normalizedQuery, operation.toUpperCase())) {
19
159
  return {
20
160
  message: `This query contains a destructive operation: ${operation}`,
21
161
  required: true,
@@ -25,7 +165,7 @@ export function requiresConfirmation(query, confirmationOperations) {
25
165
  return { required: false };
26
166
  }
27
167
  export function getQueryType(query) {
28
- const normalizedQuery = query.trim().toUpperCase();
168
+ const normalizedQuery = normalize(query);
29
169
  const firstWord = normalizedQuery.split(/\s+/, 1)[0];
30
170
  const knownTypes = [
31
171
  'SELECT',
@@ -47,10 +187,10 @@ export function getQueryType(query) {
47
187
  }
48
188
  export function analyzeQuery(query) {
49
189
  const warnings = [];
50
- const normalizedQuery = query.trim().toUpperCase();
190
+ const normalizedQuery = normalizeClauses(query);
51
191
  // Check for missing WHERE clause in UPDATE/DELETE
52
192
  if ((normalizedQuery.startsWith('UPDATE') || normalizedQuery.startsWith('DELETE')) &&
53
- !normalizedQuery.includes('WHERE')) {
193
+ !containsOperation(normalizedQuery, 'WHERE')) {
54
194
  warnings.push({
55
195
  level: 'warning',
56
196
  message: 'Missing WHERE clause in UPDATE/DELETE query',
@@ -66,7 +206,7 @@ export function analyzeQuery(query) {
66
206
  });
67
207
  }
68
208
  // Check for missing LIMIT in SELECT
69
- if (normalizedQuery.startsWith('SELECT') && !normalizedQuery.includes('LIMIT')) {
209
+ if (normalizedQuery.startsWith('SELECT') && !containsOperation(normalizedQuery, 'LIMIT')) {
70
210
  warnings.push({
71
211
  level: 'info',
72
212
  message: 'SELECT query without LIMIT',
@@ -76,9 +216,17 @@ export function analyzeQuery(query) {
76
216
  return warnings;
77
217
  }
78
218
  export function applyDefaultLimit(query, defaultLimit) {
79
- const normalizedQuery = query.trim().toUpperCase();
80
- if (normalizedQuery.startsWith('SELECT') && !normalizedQuery.includes('LIMIT')) {
81
- return `${query.trim()} LIMIT ${defaultLimit}`;
219
+ const normalizedQuery = normalizeClauses(query);
220
+ if (!normalizedQuery.startsWith('SELECT') || containsOperation(normalizedQuery, 'LIMIT')) {
221
+ return query;
222
+ }
223
+ const terminator = findTrailingTerminator(query);
224
+ // On its own line either way: a trailing `-- comment` would otherwise swallow
225
+ // the LIMIT.
226
+ if (terminator === -1) {
227
+ return `${query.trim()}\nLIMIT ${defaultLimit}`;
82
228
  }
83
- return query;
229
+ // The LIMIT has to go in front of the terminator. After it, MySQL reads
230
+ // `LIMIT 100` as a second statement and rejects the whole query.
231
+ return `${query.slice(0, terminator).trim()}\nLIMIT ${defaultLimit}\n${query.slice(terminator).trim()}`;
84
232
  }
@@ -689,5 +689,5 @@
689
689
  ]
690
690
  }
691
691
  },
692
- "version": "0.6.1"
692
+ "version": "0.6.2"
693
693
  }
package/package.json CHANGED
@@ -1,14 +1,14 @@
1
1
  {
2
2
  "name": "@hesed/mysql",
3
3
  "description": "CLI for MySQL database interaction",
4
- "version": "0.6.1",
4
+ "version": "0.6.2",
5
5
  "author": "Hesed",
6
6
  "bin": {
7
7
  "mq": "./bin/run.js"
8
8
  },
9
9
  "bugs": "https://github.com/hesedcasa/mysql/issues",
10
10
  "dependencies": {
11
- "@hesed/plugin-lib": "^0.12.1",
11
+ "@hesed/plugin-lib": "^0.12.3",
12
12
  "@inquirer/prompts": "^8.3.0",
13
13
  "@oclif/core": "^4",
14
14
  "@toon-format/toon": "^2.1.0",
@@ -24,7 +24,7 @@
24
24
  "@types/mocha": "^10",
25
25
  "@types/node": "^26",
26
26
  "@types/sinon": "^22.0.0",
27
- "c8": "^11.0.0",
27
+ "c8": "^12.0.0",
28
28
  "chai": "^6",
29
29
  "eslint": "^10",
30
30
  "eslint-config-oclif": "^7",
@@ -69,13 +69,17 @@
69
69
  "postpack": "shx rm -f oclif.manifest.json",
70
70
  "posttest": "npm run lint",
71
71
  "prepack": "oclif manifest && oclif readme",
72
- "test": "mocha --forbid-only \"test/**/*.test.ts\"",
72
+ "test": "mocha --forbid-only \"test/**/*.test.ts\" --ignore \"test/e2e/**\"",
73
73
  "test:coverage": "c8 npm test",
74
74
  "test:coverage:report": "c8 --reporter=html --reporter=text npm test",
75
75
  "version": "oclif readme && git add README.md",
76
76
  "find-deadcode": "ts-prune --ignore '(run|default)'",
77
77
  "format": "eslint --cache --fix --quiet . && prettier --write --cache .",
78
- "pre-commit": "npm run format && npm run find-deadcode"
78
+ "pre-commit": "npm run format && npm run find-deadcode",
79
+ "test:e2e": "./scripts/e2e.sh",
80
+ "e2e:up": "docker compose -f docker/compose.yaml up -d --build --wait",
81
+ "e2e:down": "docker compose -f docker/compose.yaml down -v --remove-orphans",
82
+ "e2e:mocha": "mocha --forbid-only \"test/e2e/**/*.e2e.test.ts\""
79
83
  },
80
84
  "types": "dist/index.d.ts",
81
85
  "c8": {