@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 +13 -13
- package/dist/commands/mysql/describe-table.d.ts +1 -1
- package/dist/commands/mysql/explain.d.ts +1 -1
- package/dist/commands/mysql/indexes.d.ts +1 -1
- package/dist/commands/mysql/query.d.ts +1 -1
- package/dist/mysql/config-loader.d.ts +6 -6
- package/dist/mysql/database.d.ts +19 -19
- package/dist/mysql/formatters.js +1 -0
- package/dist/mysql/mysql-client.js +5 -4
- package/dist/mysql/mysql-utils.d.ts +4 -4
- package/dist/mysql/mysql-utils.js +30 -16
- package/dist/mysql/query-validator.d.ts +6 -6
- package/dist/mysql/query-validator.js +163 -15
- package/oclif.manifest.json +1 -1
- package/package.json +15 -10
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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,5 +1,5 @@
|
|
|
1
1
|
import type { ConnectionOptions as MySQL2ConnectionOptions } from 'mysql2/promise';
|
|
2
|
-
export
|
|
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
|
-
|
|
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
|
|
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 {};
|
package/dist/mysql/database.d.ts
CHANGED
|
@@ -1,36 +1,36 @@
|
|
|
1
1
|
import type { ApiResult } from '@hesed/plugin-lib';
|
|
2
2
|
export type OutputFormat = 'csv' | 'json' | 'table' | 'toon';
|
|
3
|
-
export
|
|
3
|
+
export type QueryData = {
|
|
4
4
|
message?: string;
|
|
5
5
|
notices?: string;
|
|
6
6
|
requiresConfirmation?: boolean;
|
|
7
7
|
result?: unknown;
|
|
8
|
-
}
|
|
9
|
-
export
|
|
8
|
+
};
|
|
9
|
+
export type DatabaseListData = {
|
|
10
10
|
databases: string[];
|
|
11
11
|
result?: string;
|
|
12
|
-
}
|
|
13
|
-
export
|
|
12
|
+
};
|
|
13
|
+
export type TableListData = {
|
|
14
14
|
result?: string;
|
|
15
15
|
tables: string[];
|
|
16
|
-
}
|
|
17
|
-
export
|
|
16
|
+
};
|
|
17
|
+
export type TableStructureData = {
|
|
18
18
|
result?: string;
|
|
19
|
-
structure: Record<string, unknown
|
|
20
|
-
}
|
|
21
|
-
export
|
|
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
|
|
26
|
-
plan: Record<string, unknown
|
|
24
|
+
};
|
|
25
|
+
export type ExplainData = {
|
|
26
|
+
plan: Array<Record<string, unknown>>;
|
|
27
27
|
result?: string;
|
|
28
|
-
}
|
|
29
|
-
|
|
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
|
|
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 {};
|
package/dist/mysql/formatters.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
|
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
|
|
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:
|
|
99
|
-
result:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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
|
|
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
|
|
329
|
+
return await this.getPool(profileName).query(sql);
|
|
316
330
|
}
|
|
317
331
|
finally {
|
|
318
332
|
release();
|
|
@@ -1,16 +1,16 @@
|
|
|
1
|
-
|
|
1
|
+
type BlacklistCheckResult = {
|
|
2
2
|
allowed: boolean;
|
|
3
3
|
reason?: string;
|
|
4
|
-
}
|
|
5
|
-
|
|
4
|
+
};
|
|
5
|
+
type ConfirmationCheckResult = {
|
|
6
6
|
message?: string;
|
|
7
7
|
required: boolean;
|
|
8
|
-
}
|
|
9
|
-
|
|
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
|
|
144
|
+
const normalizedQuery = normalize(query);
|
|
3
145
|
for (const operation of blacklistedOperations) {
|
|
4
|
-
|
|
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
|
|
156
|
+
const normalizedQuery = normalize(query);
|
|
16
157
|
for (const operation of confirmationOperations) {
|
|
17
|
-
|
|
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
|
|
29
|
-
const firstWord = normalizedQuery.split(/\s
|
|
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
|
|
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
|
|
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
|
|
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
|
|
80
|
-
if (normalizedQuery.startsWith('SELECT')
|
|
81
|
-
return
|
|
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
|
-
|
|
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
|
}
|
package/oclif.manifest.json
CHANGED
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.
|
|
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.
|
|
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": "^
|
|
27
|
-
"c8": "^
|
|
26
|
+
"@types/sinon": "^22.0.0",
|
|
27
|
+
"c8": "^12.0.0",
|
|
28
28
|
"chai": "^6",
|
|
29
|
-
"eslint": "^
|
|
30
|
-
"eslint-config-oclif": "^
|
|
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": ">=
|
|
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": {
|