@berthojoris/mcp-mysql-server 1.16.3 → 1.17.0
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/CHANGELOG.md +27 -0
- package/DOCUMENTATIONS.md +378 -24
- package/README.md +36 -453
- package/dist/config/featureConfig.js +20 -0
- package/dist/index.d.ts +166 -0
- package/dist/index.js +68 -0
- package/dist/mcp-server.js +275 -1
- package/dist/tools/forecastingTools.d.ts +36 -0
- package/dist/tools/forecastingTools.js +256 -0
- package/dist/tools/indexRecommendationTools.d.ts +50 -0
- package/dist/tools/indexRecommendationTools.js +451 -0
- package/dist/tools/queryVisualizationTools.d.ts +22 -0
- package/dist/tools/queryVisualizationTools.js +155 -0
- package/dist/tools/schemaDesignTools.d.ts +67 -0
- package/dist/tools/schemaDesignTools.js +359 -0
- package/dist/tools/schemaPatternTools.d.ts +19 -0
- package/dist/tools/schemaPatternTools.js +253 -0
- package/dist/tools/securityAuditTools.d.ts +39 -0
- package/dist/tools/securityAuditTools.js +319 -0
- package/dist/tools/testDataTools.d.ts +26 -0
- package/dist/tools/testDataTools.js +325 -0
- package/manifest.json +189 -1
- package/package.json +2 -2
|
@@ -0,0 +1,319 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.SecurityAuditTools = void 0;
|
|
7
|
+
const connection_1 = __importDefault(require("../db/connection"));
|
|
8
|
+
const config_1 = require("../config/config");
|
|
9
|
+
class SecurityAuditTools {
|
|
10
|
+
constructor() {
|
|
11
|
+
this.db = connection_1.default.getInstance();
|
|
12
|
+
}
|
|
13
|
+
validateDatabaseAccess(requestedDatabase) {
|
|
14
|
+
const connectedDatabase = config_1.dbConfig.database;
|
|
15
|
+
if (!connectedDatabase) {
|
|
16
|
+
return {
|
|
17
|
+
valid: false,
|
|
18
|
+
database: "",
|
|
19
|
+
error: "No database specified in connection string. Cannot access any database.",
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
if (!requestedDatabase) {
|
|
23
|
+
return { valid: true, database: connectedDatabase };
|
|
24
|
+
}
|
|
25
|
+
if (requestedDatabase !== connectedDatabase) {
|
|
26
|
+
return {
|
|
27
|
+
valid: false,
|
|
28
|
+
database: "",
|
|
29
|
+
error: `Access denied. You can only access the connected database '${connectedDatabase}'. Requested database '${requestedDatabase}' is not allowed.`,
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
return { valid: true, database: connectedDatabase };
|
|
33
|
+
}
|
|
34
|
+
async auditDatabaseSecurity(params) {
|
|
35
|
+
try {
|
|
36
|
+
const dbValidation = this.validateDatabaseAccess(params?.database);
|
|
37
|
+
if (!dbValidation.valid) {
|
|
38
|
+
return { status: "error", error: dbValidation.error };
|
|
39
|
+
}
|
|
40
|
+
const includeUsers = params?.include_user_account_checks ?? true;
|
|
41
|
+
const includePrivileges = params?.include_privilege_checks ?? true;
|
|
42
|
+
const findings = [];
|
|
43
|
+
const notes = [
|
|
44
|
+
"This audit is best-effort and depends on the MySQL account's privileges.",
|
|
45
|
+
"The tool only runs hardcoded read-only inspection queries.",
|
|
46
|
+
];
|
|
47
|
+
// ===== Server variables / configuration checks (generally accessible) =====
|
|
48
|
+
const vars = await this.readVariables([
|
|
49
|
+
"require_secure_transport",
|
|
50
|
+
"have_ssl",
|
|
51
|
+
"tls_version",
|
|
52
|
+
"validate_password.policy",
|
|
53
|
+
"validate_password.length",
|
|
54
|
+
"default_password_lifetime",
|
|
55
|
+
"local_infile",
|
|
56
|
+
"secure_file_priv",
|
|
57
|
+
"skip_name_resolve",
|
|
58
|
+
"bind_address",
|
|
59
|
+
"log_error_verbosity",
|
|
60
|
+
"slow_query_log",
|
|
61
|
+
]);
|
|
62
|
+
const requireSecureTransport = this.asOnOff(vars["require_secure_transport"]);
|
|
63
|
+
const haveSsl = (vars["have_ssl"] || "").toString().toUpperCase();
|
|
64
|
+
if (requireSecureTransport === "OFF") {
|
|
65
|
+
findings.push({
|
|
66
|
+
severity: "high",
|
|
67
|
+
title: "TLS not enforced (require_secure_transport=OFF)",
|
|
68
|
+
evidence: `require_secure_transport=${vars["require_secure_transport"] ?? "<unknown>"}`,
|
|
69
|
+
recommendation: "Set require_secure_transport=ON and require clients to connect over TLS.",
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
else if (requireSecureTransport === "ON") {
|
|
73
|
+
findings.push({
|
|
74
|
+
severity: "info",
|
|
75
|
+
title: "TLS enforced (require_secure_transport=ON)",
|
|
76
|
+
evidence: `require_secure_transport=${vars["require_secure_transport"]}`,
|
|
77
|
+
recommendation: "Keep TLS enforcement enabled.",
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
if (haveSsl && haveSsl !== "YES") {
|
|
81
|
+
findings.push({
|
|
82
|
+
severity: "high",
|
|
83
|
+
title: "Server SSL support not available (have_ssl != YES)",
|
|
84
|
+
evidence: `have_ssl=${vars["have_ssl"]}`,
|
|
85
|
+
recommendation: "Enable SSL/TLS support on the server (configure certificates) and verify client TLS connections.",
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
const localInfile = this.asOnOff(vars["local_infile"]);
|
|
89
|
+
if (localInfile === "ON") {
|
|
90
|
+
findings.push({
|
|
91
|
+
severity: "medium",
|
|
92
|
+
title: "LOCAL INFILE is enabled (local_infile=ON)",
|
|
93
|
+
evidence: `local_infile=${vars["local_infile"]}`,
|
|
94
|
+
recommendation: "Disable local_infile unless required, as it can expand attack surface for data exfiltration.",
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
const secureFilePriv = (vars["secure_file_priv"] ?? "").toString();
|
|
98
|
+
if (secureFilePriv === "") {
|
|
99
|
+
findings.push({
|
|
100
|
+
severity: "medium",
|
|
101
|
+
title: "secure_file_priv is empty (no restriction)",
|
|
102
|
+
evidence: "secure_file_priv=<empty>",
|
|
103
|
+
recommendation: "Set secure_file_priv to a dedicated directory (or NULL) to restrict file import/export paths.",
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
const passwordLifetime = this.asInt(vars["default_password_lifetime"]);
|
|
107
|
+
if (passwordLifetime === 0) {
|
|
108
|
+
findings.push({
|
|
109
|
+
severity: "low",
|
|
110
|
+
title: "Password expiration disabled (default_password_lifetime=0)",
|
|
111
|
+
evidence: `default_password_lifetime=${vars["default_password_lifetime"]}`,
|
|
112
|
+
recommendation: "Consider setting a password expiration policy aligned with your org's security requirements.",
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
const vPolicy = vars["validate_password.policy"];
|
|
116
|
+
const vLen = this.asInt(vars["validate_password.length"]);
|
|
117
|
+
if (vPolicy === undefined && vLen === undefined) {
|
|
118
|
+
findings.push({
|
|
119
|
+
severity: "low",
|
|
120
|
+
title: "Password validation plugin settings not detected",
|
|
121
|
+
evidence: "validate_password.* variables not present",
|
|
122
|
+
recommendation: "Consider enabling validate_password component/plugin (MySQL version dependent) for stronger password policies.",
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
else {
|
|
126
|
+
if (vLen !== undefined && vLen < 12) {
|
|
127
|
+
findings.push({
|
|
128
|
+
severity: "medium",
|
|
129
|
+
title: "Password minimum length may be weak",
|
|
130
|
+
evidence: `validate_password.length=${vars["validate_password.length"]}`,
|
|
131
|
+
recommendation: "Increase password length (e.g. 12+).",
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
// ===== User/account checks (requires mysql.user privileges) =====
|
|
136
|
+
if (includeUsers) {
|
|
137
|
+
const userCheck = await this.tryReadUserAccounts();
|
|
138
|
+
if (userCheck.status === "skipped") {
|
|
139
|
+
notes.push(userCheck.note);
|
|
140
|
+
}
|
|
141
|
+
else if (userCheck.status === "ok") {
|
|
142
|
+
findings.push(...userCheck.findings);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
// ===== Privilege checks (information_schema schema/table privileges) =====
|
|
146
|
+
if (includePrivileges) {
|
|
147
|
+
const privCheck = await this.tryReadPrivilegeSummaries();
|
|
148
|
+
if (privCheck.status === "skipped") {
|
|
149
|
+
notes.push(privCheck.note);
|
|
150
|
+
}
|
|
151
|
+
else if (privCheck.status === "ok") {
|
|
152
|
+
findings.push(...privCheck.findings);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
const summary = this.summarizeFindings(findings);
|
|
156
|
+
return {
|
|
157
|
+
status: "success",
|
|
158
|
+
data: {
|
|
159
|
+
database: dbValidation.database,
|
|
160
|
+
findings: findings.sort((a, b) => this.severityRank(b.severity) - this.severityRank(a.severity)),
|
|
161
|
+
summary,
|
|
162
|
+
notes,
|
|
163
|
+
},
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
catch (error) {
|
|
167
|
+
return { status: "error", error: error.message };
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
severityRank(s) {
|
|
171
|
+
switch (s) {
|
|
172
|
+
case "critical":
|
|
173
|
+
return 5;
|
|
174
|
+
case "high":
|
|
175
|
+
return 4;
|
|
176
|
+
case "medium":
|
|
177
|
+
return 3;
|
|
178
|
+
case "low":
|
|
179
|
+
return 2;
|
|
180
|
+
case "info":
|
|
181
|
+
default:
|
|
182
|
+
return 1;
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
summarizeFindings(findings) {
|
|
186
|
+
const summary = { critical: 0, high: 0, medium: 0, low: 0, info: 0 };
|
|
187
|
+
for (const f of findings) {
|
|
188
|
+
summary[f.severity]++;
|
|
189
|
+
}
|
|
190
|
+
return summary;
|
|
191
|
+
}
|
|
192
|
+
asOnOff(value) {
|
|
193
|
+
if (value === undefined || value === null)
|
|
194
|
+
return "UNKNOWN";
|
|
195
|
+
const v = value.toString().trim().toUpperCase();
|
|
196
|
+
if (v === "ON" || v === "1" || v === "YES")
|
|
197
|
+
return "ON";
|
|
198
|
+
if (v === "OFF" || v === "0" || v === "NO")
|
|
199
|
+
return "OFF";
|
|
200
|
+
return "UNKNOWN";
|
|
201
|
+
}
|
|
202
|
+
asInt(value) {
|
|
203
|
+
if (value === undefined || value === null)
|
|
204
|
+
return undefined;
|
|
205
|
+
const n = parseInt(value.toString(), 10);
|
|
206
|
+
return Number.isFinite(n) ? n : undefined;
|
|
207
|
+
}
|
|
208
|
+
async readVariables(keys) {
|
|
209
|
+
const results = {};
|
|
210
|
+
const query = "SHOW VARIABLES WHERE Variable_name IN (" + keys.map(() => "?").join(",") + ")";
|
|
211
|
+
const rows = await this.db.query(query, keys);
|
|
212
|
+
for (const row of rows) {
|
|
213
|
+
results[row.Variable_name] = row.Value;
|
|
214
|
+
}
|
|
215
|
+
return results;
|
|
216
|
+
}
|
|
217
|
+
async tryReadUserAccounts() {
|
|
218
|
+
try {
|
|
219
|
+
// We intentionally do NOT return authentication_string or password hashes.
|
|
220
|
+
const rows = await this.db.query(`
|
|
221
|
+
SELECT
|
|
222
|
+
User as user,
|
|
223
|
+
Host as host,
|
|
224
|
+
account_locked,
|
|
225
|
+
password_expired,
|
|
226
|
+
plugin
|
|
227
|
+
FROM mysql.user
|
|
228
|
+
WHERE User NOT IN ('mysql.sys', 'mysql.session', 'mysql.infoschema')
|
|
229
|
+
`);
|
|
230
|
+
const findings = [];
|
|
231
|
+
const anonymous = rows.filter((r) => !r.user);
|
|
232
|
+
if (anonymous.length > 0) {
|
|
233
|
+
findings.push({
|
|
234
|
+
severity: "critical",
|
|
235
|
+
title: "Anonymous MySQL user accounts detected",
|
|
236
|
+
evidence: `Found ${anonymous.length} anonymous account(s) in mysql.user`,
|
|
237
|
+
recommendation: "Remove anonymous users or lock them; restrict Host to localhost if required.",
|
|
238
|
+
});
|
|
239
|
+
}
|
|
240
|
+
const rootRemote = rows.filter((r) => (r.user || "").toLowerCase() === "root" && (r.host || "") !== "localhost");
|
|
241
|
+
if (rootRemote.length > 0) {
|
|
242
|
+
findings.push({
|
|
243
|
+
severity: "high",
|
|
244
|
+
title: "Remote root accounts detected",
|
|
245
|
+
evidence: `root accounts with non-localhost Host found: ${rootRemote
|
|
246
|
+
.slice(0, 5)
|
|
247
|
+
.map((r) => `${r.user}@${r.host}`)
|
|
248
|
+
.join(", ")}${rootRemote.length > 5 ? ", ..." : ""}`,
|
|
249
|
+
recommendation: "Restrict root access to localhost or a tightly controlled admin network; use named admin accounts instead.",
|
|
250
|
+
});
|
|
251
|
+
}
|
|
252
|
+
const wildcardHosts = rows.filter((r) => (r.host || "") === "%");
|
|
253
|
+
if (wildcardHosts.length > 0) {
|
|
254
|
+
findings.push({
|
|
255
|
+
severity: "medium",
|
|
256
|
+
title: "User accounts with wildcard host (%) detected",
|
|
257
|
+
evidence: `Accounts with Host='%': ${wildcardHosts
|
|
258
|
+
.slice(0, 8)
|
|
259
|
+
.map((r) => `${r.user}@${r.host}`)
|
|
260
|
+
.join(", ")}${wildcardHosts.length > 8 ? ", ..." : ""}`,
|
|
261
|
+
recommendation: "Prefer explicit Host values (specific IPs/subnets) instead of '%' where possible.",
|
|
262
|
+
});
|
|
263
|
+
}
|
|
264
|
+
const unlocked = rows.filter((r) => ("" + r.account_locked).toUpperCase() === "N");
|
|
265
|
+
if (unlocked.length > 0) {
|
|
266
|
+
findings.push({
|
|
267
|
+
severity: "info",
|
|
268
|
+
title: "Unlocked user accounts present",
|
|
269
|
+
evidence: `Unlocked accounts count: ${unlocked.length}`,
|
|
270
|
+
recommendation: "Ensure unused accounts are locked or removed, and use strong authentication plugins.",
|
|
271
|
+
});
|
|
272
|
+
}
|
|
273
|
+
return { status: "ok", findings };
|
|
274
|
+
}
|
|
275
|
+
catch (e) {
|
|
276
|
+
return {
|
|
277
|
+
status: "skipped",
|
|
278
|
+
note: `User/account checks skipped: insufficient privileges to read mysql.user (${e.message}).`,
|
|
279
|
+
};
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
async tryReadPrivilegeSummaries() {
|
|
283
|
+
try {
|
|
284
|
+
// These INFORMATION_SCHEMA views are generally less sensitive than mysql.user.
|
|
285
|
+
const schemaPriv = await this.db.query(`
|
|
286
|
+
SELECT GRANTEE as grantee, TABLE_SCHEMA as schema_name, PRIVILEGE_TYPE as privilege_type
|
|
287
|
+
FROM INFORMATION_SCHEMA.SCHEMA_PRIVILEGES
|
|
288
|
+
WHERE TABLE_SCHEMA NOT IN ('mysql','performance_schema','information_schema','sys')
|
|
289
|
+
`);
|
|
290
|
+
const findings = [];
|
|
291
|
+
const anyAll = schemaPriv.filter((r) => (r.privilege_type || "").toUpperCase() === "ALL PRIVILEGES");
|
|
292
|
+
if (anyAll.length > 0) {
|
|
293
|
+
findings.push({
|
|
294
|
+
severity: "medium",
|
|
295
|
+
title: "Broad schema privileges detected (ALL PRIVILEGES)",
|
|
296
|
+
evidence: `Found ${anyAll.length} schema privilege rows with ALL PRIVILEGES`,
|
|
297
|
+
recommendation: "Review and apply least privilege for schema-level grants; prefer role-based access.",
|
|
298
|
+
});
|
|
299
|
+
}
|
|
300
|
+
const grantOption = schemaPriv.filter((r) => (r.privilege_type || "").toUpperCase() === "GRANT OPTION");
|
|
301
|
+
if (grantOption.length > 0) {
|
|
302
|
+
findings.push({
|
|
303
|
+
severity: "high",
|
|
304
|
+
title: "GRANT OPTION detected in schema privileges",
|
|
305
|
+
evidence: `Found ${grantOption.length} schema privilege rows with GRANT OPTION`,
|
|
306
|
+
recommendation: "Minimize GRANT OPTION; it can allow privilege escalation if misassigned.",
|
|
307
|
+
});
|
|
308
|
+
}
|
|
309
|
+
return { status: "ok", findings };
|
|
310
|
+
}
|
|
311
|
+
catch (e) {
|
|
312
|
+
return {
|
|
313
|
+
status: "skipped",
|
|
314
|
+
note: `Privilege checks skipped: unable to read INFORMATION_SCHEMA privilege views (${e.message}).`,
|
|
315
|
+
};
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
exports.SecurityAuditTools = SecurityAuditTools;
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { SecurityLayer } from "../security/securityLayer";
|
|
2
|
+
export declare class TestDataTools {
|
|
3
|
+
private db;
|
|
4
|
+
private security;
|
|
5
|
+
constructor(security: SecurityLayer);
|
|
6
|
+
private validateDatabaseAccess;
|
|
7
|
+
private escapeValue;
|
|
8
|
+
private parseEnumValues;
|
|
9
|
+
private clampString;
|
|
10
|
+
private generateValueForColumn;
|
|
11
|
+
/**
|
|
12
|
+
* Generate SQL INSERT statements (does not execute) for synthetic test data.
|
|
13
|
+
* Attempts to maintain referential integrity by sampling referenced keys when foreign keys exist.
|
|
14
|
+
*/
|
|
15
|
+
generateTestData(params: {
|
|
16
|
+
table_name: string;
|
|
17
|
+
row_count: number;
|
|
18
|
+
batch_size?: number;
|
|
19
|
+
include_nulls?: boolean;
|
|
20
|
+
database?: string;
|
|
21
|
+
}): Promise<{
|
|
22
|
+
status: string;
|
|
23
|
+
data?: any;
|
|
24
|
+
error?: string;
|
|
25
|
+
}>;
|
|
26
|
+
}
|
|
@@ -0,0 +1,325 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.TestDataTools = void 0;
|
|
7
|
+
const connection_1 = __importDefault(require("../db/connection"));
|
|
8
|
+
const config_1 = require("../config/config");
|
|
9
|
+
class TestDataTools {
|
|
10
|
+
constructor(security) {
|
|
11
|
+
this.db = connection_1.default.getInstance();
|
|
12
|
+
this.security = security;
|
|
13
|
+
}
|
|
14
|
+
validateDatabaseAccess(requestedDatabase) {
|
|
15
|
+
const connectedDatabase = config_1.dbConfig.database;
|
|
16
|
+
if (!connectedDatabase) {
|
|
17
|
+
return {
|
|
18
|
+
valid: false,
|
|
19
|
+
database: "",
|
|
20
|
+
error: "No database configured. Please specify a database in your connection settings.",
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
if (requestedDatabase && requestedDatabase !== connectedDatabase) {
|
|
24
|
+
return {
|
|
25
|
+
valid: false,
|
|
26
|
+
database: "",
|
|
27
|
+
error: `Access denied: You are connected to '${connectedDatabase}' but requested '${requestedDatabase}'. Cross-database access is not permitted.`,
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
return {
|
|
31
|
+
valid: true,
|
|
32
|
+
database: connectedDatabase,
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
escapeValue(value) {
|
|
36
|
+
if (value === null || value === undefined)
|
|
37
|
+
return "NULL";
|
|
38
|
+
if (typeof value === "number")
|
|
39
|
+
return String(value);
|
|
40
|
+
if (typeof value === "boolean")
|
|
41
|
+
return value ? "1" : "0";
|
|
42
|
+
if (value instanceof Date) {
|
|
43
|
+
return `'${value.toISOString().slice(0, 19).replace("T", " ")}'`;
|
|
44
|
+
}
|
|
45
|
+
if (Buffer.isBuffer(value)) {
|
|
46
|
+
return `X'${value.toString("hex")}'`;
|
|
47
|
+
}
|
|
48
|
+
const escaped = String(value)
|
|
49
|
+
.replace(/\\/g, "\\\\")
|
|
50
|
+
.replace(/'/g, "\\'")
|
|
51
|
+
.replace(/"/g, '\\"')
|
|
52
|
+
.replace(/\n/g, "\\n")
|
|
53
|
+
.replace(/\r/g, "\\r")
|
|
54
|
+
.replace(/\t/g, "\\t")
|
|
55
|
+
.replace(/\0/g, "\\0");
|
|
56
|
+
return `'${escaped}'`;
|
|
57
|
+
}
|
|
58
|
+
parseEnumValues(columnType) {
|
|
59
|
+
const m = columnType.match(/^enum\((.*)\)$/i);
|
|
60
|
+
if (!m)
|
|
61
|
+
return [];
|
|
62
|
+
// values are single-quoted and may contain escaped quotes
|
|
63
|
+
const inner = m[1];
|
|
64
|
+
const values = [];
|
|
65
|
+
let current = "";
|
|
66
|
+
let inQuote = false;
|
|
67
|
+
for (let i = 0; i < inner.length; i++) {
|
|
68
|
+
const ch = inner[i];
|
|
69
|
+
const prev = inner[i - 1];
|
|
70
|
+
if (ch === "'" && prev !== "\\") {
|
|
71
|
+
inQuote = !inQuote;
|
|
72
|
+
if (!inQuote) {
|
|
73
|
+
values.push(current);
|
|
74
|
+
current = "";
|
|
75
|
+
}
|
|
76
|
+
continue;
|
|
77
|
+
}
|
|
78
|
+
if (inQuote)
|
|
79
|
+
current += ch;
|
|
80
|
+
}
|
|
81
|
+
return values;
|
|
82
|
+
}
|
|
83
|
+
clampString(value, maxLen) {
|
|
84
|
+
if (!maxLen || maxLen <= 0)
|
|
85
|
+
return value;
|
|
86
|
+
if (value.length <= maxLen)
|
|
87
|
+
return value;
|
|
88
|
+
return value.slice(0, Math.max(1, maxLen));
|
|
89
|
+
}
|
|
90
|
+
generateValueForColumn(col, rowIndex, fkSamples) {
|
|
91
|
+
const dataType = (col.DATA_TYPE || "").toLowerCase();
|
|
92
|
+
const colNameLower = col.COLUMN_NAME.toLowerCase();
|
|
93
|
+
// If FK sample values exist, use them
|
|
94
|
+
if (fkSamples && fkSamples.length > 0) {
|
|
95
|
+
const sample = fkSamples[rowIndex % fkSamples.length];
|
|
96
|
+
if (sample !== undefined && sample !== null)
|
|
97
|
+
return sample;
|
|
98
|
+
}
|
|
99
|
+
// Handle enums
|
|
100
|
+
if (dataType === "enum") {
|
|
101
|
+
const values = this.parseEnumValues(col.COLUMN_TYPE);
|
|
102
|
+
if (values.length > 0)
|
|
103
|
+
return values[rowIndex % values.length];
|
|
104
|
+
return col.IS_NULLABLE === "YES" ? null : "";
|
|
105
|
+
}
|
|
106
|
+
// Numeric types
|
|
107
|
+
if ([
|
|
108
|
+
"int",
|
|
109
|
+
"tinyint",
|
|
110
|
+
"smallint",
|
|
111
|
+
"mediumint",
|
|
112
|
+
"bigint",
|
|
113
|
+
].includes(dataType)) {
|
|
114
|
+
if (colNameLower.includes("is_") || colNameLower.startsWith("is")) {
|
|
115
|
+
return rowIndex % 2;
|
|
116
|
+
}
|
|
117
|
+
if (colNameLower.endsWith("_id")) {
|
|
118
|
+
return rowIndex + 1;
|
|
119
|
+
}
|
|
120
|
+
return rowIndex + 1;
|
|
121
|
+
}
|
|
122
|
+
if (["decimal", "float", "double"].includes(dataType)) {
|
|
123
|
+
return parseFloat(((rowIndex + 1) * 1.11).toFixed(2));
|
|
124
|
+
}
|
|
125
|
+
// Date/time types
|
|
126
|
+
if (["date", "datetime", "timestamp"].includes(dataType)) {
|
|
127
|
+
const now = Date.now();
|
|
128
|
+
const daysAgo = rowIndex % 30;
|
|
129
|
+
const dt = new Date(now - daysAgo * 24 * 60 * 60 * 1000);
|
|
130
|
+
if (dataType === "date")
|
|
131
|
+
return dt.toISOString().slice(0, 10);
|
|
132
|
+
return dt;
|
|
133
|
+
}
|
|
134
|
+
// JSON
|
|
135
|
+
if (dataType === "json") {
|
|
136
|
+
return JSON.stringify({ seed: rowIndex + 1 });
|
|
137
|
+
}
|
|
138
|
+
// Binary
|
|
139
|
+
if (["blob", "tinyblob", "mediumblob", "longblob"].includes(dataType)) {
|
|
140
|
+
return Buffer.from([rowIndex % 256]);
|
|
141
|
+
}
|
|
142
|
+
// Text/string types
|
|
143
|
+
if (["varchar", "char", "text", "tinytext", "mediumtext", "longtext"].includes(dataType)) {
|
|
144
|
+
let v = "";
|
|
145
|
+
if (colNameLower.includes("email"))
|
|
146
|
+
v = `user${rowIndex + 1}@example.com`;
|
|
147
|
+
else if (colNameLower.includes("phone"))
|
|
148
|
+
v = `+1555000${String(rowIndex + 1).padStart(4, "0")}`;
|
|
149
|
+
else if (colNameLower.includes("url"))
|
|
150
|
+
v = `https://example.com/item/${rowIndex + 1}`;
|
|
151
|
+
else if (colNameLower.includes("name"))
|
|
152
|
+
v = `Sample ${col.COLUMN_NAME} ${rowIndex + 1}`;
|
|
153
|
+
else if (colNameLower.includes("title"))
|
|
154
|
+
v = `Title ${rowIndex + 1}`;
|
|
155
|
+
else if (colNameLower.includes("description"))
|
|
156
|
+
v = `Description for row ${rowIndex + 1}`;
|
|
157
|
+
else
|
|
158
|
+
v = `${col.COLUMN_NAME}_${rowIndex + 1}`;
|
|
159
|
+
return this.clampString(v, col.CHARACTER_MAXIMUM_LENGTH);
|
|
160
|
+
}
|
|
161
|
+
// Fallback: string
|
|
162
|
+
const fallback = `${col.COLUMN_NAME}_${rowIndex + 1}`;
|
|
163
|
+
return this.clampString(fallback, col.CHARACTER_MAXIMUM_LENGTH);
|
|
164
|
+
}
|
|
165
|
+
/**
|
|
166
|
+
* Generate SQL INSERT statements (does not execute) for synthetic test data.
|
|
167
|
+
* Attempts to maintain referential integrity by sampling referenced keys when foreign keys exist.
|
|
168
|
+
*/
|
|
169
|
+
async generateTestData(params) {
|
|
170
|
+
try {
|
|
171
|
+
const dbValidation = this.validateDatabaseAccess(params?.database);
|
|
172
|
+
if (!dbValidation.valid) {
|
|
173
|
+
return { status: "error", error: dbValidation.error };
|
|
174
|
+
}
|
|
175
|
+
const database = dbValidation.database;
|
|
176
|
+
const { table_name, row_count } = params;
|
|
177
|
+
const batchSize = Math.min(Math.max(params.batch_size ?? 100, 1), 1000);
|
|
178
|
+
const includeNulls = params.include_nulls ?? true;
|
|
179
|
+
if (!this.security.validateIdentifier(table_name).valid) {
|
|
180
|
+
return { status: "error", error: "Invalid table name" };
|
|
181
|
+
}
|
|
182
|
+
if (!Number.isFinite(row_count) || row_count <= 0) {
|
|
183
|
+
return { status: "error", error: "row_count must be a positive number" };
|
|
184
|
+
}
|
|
185
|
+
if (row_count > 5000) {
|
|
186
|
+
return {
|
|
187
|
+
status: "error",
|
|
188
|
+
error: "row_count too large (max 5000) to avoid oversized responses",
|
|
189
|
+
};
|
|
190
|
+
}
|
|
191
|
+
// Read column metadata
|
|
192
|
+
const columns = await this.db.query(`
|
|
193
|
+
SELECT
|
|
194
|
+
COLUMN_NAME,
|
|
195
|
+
DATA_TYPE,
|
|
196
|
+
COLUMN_TYPE,
|
|
197
|
+
IS_NULLABLE,
|
|
198
|
+
COLUMN_DEFAULT,
|
|
199
|
+
EXTRA,
|
|
200
|
+
COLUMN_KEY,
|
|
201
|
+
CHARACTER_MAXIMUM_LENGTH
|
|
202
|
+
FROM INFORMATION_SCHEMA.COLUMNS
|
|
203
|
+
WHERE TABLE_SCHEMA = ? AND TABLE_NAME = ?
|
|
204
|
+
ORDER BY ORDINAL_POSITION
|
|
205
|
+
`, [database, table_name]);
|
|
206
|
+
if (!columns.length) {
|
|
207
|
+
return {
|
|
208
|
+
status: "error",
|
|
209
|
+
error: `Table '${table_name}' not found or has no columns`,
|
|
210
|
+
};
|
|
211
|
+
}
|
|
212
|
+
// Foreign keys for the table
|
|
213
|
+
const fks = await this.db.query(`
|
|
214
|
+
SELECT
|
|
215
|
+
COLUMN_NAME,
|
|
216
|
+
REFERENCED_TABLE_NAME,
|
|
217
|
+
REFERENCED_COLUMN_NAME
|
|
218
|
+
FROM INFORMATION_SCHEMA.KEY_COLUMN_USAGE
|
|
219
|
+
WHERE TABLE_SCHEMA = ?
|
|
220
|
+
AND TABLE_NAME = ?
|
|
221
|
+
AND REFERENCED_TABLE_NAME IS NOT NULL
|
|
222
|
+
`, [database, table_name]);
|
|
223
|
+
const fkSamplesByColumn = new Map();
|
|
224
|
+
const warnings = [];
|
|
225
|
+
for (const fk of fks) {
|
|
226
|
+
const refTable = fk.REFERENCED_TABLE_NAME;
|
|
227
|
+
const refCol = fk.REFERENCED_COLUMN_NAME;
|
|
228
|
+
if (!this.security.validateIdentifier(refTable).valid)
|
|
229
|
+
continue;
|
|
230
|
+
if (!this.security.validateIdentifier(refCol).valid)
|
|
231
|
+
continue;
|
|
232
|
+
const escapedRefTable = this.security.escapeIdentifier(refTable);
|
|
233
|
+
const escapedRefCol = this.security.escapeIdentifier(refCol);
|
|
234
|
+
const sampleRows = await this.db.query(`SELECT ${escapedRefCol} as v FROM ${escapedRefTable} WHERE ${escapedRefCol} IS NOT NULL LIMIT 200`);
|
|
235
|
+
const sampleValues = sampleRows.map((r) => r.v).filter((v) => v !== null);
|
|
236
|
+
if (sampleValues.length === 0) {
|
|
237
|
+
warnings.push(`Foreign key '${table_name}.${fk.COLUMN_NAME}' references '${refTable}.${refCol}' but no referenced key samples were found (referenced table may be empty).`);
|
|
238
|
+
}
|
|
239
|
+
fkSamplesByColumn.set(fk.COLUMN_NAME, sampleValues);
|
|
240
|
+
}
|
|
241
|
+
// Determine insertable columns (skip auto-increment and generated columns)
|
|
242
|
+
const insertColumns = columns.filter((c) => {
|
|
243
|
+
const extra = (c.EXTRA || "").toLowerCase();
|
|
244
|
+
if (extra.includes("auto_increment"))
|
|
245
|
+
return false;
|
|
246
|
+
if (extra.includes("generated"))
|
|
247
|
+
return false;
|
|
248
|
+
return true;
|
|
249
|
+
});
|
|
250
|
+
if (!insertColumns.length) {
|
|
251
|
+
return {
|
|
252
|
+
status: "error",
|
|
253
|
+
error: `No insertable columns found for '${table_name}' (all columns are auto-increment/generated?)`,
|
|
254
|
+
};
|
|
255
|
+
}
|
|
256
|
+
const escapedDb = this.security.escapeIdentifier(database);
|
|
257
|
+
const escapedTable = this.security.escapeIdentifier(table_name);
|
|
258
|
+
const escapedCols = insertColumns.map((c) => this.security.escapeIdentifier(c.COLUMN_NAME));
|
|
259
|
+
const previewRows = [];
|
|
260
|
+
const statements = [];
|
|
261
|
+
for (let start = 0; start < row_count; start += batchSize) {
|
|
262
|
+
const end = Math.min(start + batchSize, row_count);
|
|
263
|
+
const valuesSql = [];
|
|
264
|
+
for (let i = start; i < end; i++) {
|
|
265
|
+
const rowObj = {};
|
|
266
|
+
const rowVals = [];
|
|
267
|
+
for (const col of insertColumns) {
|
|
268
|
+
const fkSamples = fkSamplesByColumn.get(col.COLUMN_NAME);
|
|
269
|
+
let v = this.generateValueForColumn(col, i, fkSamples);
|
|
270
|
+
// Null handling
|
|
271
|
+
if (!includeNulls && col.IS_NULLABLE === "YES") {
|
|
272
|
+
// avoid NULLs unless needed
|
|
273
|
+
if (v === null)
|
|
274
|
+
v = this.generateValueForColumn(col, i, undefined);
|
|
275
|
+
}
|
|
276
|
+
if ((v === null || v === undefined) && col.IS_NULLABLE === "NO") {
|
|
277
|
+
if (col.COLUMN_DEFAULT !== null && col.COLUMN_DEFAULT !== undefined) {
|
|
278
|
+
// Let DB default apply by omitting value where possible.
|
|
279
|
+
// We can't omit per-column in multi-row insert safely, so materialize a value.
|
|
280
|
+
v = col.COLUMN_DEFAULT;
|
|
281
|
+
}
|
|
282
|
+
else {
|
|
283
|
+
// Last resort: generate non-null fallback
|
|
284
|
+
v = this.generateValueForColumn({ ...col, IS_NULLABLE: "YES" }, i);
|
|
285
|
+
if (v === null || v === undefined) {
|
|
286
|
+
return {
|
|
287
|
+
status: "error",
|
|
288
|
+
error: `Cannot generate non-null value for NOT NULL column '${col.COLUMN_NAME}'`,
|
|
289
|
+
};
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
rowObj[col.COLUMN_NAME] = v;
|
|
294
|
+
rowVals.push(this.escapeValue(v));
|
|
295
|
+
}
|
|
296
|
+
if (previewRows.length < 20)
|
|
297
|
+
previewRows.push(rowObj);
|
|
298
|
+
valuesSql.push(`(${rowVals.join(", ")})`);
|
|
299
|
+
}
|
|
300
|
+
const stmt = `INSERT INTO ${escapedDb}.${escapedTable} (${escapedCols.join(", ")}) VALUES\n${valuesSql.join(",\n")};`;
|
|
301
|
+
statements.push(stmt);
|
|
302
|
+
}
|
|
303
|
+
return {
|
|
304
|
+
status: "success",
|
|
305
|
+
data: {
|
|
306
|
+
database,
|
|
307
|
+
table_name,
|
|
308
|
+
row_count,
|
|
309
|
+
batch_size: batchSize,
|
|
310
|
+
insert_sql: statements.join("\n\n"),
|
|
311
|
+
preview_rows: previewRows,
|
|
312
|
+
warnings,
|
|
313
|
+
notes: [
|
|
314
|
+
"This tool only generates SQL (does not execute).",
|
|
315
|
+
"Foreign key columns will use sampled referenced keys when available.",
|
|
316
|
+
],
|
|
317
|
+
},
|
|
318
|
+
};
|
|
319
|
+
}
|
|
320
|
+
catch (error) {
|
|
321
|
+
return { status: "error", error: error.message };
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
exports.TestDataTools = TestDataTools;
|