@hesed/mysql 0.6.0 → 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.0 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.0/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.0/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.0/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.0/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.0/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.0/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.0/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.0/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.0/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.0/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.0/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.0/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 -->
@@ -1,4 +1,4 @@
1
- import { ApiResult } from '@hesed/plugin-lib';
1
+ import { type ApiResult } from '@hesed/plugin-lib';
2
2
  import { BaseCommand } from '../../base-command.js';
3
3
  export default class MySQLDescribeTable extends BaseCommand {
4
4
  static args: {
@@ -1,4 +1,4 @@
1
- import { ApiResult } from '@hesed/plugin-lib';
1
+ import { type ApiResult } from '@hesed/plugin-lib';
2
2
  import { BaseCommand } from '../../base-command.js';
3
3
  export default class MySQLExplain extends BaseCommand {
4
4
  static args: {
@@ -1,4 +1,4 @@
1
- import { ApiResult } from '@hesed/plugin-lib';
1
+ import { type ApiResult } from '@hesed/plugin-lib';
2
2
  import { BaseCommand } from '../../base-command.js';
3
3
  export default class MySQLIndexes extends BaseCommand {
4
4
  static args: {
@@ -1,4 +1,4 @@
1
- import { ApiResult } from '@hesed/plugin-lib';
1
+ import { type ApiResult } from '@hesed/plugin-lib';
2
2
  import { BaseCommand } from '../../base-command.js';
3
3
  export default class MySQLQuery extends BaseCommand {
4
4
  static args: {
@@ -1,5 +1,5 @@
1
1
  import type { ConnectionOptions as MySQL2ConnectionOptions } from 'mysql2/promise';
2
- export interface DatabaseProfile {
2
+ export type DatabaseProfile = {
3
3
  database: string;
4
4
  host: string;
5
5
  maxConcurrentQueries?: number;
@@ -8,20 +8,20 @@ export interface DatabaseProfile {
8
8
  queryQueueTimeoutMs?: number;
9
9
  ssl?: boolean;
10
10
  user: string;
11
- }
12
- interface SafetyConfig {
11
+ };
12
+ type SafetyConfig = {
13
13
  blacklistedOperations: string[];
14
14
  defaultLimit: number;
15
15
  maxConcurrentQueries?: number;
16
16
  queryQueueTimeoutMs?: number;
17
17
  requireConfirmationFor: string[];
18
- }
19
- export interface MySQLConfig {
18
+ };
19
+ export type MySQLConfig = {
20
20
  defaultFormat: 'csv' | 'json' | 'table' | 'toon';
21
21
  defaultProfile: string;
22
22
  profiles: Record<string, DatabaseProfile>;
23
23
  safety: SafetyConfig;
24
- }
24
+ };
25
25
  type MySQLConnectionOptions = Pick<MySQL2ConnectionOptions, 'connectTimeout' | 'database' | 'host' | 'multipleStatements' | 'password' | 'port' | 'ssl' | 'user'>;
26
26
  export declare function getMySQLConnectionOptions(config: MySQLConfig, profileName: string): MySQLConnectionOptions;
27
27
  export {};
@@ -1,36 +1,36 @@
1
1
  import type { ApiResult } from '@hesed/plugin-lib';
2
2
  export type OutputFormat = 'csv' | 'json' | 'table' | 'toon';
3
- export interface QueryData {
3
+ export type QueryData = {
4
4
  message?: string;
5
5
  notices?: string;
6
6
  requiresConfirmation?: boolean;
7
7
  result?: unknown;
8
- }
9
- export interface DatabaseListData {
8
+ };
9
+ export type DatabaseListData = {
10
10
  databases: string[];
11
11
  result?: string;
12
- }
13
- export interface TableListData {
12
+ };
13
+ export type TableListData = {
14
14
  result?: string;
15
15
  tables: string[];
16
- }
17
- export interface TableStructureData {
16
+ };
17
+ export type TableStructureData = {
18
18
  result?: string;
19
- structure: Record<string, unknown>[];
20
- }
21
- export interface IndexData {
22
- indexes: Record<string, unknown>[];
19
+ structure: Array<Record<string, unknown>>;
20
+ };
21
+ export type IndexData = {
22
+ indexes: Array<Record<string, unknown>>;
23
23
  result?: string;
24
- }
25
- export interface ExplainData {
26
- plan: Record<string, unknown>[];
24
+ };
25
+ export type ExplainData = {
26
+ plan: Array<Record<string, unknown>>;
27
27
  result?: string;
28
- }
29
- interface ConnectionTestData {
28
+ };
29
+ type ConnectionTestData = {
30
30
  database: string;
31
31
  result?: string;
32
32
  version: string;
33
- }
33
+ };
34
34
  export type QueryResult = ApiResult & {
35
35
  data?: QueryData;
36
36
  };
@@ -52,7 +52,7 @@ export type ExplainResult = ApiResult & {
52
52
  export type ConnectionTestResult = ApiResult & {
53
53
  data?: ConnectionTestData;
54
54
  };
55
- export interface DatabaseUtil {
55
+ export type DatabaseUtil = {
56
56
  closeAll(): Promise<void>;
57
57
  describeTable(profileName: string, table: string, format?: OutputFormat): Promise<TableStructureResult>;
58
58
  executeQuery(profileName: string, query: string, format?: OutputFormat, skipConfirmation?: boolean): Promise<QueryResult>;
@@ -60,5 +60,5 @@ export interface DatabaseUtil {
60
60
  listDatabases(profileName: string): Promise<DatabaseListResult>;
61
61
  listTables(profileName: string): Promise<TableListResult>;
62
62
  showIndexes(profileName: string, table: string, format?: OutputFormat): Promise<IndexResult>;
63
- }
63
+ };
64
64
  export {};
@@ -1,4 +1,5 @@
1
1
  import { encode } from '@toon-format/toon';
2
+ import { Buffer } from 'node:buffer';
2
3
  function formatAsCsv(rows, fields) {
3
4
  if (!rows || rows.length === 0) {
4
5
  return '';
@@ -68,9 +68,10 @@ export async function testDirectConnection(profile) {
68
68
  return result;
69
69
  }
70
70
  export async function closeConnections() {
71
- if (mysqlUtil) {
72
- await mysqlUtil.closeAll();
73
- mysqlUtil = null;
74
- cachedConfig = null;
71
+ if (!mysqlUtil) {
72
+ return;
75
73
  }
74
+ await mysqlUtil.closeAll();
75
+ mysqlUtil = null;
76
+ cachedConfig = null;
76
77
  }
@@ -1,9 +1,9 @@
1
- import type { MySQLConfig } from './config-loader.js';
2
1
  import type { ConnectionTestResult, DatabaseListResult, DatabaseUtil, ExplainResult, IndexResult, OutputFormat, QueryResult, TableListResult, TableStructureResult } from './database.js';
2
+ import { type MySQLConfig } from './config-loader.js';
3
3
  export declare class MySQLUtil implements DatabaseUtil {
4
- private config;
5
- private pools;
6
- private querySlots;
4
+ private readonly config;
5
+ private readonly pools;
6
+ private readonly querySlots;
7
7
  constructor(config: MySQLConfig);
8
8
  closeAll(): Promise<void>;
9
9
  describeTable(profileName: string, table: string, format?: 'json' | 'table' | 'toon'): Promise<TableStructureResult>;
@@ -4,6 +4,10 @@ import { FORMATTERS } from './formatters.js';
4
4
  import { analyzeQuery, applyDefaultLimit, checkBlacklist, getQueryType, requiresConfirmation } from './query-validator.js';
5
5
  const DEFAULT_MAX_CONCURRENT_QUERIES = 5;
6
6
  const DEFAULT_QUEUE_TIMEOUT_MS = 60_000;
7
+ // Formats that must emit only the data payload on stdout.
8
+ const MACHINE_FORMATS = new Set(['csv', 'json', 'toon']);
9
+ // Query types whose result set is rows rather than an OkPacket.
10
+ const READ_QUERY_TYPES = new Set(['DESCRIBE', 'EXPLAIN', 'SELECT', 'SHOW']);
7
11
  export class MySQLUtil {
8
12
  config;
9
13
  pools;
@@ -16,14 +20,16 @@ export class MySQLUtil {
16
20
  async closeAll() {
17
21
  // Reject queued queries first so nothing waits forever on a closed util.
18
22
  for (const slot of this.querySlots.values()) {
19
- for (const waiter of slot.waiting.splice(0)) {
23
+ const { waiting } = slot;
24
+ slot.waiting = [];
25
+ for (const waiter of waiting) {
20
26
  waiter.reject(new Error('Connections were closed while the query was waiting for a free slot'));
21
27
  }
22
28
  }
23
29
  this.querySlots.clear();
24
30
  const pools = [...this.pools.values()];
25
31
  this.pools.clear();
26
- await Promise.allSettled(pools.map((pool) => pool.end()));
32
+ await Promise.allSettled(pools.map(async (pool) => pool.end()));
27
33
  }
28
34
  async describeTable(profileName, table, format = 'table') {
29
35
  try {
@@ -39,7 +45,7 @@ export class MySQLUtil {
39
45
  catch (error) {
40
46
  const errorMessage = error instanceof Error ? error.message : String(error);
41
47
  return {
42
- error: `ERROR: ${errorMessage}`,
48
+ error: errorMessage,
43
49
  success: false,
44
50
  };
45
51
  }
@@ -66,7 +72,7 @@ export class MySQLUtil {
66
72
  }
67
73
  // Machine-readable formats must emit only the data payload on stdout, so
68
74
  // analysis warnings and status lines are collected as notices instead.
69
- const machineFormat = format === 'json' || format === 'csv' || format === 'toon';
75
+ const isMachineFormat = MACHINE_FORMATS.has(format);
70
76
  const notices = [];
71
77
  const warnings = analyzeQuery(query);
72
78
  if (warnings.length > 0) {
@@ -83,7 +89,7 @@ export class MySQLUtil {
83
89
  }
84
90
  try {
85
91
  const [rows, fields] = await this.runQuery(profileName, finalQuery);
86
- const isRead = queryType === 'SELECT' || queryType === 'SHOW' || queryType === 'DESCRIBE' || queryType === 'EXPLAIN';
92
+ const isRead = READ_QUERY_TYPES.has(queryType);
87
93
  let data = isRead
88
94
  ? this.formatReadResult(rows, fields, format, notices)
89
95
  : this.formatWriteResult(rows, notices, format);
@@ -95,8 +101,8 @@ export class MySQLUtil {
95
101
  // For machine formats the data is returned alone and notices go to stderr.
96
102
  return {
97
103
  data: {
98
- notices: machineFormat ? notice : undefined,
99
- result: machineFormat ? data : `${notice}\n\n${data}`,
104
+ notices: isMachineFormat ? notice : undefined,
105
+ result: isMachineFormat ? data : `${notice}\n\n${data}`,
100
106
  },
101
107
  success: true,
102
108
  };
@@ -104,7 +110,7 @@ export class MySQLUtil {
104
110
  catch (error) {
105
111
  const errorMessage = error instanceof Error ? error.message : String(error);
106
112
  return {
107
- error: `ERROR: ${errorMessage}`,
113
+ error: errorMessage,
108
114
  success: false,
109
115
  };
110
116
  }
@@ -123,7 +129,7 @@ export class MySQLUtil {
123
129
  catch (error) {
124
130
  const errorMessage = error instanceof Error ? error.message : String(error);
125
131
  return {
126
- error: `ERROR: ${errorMessage}`,
132
+ error: errorMessage,
127
133
  success: false,
128
134
  };
129
135
  }
@@ -143,7 +149,7 @@ export class MySQLUtil {
143
149
  catch (error) {
144
150
  const errorMessage = error instanceof Error ? error.message : String(error);
145
151
  return {
146
- error: `ERROR: ${errorMessage}`,
152
+ error: errorMessage,
147
153
  success: false,
148
154
  };
149
155
  }
@@ -152,6 +158,14 @@ export class MySQLUtil {
152
158
  try {
153
159
  const [rows] = await this.runQuery(profileName, 'SHOW TABLES');
154
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
+ }
155
169
  const tableKey = Object.keys(rowsArray[0])[0];
156
170
  const tables = rowsArray.map((row) => row[tableKey]);
157
171
  return {
@@ -165,7 +179,7 @@ export class MySQLUtil {
165
179
  catch (error) {
166
180
  const errorMessage = error instanceof Error ? error.message : String(error);
167
181
  return {
168
- error: `ERROR: ${errorMessage}`,
182
+ error: errorMessage,
169
183
  success: false,
170
184
  };
171
185
  }
@@ -184,7 +198,7 @@ export class MySQLUtil {
184
198
  catch (error) {
185
199
  const errorMessage = error instanceof Error ? error.message : String(error);
186
200
  return {
187
- error: `ERROR: ${errorMessage}`,
201
+ error: errorMessage,
188
202
  success: false,
189
203
  };
190
204
  }
@@ -205,14 +219,14 @@ export class MySQLUtil {
205
219
  catch (error) {
206
220
  const errorMessage = error instanceof Error ? error.message : String(error);
207
221
  return {
208
- error: `ERROR: ${errorMessage}`,
222
+ error: errorMessage,
209
223
  success: false,
210
224
  };
211
225
  }
212
226
  }
213
227
  // Grants a query slot for the profile, or waits until one frees up. The
214
228
  // returned release callback must be invoked exactly once per acquisition.
215
- acquireQuerySlot(profileName) {
229
+ async acquireQuerySlot(profileName) {
216
230
  const limit = this.getQueryLimit(profileName);
217
231
  let slot = this.querySlots.get(profileName);
218
232
  if (!slot) {
@@ -231,7 +245,7 @@ export class MySQLUtil {
231
245
  };
232
246
  if (state.active < limit) {
233
247
  state.active += 1;
234
- return Promise.resolve(release);
248
+ return release;
235
249
  }
236
250
  const timeoutMs = this.config.profiles[profileName]?.queryQueueTimeoutMs ??
237
251
  this.config.safety.queryQueueTimeoutMs ??
@@ -312,7 +326,7 @@ export class MySQLUtil {
312
326
  async runQuery(profileName, sql) {
313
327
  const release = await this.acquireQuerySlot(profileName);
314
328
  try {
315
- return (await this.getPool(profileName).query(sql));
329
+ return await this.getPool(profileName).query(sql);
316
330
  }
317
331
  finally {
318
332
  release();
@@ -1,16 +1,16 @@
1
- interface BlacklistCheckResult {
1
+ type BlacklistCheckResult = {
2
2
  allowed: boolean;
3
3
  reason?: string;
4
- }
5
- interface ConfirmationCheckResult {
4
+ };
5
+ type ConfirmationCheckResult = {
6
6
  message?: string;
7
7
  required: boolean;
8
- }
9
- interface QueryWarning {
8
+ };
9
+ type QueryWarning = {
10
10
  level: 'info' | 'warning';
11
11
  message: string;
12
12
  suggestion: string;
13
- }
13
+ };
14
14
  export declare function checkBlacklist(query: string, blacklistedOperations: string[]): BlacklistCheckResult;
15
15
  export declare function requiresConfirmation(query: string, confirmationOperations: string[]): ConfirmationCheckResult;
16
16
  export declare function getQueryType(query: string): string;
@@ -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,8 +165,8 @@ export function requiresConfirmation(query, confirmationOperations) {
25
165
  return { required: false };
26
166
  }
27
167
  export function getQueryType(query) {
28
- const normalizedQuery = query.trim().toUpperCase();
29
- const firstWord = normalizedQuery.split(/\s+/)[0];
168
+ const normalizedQuery = normalize(query);
169
+ const firstWord = normalizedQuery.split(/\s+/, 1)[0];
30
170
  const knownTypes = [
31
171
  'SELECT',
32
172
  'INSERT',
@@ -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.0"
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.0",
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.9.0",
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",
@@ -23,11 +23,11 @@
23
23
  "@types/fs-extra": "^11.0.4",
24
24
  "@types/mocha": "^10",
25
25
  "@types/node": "^26",
26
- "@types/sinon": "^21.0.0",
27
- "c8": "^11.0.0",
26
+ "@types/sinon": "^22.0.0",
27
+ "c8": "^12.0.0",
28
28
  "chai": "^6",
29
- "eslint": "^9",
30
- "eslint-config-oclif": "^6",
29
+ "eslint": "^10",
30
+ "eslint-config-oclif": "^7",
31
31
  "eslint-config-prettier": "^10",
32
32
  "esmock": "^2.7.3",
33
33
  "mocha": "^11",
@@ -37,10 +37,11 @@
37
37
  "sinon": "^22.0.0",
38
38
  "ts-node": "^10",
39
39
  "ts-prune": "^0.10.3",
40
- "typescript": "^5"
40
+ "typescript": "^5",
41
+ "typescript-eslint": "^8.67.0"
41
42
  },
42
43
  "engines": {
43
- "node": ">=18.0.0"
44
+ "node": ">=22.13.0"
44
45
  },
45
46
  "files": [
46
47
  "./bin",
@@ -68,13 +69,17 @@
68
69
  "postpack": "shx rm -f oclif.manifest.json",
69
70
  "posttest": "npm run lint",
70
71
  "prepack": "oclif manifest && oclif readme",
71
- "test": "mocha --forbid-only \"test/**/*.test.ts\"",
72
+ "test": "mocha --forbid-only \"test/**/*.test.ts\" --ignore \"test/e2e/**\"",
72
73
  "test:coverage": "c8 npm test",
73
74
  "test:coverage:report": "c8 --reporter=html --reporter=text npm test",
74
75
  "version": "oclif readme && git add README.md",
75
76
  "find-deadcode": "ts-prune --ignore '(run|default)'",
76
77
  "format": "eslint --cache --fix --quiet . && prettier --write --cache .",
77
- "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\""
78
83
  },
79
84
  "types": "dist/index.d.ts",
80
85
  "c8": {