@berthojoris/mcp-mysql-server 1.16.4 → 1.18.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 +22 -0
- package/DOCUMENTATIONS.md +109 -548
- package/README.md +85 -486
- package/bin/mcp-mysql.js +57 -107
- package/dist/config/featureConfig.d.ts +2 -29
- package/dist/config/featureConfig.js +18 -211
- package/dist/index.d.ts +59 -12
- package/dist/index.js +44 -4
- package/dist/mcp-server.js +155 -9
- package/dist/security/securityLayer.js +2 -3
- package/dist/tools/forecastingTools.d.ts +36 -0
- package/dist/tools/forecastingTools.js +256 -0
- package/dist/tools/queryVisualizationTools.d.ts +22 -0
- package/dist/tools/queryVisualizationTools.js +155 -0
- package/dist/tools/schemaPatternTools.d.ts +19 -0
- package/dist/tools/schemaPatternTools.js +253 -0
- package/dist/tools/testDataTools.d.ts +26 -0
- package/dist/tools/testDataTools.js +325 -0
- package/manifest.json +109 -1
- package/package.json +1 -1
|
@@ -0,0 +1,253 @@
|
|
|
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.SchemaPatternTools = void 0;
|
|
7
|
+
const connection_1 = __importDefault(require("../db/connection"));
|
|
8
|
+
const config_1 = require("../config/config");
|
|
9
|
+
class SchemaPatternTools {
|
|
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 { valid: true, database: connectedDatabase };
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Recognize common schema patterns and anti-patterns based on INFORMATION_SCHEMA.
|
|
34
|
+
*/
|
|
35
|
+
async analyzeSchemaPatterns(params = {}) {
|
|
36
|
+
try {
|
|
37
|
+
const dbValidation = this.validateDatabaseAccess(params?.database);
|
|
38
|
+
if (!dbValidation.valid) {
|
|
39
|
+
return { status: "error", error: dbValidation.error };
|
|
40
|
+
}
|
|
41
|
+
const database = dbValidation.database;
|
|
42
|
+
const scope = params.scope || (params.table_name ? "table" : "database");
|
|
43
|
+
if (scope === "table") {
|
|
44
|
+
if (!params.table_name) {
|
|
45
|
+
return { status: "error", error: "table_name is required for table scope" };
|
|
46
|
+
}
|
|
47
|
+
if (!this.security.validateIdentifier(params.table_name).valid) {
|
|
48
|
+
return { status: "error", error: "Invalid table name" };
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
const tables = await this.db.query(`
|
|
52
|
+
SELECT TABLE_NAME, TABLE_ROWS
|
|
53
|
+
FROM INFORMATION_SCHEMA.TABLES
|
|
54
|
+
WHERE TABLE_SCHEMA = ? AND TABLE_TYPE = 'BASE TABLE'
|
|
55
|
+
${scope === "table" ? "AND TABLE_NAME = ?" : ""}
|
|
56
|
+
ORDER BY TABLE_NAME
|
|
57
|
+
`, scope === "table" ? [database, params.table_name] : [database]);
|
|
58
|
+
if (!tables.length) {
|
|
59
|
+
return {
|
|
60
|
+
status: "success",
|
|
61
|
+
data: {
|
|
62
|
+
database,
|
|
63
|
+
scope,
|
|
64
|
+
tables_analyzed: 0,
|
|
65
|
+
findings: [],
|
|
66
|
+
summary: { high: 0, medium: 0, low: 0 },
|
|
67
|
+
message: "No tables found.",
|
|
68
|
+
},
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
const tableNames = tables.map((t) => t.TABLE_NAME);
|
|
72
|
+
const placeholders = tableNames.map(() => "?").join(",");
|
|
73
|
+
const columns = await this.db.query(`
|
|
74
|
+
SELECT TABLE_NAME, COLUMN_NAME, DATA_TYPE, COLUMN_TYPE, IS_NULLABLE, COLUMN_KEY
|
|
75
|
+
FROM INFORMATION_SCHEMA.COLUMNS
|
|
76
|
+
WHERE TABLE_SCHEMA = ?
|
|
77
|
+
AND TABLE_NAME IN (${placeholders})
|
|
78
|
+
ORDER BY TABLE_NAME, ORDINAL_POSITION
|
|
79
|
+
`, [database, ...tableNames]);
|
|
80
|
+
const stats = await this.db.query(`
|
|
81
|
+
SELECT TABLE_NAME, INDEX_NAME, COLUMN_NAME, SEQ_IN_INDEX
|
|
82
|
+
FROM INFORMATION_SCHEMA.STATISTICS
|
|
83
|
+
WHERE TABLE_SCHEMA = ?
|
|
84
|
+
AND TABLE_NAME IN (${placeholders})
|
|
85
|
+
ORDER BY TABLE_NAME, INDEX_NAME, SEQ_IN_INDEX
|
|
86
|
+
`, [database, ...tableNames]);
|
|
87
|
+
const fks = await this.db.query(`
|
|
88
|
+
SELECT TABLE_NAME, COLUMN_NAME, REFERENCED_TABLE_NAME, REFERENCED_COLUMN_NAME
|
|
89
|
+
FROM INFORMATION_SCHEMA.KEY_COLUMN_USAGE
|
|
90
|
+
WHERE TABLE_SCHEMA = ?
|
|
91
|
+
AND TABLE_NAME IN (${placeholders})
|
|
92
|
+
AND REFERENCED_TABLE_NAME IS NOT NULL
|
|
93
|
+
`, [database, ...tableNames]);
|
|
94
|
+
const columnsByTable = new Map();
|
|
95
|
+
for (const c of columns) {
|
|
96
|
+
if (!columnsByTable.has(c.TABLE_NAME))
|
|
97
|
+
columnsByTable.set(c.TABLE_NAME, []);
|
|
98
|
+
columnsByTable.get(c.TABLE_NAME).push(c);
|
|
99
|
+
}
|
|
100
|
+
const indexesByTable = new Map();
|
|
101
|
+
for (const s of stats) {
|
|
102
|
+
if (!indexesByTable.has(s.TABLE_NAME))
|
|
103
|
+
indexesByTable.set(s.TABLE_NAME, []);
|
|
104
|
+
indexesByTable.get(s.TABLE_NAME).push(s);
|
|
105
|
+
}
|
|
106
|
+
const fksByTable = new Map();
|
|
107
|
+
for (const fk of fks) {
|
|
108
|
+
if (!fksByTable.has(fk.TABLE_NAME))
|
|
109
|
+
fksByTable.set(fk.TABLE_NAME, []);
|
|
110
|
+
fksByTable.get(fk.TABLE_NAME).push(fk);
|
|
111
|
+
}
|
|
112
|
+
const findings = [];
|
|
113
|
+
const isColumnIndexed = (table, column) => {
|
|
114
|
+
const idx = indexesByTable.get(table) || [];
|
|
115
|
+
return idx.some((s) => s.COLUMN_NAME === column);
|
|
116
|
+
};
|
|
117
|
+
for (const t of tables) {
|
|
118
|
+
const tCols = columnsByTable.get(t.TABLE_NAME) || [];
|
|
119
|
+
const tFks = fksByTable.get(t.TABLE_NAME) || [];
|
|
120
|
+
const pkCols = tCols.filter((c) => c.COLUMN_KEY === "PRI");
|
|
121
|
+
if (!pkCols.length) {
|
|
122
|
+
findings.push({
|
|
123
|
+
severity: "high",
|
|
124
|
+
table_name: t.TABLE_NAME,
|
|
125
|
+
pattern: "MISSING_PRIMARY_KEY",
|
|
126
|
+
description: "Table has no PRIMARY KEY.",
|
|
127
|
+
recommendations: [
|
|
128
|
+
"Add a surrogate primary key (e.g., BIGINT AUTO_INCREMENT) or a natural composite key.",
|
|
129
|
+
"Primary keys improve replication, indexing, and ORM compatibility.",
|
|
130
|
+
],
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
if (tCols.length > 40) {
|
|
134
|
+
findings.push({
|
|
135
|
+
severity: "medium",
|
|
136
|
+
table_name: t.TABLE_NAME,
|
|
137
|
+
pattern: "WIDE_TABLE",
|
|
138
|
+
description: `Table has ${tCols.length} columns, which can indicate overloading multiple concerns.`,
|
|
139
|
+
evidence: { column_count: tCols.length },
|
|
140
|
+
recommendations: [
|
|
141
|
+
"Consider vertical partitioning or splitting into related tables.",
|
|
142
|
+
"Move sparse/optional fields into a separate table.",
|
|
143
|
+
],
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
const nullableCount = tCols.filter((c) => c.IS_NULLABLE === "YES").length;
|
|
147
|
+
if (tCols.length > 0 && nullableCount / tCols.length > 0.6) {
|
|
148
|
+
findings.push({
|
|
149
|
+
severity: "medium",
|
|
150
|
+
table_name: t.TABLE_NAME,
|
|
151
|
+
pattern: "HIGH_NULLABILITY",
|
|
152
|
+
description: `More than 60% of columns are NULLable (${nullableCount}/${tCols.length}).`,
|
|
153
|
+
evidence: { nullable_columns: nullableCount, total_columns: tCols.length },
|
|
154
|
+
recommendations: [
|
|
155
|
+
"Review whether columns should be NOT NULL with defaults.",
|
|
156
|
+
"Consider splitting rarely-used columns into another table.",
|
|
157
|
+
],
|
|
158
|
+
});
|
|
159
|
+
}
|
|
160
|
+
// Foreign key columns without any index
|
|
161
|
+
for (const fk of tFks) {
|
|
162
|
+
if (!isColumnIndexed(t.TABLE_NAME, fk.COLUMN_NAME)) {
|
|
163
|
+
findings.push({
|
|
164
|
+
severity: "medium",
|
|
165
|
+
table_name: t.TABLE_NAME,
|
|
166
|
+
pattern: "FK_NOT_INDEXED",
|
|
167
|
+
description: `Foreign key column '${fk.COLUMN_NAME}' is not indexed.`,
|
|
168
|
+
evidence: {
|
|
169
|
+
column: fk.COLUMN_NAME,
|
|
170
|
+
references: `${fk.REFERENCED_TABLE_NAME}.${fk.REFERENCED_COLUMN_NAME}`,
|
|
171
|
+
},
|
|
172
|
+
recommendations: [
|
|
173
|
+
`Add an index on '${fk.COLUMN_NAME}' to improve join and delete/update performance.`,
|
|
174
|
+
],
|
|
175
|
+
});
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
// Heuristic EAV detection: entity_id + attribute_id + value-like
|
|
179
|
+
const colNames = new Set(tCols.map((c) => c.COLUMN_NAME.toLowerCase()));
|
|
180
|
+
const hasEntityId = colNames.has("entity_id") || colNames.has("object_id");
|
|
181
|
+
const hasAttrId = colNames.has("attribute_id") || colNames.has("field_id");
|
|
182
|
+
const hasValue = colNames.has("value") ||
|
|
183
|
+
colNames.has("value_string") ||
|
|
184
|
+
colNames.has("value_int") ||
|
|
185
|
+
colNames.has("value_decimal");
|
|
186
|
+
if (hasEntityId && hasAttrId && hasValue) {
|
|
187
|
+
findings.push({
|
|
188
|
+
severity: "high",
|
|
189
|
+
table_name: t.TABLE_NAME,
|
|
190
|
+
pattern: "EAV_PATTERN",
|
|
191
|
+
description: "Table resembles an Entity-Attribute-Value (EAV) design, which often causes performance and integrity issues.",
|
|
192
|
+
recommendations: [
|
|
193
|
+
"Consider modeling attributes as real columns or separate typed tables.",
|
|
194
|
+
"If EAV is required, ensure strong indexing and constraints on (entity_id, attribute_id).",
|
|
195
|
+
],
|
|
196
|
+
});
|
|
197
|
+
}
|
|
198
|
+
// Soft delete without index
|
|
199
|
+
const softDeleteCol = tCols.find((c) => ["deleted_at", "is_deleted", "deleted"].includes(c.COLUMN_NAME.toLowerCase()));
|
|
200
|
+
if (softDeleteCol && !isColumnIndexed(t.TABLE_NAME, softDeleteCol.COLUMN_NAME)) {
|
|
201
|
+
findings.push({
|
|
202
|
+
severity: "low",
|
|
203
|
+
table_name: t.TABLE_NAME,
|
|
204
|
+
pattern: "SOFT_DELETE_NOT_INDEXED",
|
|
205
|
+
description: `Soft-delete column '${softDeleteCol.COLUMN_NAME}' exists but is not indexed.`,
|
|
206
|
+
recommendations: [
|
|
207
|
+
`If queries frequently filter on '${softDeleteCol.COLUMN_NAME}', add an index (possibly composite with tenant/account keys).`,
|
|
208
|
+
],
|
|
209
|
+
});
|
|
210
|
+
}
|
|
211
|
+
// Potential implicit relationships: *_id columns without FK constraints
|
|
212
|
+
const idCols = tCols
|
|
213
|
+
.filter((c) => c.COLUMN_NAME.toLowerCase().endsWith("_id"))
|
|
214
|
+
.map((c) => c.COLUMN_NAME);
|
|
215
|
+
if (idCols.length > 0 && tFks.length === 0) {
|
|
216
|
+
findings.push({
|
|
217
|
+
severity: "low",
|
|
218
|
+
table_name: t.TABLE_NAME,
|
|
219
|
+
pattern: "IMPLICIT_RELATIONSHIPS",
|
|
220
|
+
description: "Table has *_id columns but no declared foreign keys; this may be intentional, but can reduce integrity and join discoverability.",
|
|
221
|
+
evidence: { id_columns: idCols.slice(0, 10), total_id_columns: idCols.length },
|
|
222
|
+
recommendations: [
|
|
223
|
+
"If appropriate, add foreign keys for critical relationships.",
|
|
224
|
+
"At minimum, ensure *_id columns are indexed.",
|
|
225
|
+
],
|
|
226
|
+
});
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
const summary = {
|
|
230
|
+
high: findings.filter((f) => f.severity === "high").length,
|
|
231
|
+
medium: findings.filter((f) => f.severity === "medium").length,
|
|
232
|
+
low: findings.filter((f) => f.severity === "low").length,
|
|
233
|
+
};
|
|
234
|
+
return {
|
|
235
|
+
status: "success",
|
|
236
|
+
data: {
|
|
237
|
+
database,
|
|
238
|
+
scope,
|
|
239
|
+
tables_analyzed: tables.length,
|
|
240
|
+
findings,
|
|
241
|
+
summary,
|
|
242
|
+
notes: [
|
|
243
|
+
"Heuristic-based analysis; review recommendations before changing production schema.",
|
|
244
|
+
],
|
|
245
|
+
},
|
|
246
|
+
};
|
|
247
|
+
}
|
|
248
|
+
catch (error) {
|
|
249
|
+
return { status: "error", error: error.message };
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
exports.SchemaPatternTools = SchemaPatternTools;
|
|
@@ -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;
|