postgres-scout-mcp 1.0.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/LICENSE +190 -0
- package/README.md +234 -0
- package/bin/cli.js +67 -0
- package/dist/config/environment.js +52 -0
- package/dist/index.js +59 -0
- package/dist/server/setup.js +122 -0
- package/dist/tools/data-quality.js +442 -0
- package/dist/tools/database.js +148 -0
- package/dist/tools/export.js +223 -0
- package/dist/tools/index.js +52 -0
- package/dist/tools/live-monitoring.js +369 -0
- package/dist/tools/maintenance.js +617 -0
- package/dist/tools/monitoring.js +286 -0
- package/dist/tools/mutations.js +410 -0
- package/dist/tools/optimization.js +1094 -0
- package/dist/tools/query.js +138 -0
- package/dist/tools/relationships.js +261 -0
- package/dist/tools/schema.js +253 -0
- package/dist/tools/temporal.js +313 -0
- package/dist/types.js +2 -0
- package/dist/utils/database.js +123 -0
- package/dist/utils/logger.js +73 -0
- package/dist/utils/query-builder.js +180 -0
- package/dist/utils/rate-limiter.js +39 -0
- package/dist/utils/result-formatter.js +42 -0
- package/dist/utils/sanitize.js +525 -0
- package/dist/utils/zod-to-json-schema.js +85 -0
- package/package.json +58 -0
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { executeQuery } from '../utils/database.js';
|
|
3
|
+
import { formatQueryResult } from '../utils/result-formatter.js';
|
|
4
|
+
import { assertNoSensitiveCatalogAccess } from '../utils/sanitize.js';
|
|
5
|
+
const ExecuteQuerySchema = z.object({
|
|
6
|
+
query: z.string(),
|
|
7
|
+
params: z.array(z.any()).optional().default([]),
|
|
8
|
+
timeout: z.number().optional(),
|
|
9
|
+
maxRows: z.number().optional()
|
|
10
|
+
});
|
|
11
|
+
const ExplainQuerySchema = z.object({
|
|
12
|
+
query: z.string(),
|
|
13
|
+
params: z.array(z.any()).optional().default([]),
|
|
14
|
+
analyze: z.boolean().optional().default(true),
|
|
15
|
+
verbose: z.boolean().optional().default(false),
|
|
16
|
+
buffers: z.boolean().optional().default(true)
|
|
17
|
+
});
|
|
18
|
+
export async function executeQueryTool(connection, logger, args) {
|
|
19
|
+
const { query, params, timeout, maxRows } = args;
|
|
20
|
+
logger.info('executeQuery', 'Executing user query', {
|
|
21
|
+
queryLength: query.length,
|
|
22
|
+
paramCount: params.length
|
|
23
|
+
});
|
|
24
|
+
assertNoSensitiveCatalogAccess(query);
|
|
25
|
+
const startTime = Date.now();
|
|
26
|
+
const result = await executeQuery(connection, logger, {
|
|
27
|
+
query,
|
|
28
|
+
params,
|
|
29
|
+
options: { timeout, maxRows }
|
|
30
|
+
});
|
|
31
|
+
const executionTimeMs = Date.now() - startTime;
|
|
32
|
+
return formatQueryResult(result, executionTimeMs);
|
|
33
|
+
}
|
|
34
|
+
export async function explainQueryTool(connection, logger, args) {
|
|
35
|
+
const { query, params, verbose, buffers } = args;
|
|
36
|
+
const isReadOnly = connection.config.mode === 'read-only';
|
|
37
|
+
const requestedAnalyze = args.analyze;
|
|
38
|
+
const analyze = isReadOnly ? false : requestedAnalyze;
|
|
39
|
+
if (isReadOnly && requestedAnalyze) {
|
|
40
|
+
logger.warn('explainQuery', 'Read-only mode forces analyze=false for safety', {
|
|
41
|
+
queryLength: query.length
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
logger.info('explainQuery', 'Analyzing query performance', {
|
|
45
|
+
queryLength: query.length,
|
|
46
|
+
analyze,
|
|
47
|
+
verbose,
|
|
48
|
+
buffers
|
|
49
|
+
});
|
|
50
|
+
assertNoSensitiveCatalogAccess(query);
|
|
51
|
+
const explainOptions = ['FORMAT JSON'];
|
|
52
|
+
if (analyze)
|
|
53
|
+
explainOptions.push('ANALYZE');
|
|
54
|
+
if (verbose)
|
|
55
|
+
explainOptions.push('VERBOSE');
|
|
56
|
+
if (analyze && buffers)
|
|
57
|
+
explainOptions.push('BUFFERS');
|
|
58
|
+
const explainQuery = `EXPLAIN (${explainOptions.join(', ')}) ${query}`;
|
|
59
|
+
const startTime = Date.now();
|
|
60
|
+
const result = await executeQuery(connection, logger, {
|
|
61
|
+
query: explainQuery,
|
|
62
|
+
params
|
|
63
|
+
});
|
|
64
|
+
const executionTimeMs = Date.now() - startTime;
|
|
65
|
+
const planData = result.rows[0]?.['QUERY PLAN'] || result.rows[0];
|
|
66
|
+
let plan;
|
|
67
|
+
let planningTime = 0;
|
|
68
|
+
let executionTime = 0;
|
|
69
|
+
if (Array.isArray(planData)) {
|
|
70
|
+
plan = planData[0]?.Plan;
|
|
71
|
+
planningTime = planData[0]?.['Planning Time'] || 0;
|
|
72
|
+
executionTime = planData[0]?.['Execution Time'] || 0;
|
|
73
|
+
}
|
|
74
|
+
else {
|
|
75
|
+
plan = planData;
|
|
76
|
+
}
|
|
77
|
+
const recommendations = generateRecommendations(plan);
|
|
78
|
+
return {
|
|
79
|
+
query,
|
|
80
|
+
plan,
|
|
81
|
+
executionTimeMs: executionTime || executionTimeMs,
|
|
82
|
+
planningTimeMs: planningTime,
|
|
83
|
+
recommendations
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
function generateRecommendations(plan) {
|
|
87
|
+
const recommendations = [];
|
|
88
|
+
if (!plan)
|
|
89
|
+
return recommendations;
|
|
90
|
+
const nodeType = plan['Node Type'];
|
|
91
|
+
const relationName = plan['Relation Name'];
|
|
92
|
+
if (nodeType === 'Seq Scan') {
|
|
93
|
+
recommendations.push(`⚠ Sequential scan on table "${relationName}" - consider adding an index`);
|
|
94
|
+
}
|
|
95
|
+
if (nodeType === 'Index Scan' || nodeType === 'Index Only Scan') {
|
|
96
|
+
recommendations.push(`✓ Using index: ${plan['Index Name']}`);
|
|
97
|
+
}
|
|
98
|
+
if (plan['Actual Rows'] && plan['Plan Rows']) {
|
|
99
|
+
const ratio = plan['Actual Rows'] / plan['Plan Rows'];
|
|
100
|
+
if (ratio > 10 || ratio < 0.1) {
|
|
101
|
+
recommendations.push(`⚠ Poor row estimate (planned: ${plan['Plan Rows']}, actual: ${plan['Actual Rows']}) - run ANALYZE`);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
if (plan['Buffers']) {
|
|
105
|
+
const shared = plan['Buffers']['Shared'];
|
|
106
|
+
if (shared) {
|
|
107
|
+
const hit = shared['Hit'] || 0;
|
|
108
|
+
const read = shared['Read'] || 0;
|
|
109
|
+
const total = hit + read;
|
|
110
|
+
if (total > 0) {
|
|
111
|
+
const hitRatio = hit / total;
|
|
112
|
+
if (hitRatio < 0.9) {
|
|
113
|
+
recommendations.push(`⚠ Low cache hit ratio (${(hitRatio * 100).toFixed(1)}%) - data mostly from disk`);
|
|
114
|
+
}
|
|
115
|
+
else {
|
|
116
|
+
recommendations.push(`✓ Excellent cache hit ratio (${(hitRatio * 100).toFixed(1)}%)`);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
if (plan.Plans && Array.isArray(plan.Plans)) {
|
|
122
|
+
for (const subPlan of plan.Plans) {
|
|
123
|
+
recommendations.push(...generateRecommendations(subPlan));
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
return recommendations;
|
|
127
|
+
}
|
|
128
|
+
export const queryTools = {
|
|
129
|
+
executeQuery: {
|
|
130
|
+
schema: ExecuteQuerySchema,
|
|
131
|
+
handler: executeQueryTool
|
|
132
|
+
},
|
|
133
|
+
explainQuery: {
|
|
134
|
+
schema: ExplainQuerySchema,
|
|
135
|
+
handler: explainQueryTool
|
|
136
|
+
}
|
|
137
|
+
};
|
|
138
|
+
//# sourceMappingURL=query.js.map
|
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { executeInternalQuery } from '../utils/database.js';
|
|
3
|
+
import { escapeIdentifier, sanitizeIdentifier } from '../utils/sanitize.js';
|
|
4
|
+
const ExploreRelationshipsSchema = z.object({
|
|
5
|
+
table: z.string(),
|
|
6
|
+
recordId: z.union([z.string(), z.number()]),
|
|
7
|
+
schema: z.string().optional().default('public'),
|
|
8
|
+
depth: z.number().optional().default(1),
|
|
9
|
+
includeReverse: z.boolean().optional().default(true)
|
|
10
|
+
});
|
|
11
|
+
const AnalyzeForeignKeysSchema = z.object({
|
|
12
|
+
schema: z.string().optional().default('public'),
|
|
13
|
+
checkOrphans: z.boolean().optional().default(false),
|
|
14
|
+
checkIndexes: z.boolean().optional().default(true)
|
|
15
|
+
});
|
|
16
|
+
export async function exploreRelationships(connection, logger, args) {
|
|
17
|
+
const { table, recordId, schema, depth, includeReverse } = args;
|
|
18
|
+
logger.info('exploreRelationships', 'Exploring relationships', { table, recordId, depth });
|
|
19
|
+
const sanitizedSchema = sanitizeIdentifier(schema);
|
|
20
|
+
const sanitizedTable = sanitizeIdentifier(table);
|
|
21
|
+
const pkQuery = `
|
|
22
|
+
SELECT a.attname as column_name
|
|
23
|
+
FROM pg_index i
|
|
24
|
+
JOIN pg_attribute a ON a.attrelid = i.indrelid AND a.attnum = ANY(i.indkey)
|
|
25
|
+
WHERE i.indrelid = $1::regclass
|
|
26
|
+
AND i.indisprimary
|
|
27
|
+
LIMIT 1
|
|
28
|
+
`;
|
|
29
|
+
const pkResult = await executeInternalQuery(connection, logger, {
|
|
30
|
+
query: pkQuery,
|
|
31
|
+
params: [`${sanitizedSchema}.${sanitizedTable}`]
|
|
32
|
+
});
|
|
33
|
+
if (pkResult.rows.length === 0) {
|
|
34
|
+
throw new Error(`No primary key found for table ${schema}.${table}`);
|
|
35
|
+
}
|
|
36
|
+
const pkColumn = pkResult.rows[0].column_name;
|
|
37
|
+
const recordQuery = `
|
|
38
|
+
SELECT *
|
|
39
|
+
FROM ${escapeIdentifier(sanitizedSchema)}.${escapeIdentifier(sanitizedTable)}
|
|
40
|
+
WHERE ${escapeIdentifier(sanitizeIdentifier(pkColumn))} = $1
|
|
41
|
+
`;
|
|
42
|
+
const recordResult = await executeInternalQuery(connection, logger, {
|
|
43
|
+
query: recordQuery,
|
|
44
|
+
params: [recordId]
|
|
45
|
+
});
|
|
46
|
+
if (recordResult.rows.length === 0) {
|
|
47
|
+
throw new Error(`Record not found: ${schema}.${table} where ${pkColumn} = ${recordId}`);
|
|
48
|
+
}
|
|
49
|
+
const record = recordResult.rows[0];
|
|
50
|
+
const fkQuery = `
|
|
51
|
+
SELECT
|
|
52
|
+
c.conname as constraint_name,
|
|
53
|
+
a.attname as column_name,
|
|
54
|
+
ref_ns.nspname as ref_schema,
|
|
55
|
+
ref_tbl.relname as ref_table,
|
|
56
|
+
ref_attr.attname as ref_column
|
|
57
|
+
FROM pg_constraint c
|
|
58
|
+
JOIN pg_attribute a ON a.attrelid = c.conrelid AND a.attnum = ANY(c.conkey)
|
|
59
|
+
JOIN pg_class ref_tbl ON ref_tbl.oid = c.confrelid
|
|
60
|
+
JOIN pg_namespace ref_ns ON ref_ns.oid = ref_tbl.relnamespace
|
|
61
|
+
JOIN pg_attribute ref_attr ON ref_attr.attrelid = c.confrelid AND ref_attr.attnum = ANY(c.confkey)
|
|
62
|
+
WHERE c.conrelid = $1::regclass
|
|
63
|
+
AND c.contype = 'f'
|
|
64
|
+
`;
|
|
65
|
+
const fkResult = await executeInternalQuery(connection, logger, {
|
|
66
|
+
query: fkQuery,
|
|
67
|
+
params: [`${sanitizedSchema}.${sanitizedTable}`]
|
|
68
|
+
});
|
|
69
|
+
const related = {};
|
|
70
|
+
for (const fk of fkResult.rows) {
|
|
71
|
+
const fkValue = record[fk.column_name];
|
|
72
|
+
if (fkValue) {
|
|
73
|
+
const relatedQuery = `
|
|
74
|
+
SELECT *
|
|
75
|
+
FROM ${escapeIdentifier(fk.ref_schema)}.${escapeIdentifier(fk.ref_table)}
|
|
76
|
+
WHERE ${escapeIdentifier(fk.ref_column)} = $1
|
|
77
|
+
`;
|
|
78
|
+
const relatedResult = await executeInternalQuery(connection, logger, {
|
|
79
|
+
query: relatedQuery,
|
|
80
|
+
params: [fkValue]
|
|
81
|
+
});
|
|
82
|
+
if (relatedResult.rows.length > 0) {
|
|
83
|
+
related[fk.ref_table] = {
|
|
84
|
+
via: `${fk.column_name} -> ${fk.ref_table}.${fk.ref_column}`,
|
|
85
|
+
record: relatedResult.rows[0]
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
const reverseReferences = {};
|
|
91
|
+
if (includeReverse) {
|
|
92
|
+
const reverseFkQuery = `
|
|
93
|
+
SELECT
|
|
94
|
+
ns.nspname as schema,
|
|
95
|
+
tbl.relname as table,
|
|
96
|
+
a.attname as column_name,
|
|
97
|
+
c.conname as constraint_name
|
|
98
|
+
FROM pg_constraint c
|
|
99
|
+
JOIN pg_class tbl ON tbl.oid = c.conrelid
|
|
100
|
+
JOIN pg_namespace ns ON ns.oid = tbl.relnamespace
|
|
101
|
+
JOIN pg_attribute a ON a.attrelid = c.conrelid AND a.attnum = ANY(c.conkey)
|
|
102
|
+
WHERE c.confrelid = $1::regclass
|
|
103
|
+
AND c.contype = 'f'
|
|
104
|
+
`;
|
|
105
|
+
const reverseFkResult = await executeInternalQuery(connection, logger, {
|
|
106
|
+
query: reverseFkQuery,
|
|
107
|
+
params: [`${sanitizedSchema}.${sanitizedTable}`]
|
|
108
|
+
});
|
|
109
|
+
for (const revFk of reverseFkResult.rows) {
|
|
110
|
+
const reverseQuery = `
|
|
111
|
+
SELECT *
|
|
112
|
+
FROM ${escapeIdentifier(revFk.schema)}.${escapeIdentifier(revFk.table)}
|
|
113
|
+
WHERE ${escapeIdentifier(revFk.column_name)} = $1
|
|
114
|
+
LIMIT 10
|
|
115
|
+
`;
|
|
116
|
+
const reverseResult = await executeInternalQuery(connection, logger, {
|
|
117
|
+
query: reverseQuery,
|
|
118
|
+
params: [recordId]
|
|
119
|
+
});
|
|
120
|
+
if (reverseResult.rows.length > 0) {
|
|
121
|
+
reverseReferences[revFk.table] = reverseResult.rows;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
return {
|
|
126
|
+
table,
|
|
127
|
+
schema,
|
|
128
|
+
primaryKey: pkColumn,
|
|
129
|
+
recordId,
|
|
130
|
+
record,
|
|
131
|
+
related,
|
|
132
|
+
...(includeReverse && { reverseReferences })
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
export async function analyzeForeignKeys(connection, logger, args) {
|
|
136
|
+
const { schema, checkOrphans, checkIndexes } = args;
|
|
137
|
+
logger.info('analyzeForeignKeys', 'Analyzing foreign keys', { schema });
|
|
138
|
+
const sanitizedSchema = sanitizeIdentifier(schema);
|
|
139
|
+
const fkQuery = `
|
|
140
|
+
SELECT
|
|
141
|
+
c.conname as constraint_name,
|
|
142
|
+
ns.nspname as schema,
|
|
143
|
+
tbl.relname as table,
|
|
144
|
+
a.attname as column,
|
|
145
|
+
ref_ns.nspname as ref_schema,
|
|
146
|
+
ref_tbl.relname as ref_table,
|
|
147
|
+
ref_attr.attname as ref_column,
|
|
148
|
+
c.confupdtype as on_update,
|
|
149
|
+
c.confdeltype as on_delete
|
|
150
|
+
FROM pg_constraint c
|
|
151
|
+
JOIN pg_class tbl ON tbl.oid = c.conrelid
|
|
152
|
+
JOIN pg_namespace ns ON ns.oid = tbl.relnamespace
|
|
153
|
+
JOIN pg_attribute a ON a.attrelid = c.conrelid AND a.attnum = ANY(c.conkey)
|
|
154
|
+
JOIN pg_class ref_tbl ON ref_tbl.oid = c.confrelid
|
|
155
|
+
JOIN pg_namespace ref_ns ON ref_ns.oid = ref_tbl.relnamespace
|
|
156
|
+
JOIN pg_attribute ref_attr ON ref_attr.attrelid = c.confrelid AND ref_attr.attnum = ANY(c.confkey)
|
|
157
|
+
WHERE ns.nspname = $1
|
|
158
|
+
AND c.contype = 'f'
|
|
159
|
+
ORDER BY tbl.relname, c.conname
|
|
160
|
+
`;
|
|
161
|
+
const fkResult = await executeInternalQuery(connection, logger, {
|
|
162
|
+
query: fkQuery,
|
|
163
|
+
params: [sanitizedSchema]
|
|
164
|
+
});
|
|
165
|
+
const issues = [];
|
|
166
|
+
for (const fk of fkResult.rows) {
|
|
167
|
+
const fkIssues = [];
|
|
168
|
+
if (checkIndexes) {
|
|
169
|
+
const indexQuery = `
|
|
170
|
+
SELECT COUNT(*) as index_count
|
|
171
|
+
FROM pg_index i
|
|
172
|
+
JOIN pg_attribute a ON a.attrelid = i.indrelid AND a.attnum = ANY(i.indkey)
|
|
173
|
+
WHERE i.indrelid = $1::regclass
|
|
174
|
+
AND a.attname = $2
|
|
175
|
+
`;
|
|
176
|
+
const indexResult = await executeInternalQuery(connection, logger, {
|
|
177
|
+
query: indexQuery,
|
|
178
|
+
params: [`${fk.schema}.${fk.table}`, fk.column]
|
|
179
|
+
});
|
|
180
|
+
const indexCount = parseInt(indexResult.rows[0]?.index_count || '0', 10);
|
|
181
|
+
if (indexCount === 0) {
|
|
182
|
+
fkIssues.push({
|
|
183
|
+
type: 'missing_index',
|
|
184
|
+
severity: 'warning',
|
|
185
|
+
message: 'Foreign key column not indexed',
|
|
186
|
+
impact: `Slow DELETE/UPDATE on ${fk.ref_table} table`,
|
|
187
|
+
recommendation: `CREATE INDEX CONCURRENTLY idx_${fk.table}_${fk.column} ON ${fk.schema}.${fk.table}(${fk.column});`,
|
|
188
|
+
estimatedImpact: 'Will speed up CASCADE operations and JOIN queries'
|
|
189
|
+
});
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
if (checkOrphans) {
|
|
193
|
+
const orphanQuery = `
|
|
194
|
+
SELECT COUNT(*) as orphan_count
|
|
195
|
+
FROM ${escapeIdentifier(fk.schema)}.${escapeIdentifier(fk.table)} t
|
|
196
|
+
LEFT JOIN ${escapeIdentifier(fk.ref_schema)}.${escapeIdentifier(fk.ref_table)} r
|
|
197
|
+
ON t.${escapeIdentifier(fk.column)} = r.${escapeIdentifier(fk.ref_column)}
|
|
198
|
+
WHERE t.${escapeIdentifier(fk.column)} IS NOT NULL
|
|
199
|
+
AND r.${escapeIdentifier(fk.ref_column)} IS NULL
|
|
200
|
+
`;
|
|
201
|
+
const orphanResult = await executeInternalQuery(connection, logger, {
|
|
202
|
+
query: orphanQuery
|
|
203
|
+
});
|
|
204
|
+
const orphanCount = parseInt(orphanResult.rows[0]?.orphan_count || '0', 10);
|
|
205
|
+
if (orphanCount > 0) {
|
|
206
|
+
fkIssues.push({
|
|
207
|
+
type: 'orphans',
|
|
208
|
+
severity: 'error',
|
|
209
|
+
message: `${orphanCount} orphaned records found`,
|
|
210
|
+
orphanCount,
|
|
211
|
+
recommendation: 'Clean up orphans before enforcing constraint',
|
|
212
|
+
cleanupQuery: `DELETE FROM ${fk.schema}.${fk.table} WHERE ${fk.column} NOT IN (SELECT ${fk.ref_column} FROM ${fk.ref_schema}.${fk.ref_table});`
|
|
213
|
+
});
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
if (fkIssues.length > 0) {
|
|
217
|
+
issues.push({
|
|
218
|
+
constraint: fk.constraint_name,
|
|
219
|
+
table: fk.table,
|
|
220
|
+
schema: fk.schema,
|
|
221
|
+
column: fk.column,
|
|
222
|
+
referencesTable: fk.ref_table,
|
|
223
|
+
referencesSchema: fk.ref_schema,
|
|
224
|
+
referencesColumn: fk.ref_column,
|
|
225
|
+
issues: fkIssues
|
|
226
|
+
});
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
const recommendations = [];
|
|
230
|
+
const missingIndexes = issues.filter(i => i.issues.some((issue) => issue.type === 'missing_index')).length;
|
|
231
|
+
const orphanedRecords = issues.filter(i => i.issues.some((issue) => issue.type === 'orphans')).length;
|
|
232
|
+
if (missingIndexes > 0) {
|
|
233
|
+
recommendations.push(`⚠ ${missingIndexes} foreign key columns missing indexes - significant performance impact`);
|
|
234
|
+
recommendations.push('Add missing indexes during low-traffic period (use CONCURRENTLY)');
|
|
235
|
+
}
|
|
236
|
+
if (orphanedRecords > 0) {
|
|
237
|
+
recommendations.push(`⚠ ${orphanedRecords} foreign keys have orphaned records - data integrity issue`);
|
|
238
|
+
recommendations.push('Clean up orphaned records before enforcing constraints');
|
|
239
|
+
}
|
|
240
|
+
if (issues.length === 0) {
|
|
241
|
+
recommendations.push('✓ All foreign keys are properly indexed and have no orphaned records');
|
|
242
|
+
}
|
|
243
|
+
return {
|
|
244
|
+
schema,
|
|
245
|
+
totalForeignKeys: fkResult.rows.length,
|
|
246
|
+
issuesFound: issues.length,
|
|
247
|
+
issues,
|
|
248
|
+
recommendations
|
|
249
|
+
};
|
|
250
|
+
}
|
|
251
|
+
export const relationshipTools = {
|
|
252
|
+
exploreRelationships: {
|
|
253
|
+
schema: ExploreRelationshipsSchema,
|
|
254
|
+
handler: exploreRelationships
|
|
255
|
+
},
|
|
256
|
+
analyzeForeignKeys: {
|
|
257
|
+
schema: AnalyzeForeignKeysSchema,
|
|
258
|
+
handler: analyzeForeignKeys
|
|
259
|
+
}
|
|
260
|
+
};
|
|
261
|
+
//# sourceMappingURL=relationships.js.map
|
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { executeInternalQuery } from '../utils/database.js';
|
|
3
|
+
const ListTablesSchema = z.object({
|
|
4
|
+
schema: z.string().optional().default('public'),
|
|
5
|
+
includeSystemTables: z.boolean().optional().default(false)
|
|
6
|
+
});
|
|
7
|
+
const DescribeTableSchema = z.object({
|
|
8
|
+
table: z.string(),
|
|
9
|
+
schema: z.string().optional().default('public')
|
|
10
|
+
});
|
|
11
|
+
const ListSchemasSchema = z.object({});
|
|
12
|
+
export async function listSchemas(connection, logger, args) {
|
|
13
|
+
logger.info('listSchemas', 'Listing database schemas');
|
|
14
|
+
const query = `
|
|
15
|
+
SELECT
|
|
16
|
+
n.nspname as name,
|
|
17
|
+
pg_catalog.pg_get_userbyid(n.nspowner) as owner,
|
|
18
|
+
(SELECT COUNT(*) FROM pg_catalog.pg_class c WHERE c.relnamespace = n.oid AND c.relkind = 'r') as table_count
|
|
19
|
+
FROM pg_catalog.pg_namespace n
|
|
20
|
+
WHERE n.nspname !~ '^pg_' AND n.nspname <> 'information_schema'
|
|
21
|
+
ORDER BY n.nspname;
|
|
22
|
+
`;
|
|
23
|
+
const result = await executeInternalQuery(connection, logger, { query });
|
|
24
|
+
return {
|
|
25
|
+
schemas: result.rows.map(row => ({
|
|
26
|
+
name: row.name,
|
|
27
|
+
owner: row.owner,
|
|
28
|
+
tableCount: parseInt(row.table_count, 10)
|
|
29
|
+
}))
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
export async function listTables(connection, logger, args) {
|
|
33
|
+
const { schema, includeSystemTables } = args;
|
|
34
|
+
logger.info('listTables', 'Listing tables', { schema, includeSystemTables });
|
|
35
|
+
const systemTableFilter = includeSystemTables ? '' : "AND c.relname NOT LIKE 'pg_%'";
|
|
36
|
+
const query = `
|
|
37
|
+
SELECT
|
|
38
|
+
c.relname as name,
|
|
39
|
+
n.nspname as schema,
|
|
40
|
+
CASE c.relkind
|
|
41
|
+
WHEN 'r' THEN 'BASE TABLE'
|
|
42
|
+
WHEN 'v' THEN 'VIEW'
|
|
43
|
+
WHEN 'm' THEN 'MATERIALIZED VIEW'
|
|
44
|
+
WHEN 'p' THEN 'PARTITIONED TABLE'
|
|
45
|
+
END as type,
|
|
46
|
+
CASE
|
|
47
|
+
WHEN c.reltuples < 0 THEN NULL
|
|
48
|
+
ELSE c.reltuples::bigint
|
|
49
|
+
END as row_estimate,
|
|
50
|
+
pg_stat_get_live_tuples(c.oid) as live_tuples,
|
|
51
|
+
c.reltuples < 0 as needs_analyze,
|
|
52
|
+
pg_total_relation_size(c.oid) as total_size,
|
|
53
|
+
pg_table_size(c.oid) as table_size,
|
|
54
|
+
pg_indexes_size(c.oid) as index_size,
|
|
55
|
+
pg_stat_get_last_vacuum_time(c.oid) as last_vacuum,
|
|
56
|
+
pg_stat_get_last_analyze_time(c.oid) as last_analyze,
|
|
57
|
+
c.relkind = 'p' as is_partitioned
|
|
58
|
+
FROM pg_catalog.pg_class c
|
|
59
|
+
LEFT JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace
|
|
60
|
+
WHERE c.relkind IN ('r', 'v', 'm', 'p')
|
|
61
|
+
AND n.nspname = $1
|
|
62
|
+
${systemTableFilter}
|
|
63
|
+
ORDER BY c.relname;
|
|
64
|
+
`;
|
|
65
|
+
const result = await executeInternalQuery(connection, logger, {
|
|
66
|
+
query,
|
|
67
|
+
params: [schema]
|
|
68
|
+
});
|
|
69
|
+
const tables = result.rows.map(row => {
|
|
70
|
+
const rowEstimate = row.row_estimate === null
|
|
71
|
+
? parseInt(row.live_tuples || '0', 10)
|
|
72
|
+
: parseInt(row.row_estimate, 10);
|
|
73
|
+
return {
|
|
74
|
+
name: row.name,
|
|
75
|
+
schema: row.schema,
|
|
76
|
+
type: row.type,
|
|
77
|
+
rowEstimate,
|
|
78
|
+
...(row.needs_analyze && { needsAnalyze: true }),
|
|
79
|
+
sizeBytes: parseInt(row.table_size, 10),
|
|
80
|
+
indexSize: parseInt(row.index_size, 10),
|
|
81
|
+
totalSize: parseInt(row.total_size, 10),
|
|
82
|
+
lastVacuum: row.last_vacuum,
|
|
83
|
+
lastAnalyze: row.last_analyze,
|
|
84
|
+
isPartitioned: row.is_partitioned
|
|
85
|
+
};
|
|
86
|
+
});
|
|
87
|
+
return {
|
|
88
|
+
schema,
|
|
89
|
+
tableCount: tables.length,
|
|
90
|
+
tables: tables.map(t => ({
|
|
91
|
+
...t,
|
|
92
|
+
sizeMB: (t.sizeBytes / 1024 / 1024).toFixed(2),
|
|
93
|
+
indexSizeMB: (t.indexSize / 1024 / 1024).toFixed(2),
|
|
94
|
+
totalSizeMB: (t.totalSize / 1024 / 1024).toFixed(2)
|
|
95
|
+
}))
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
export async function describeTable(connection, logger, args) {
|
|
99
|
+
const { table, schema } = args;
|
|
100
|
+
logger.info('describeTable', 'Describing table', { table, schema });
|
|
101
|
+
const [columns, constraints, indexes, stats] = await Promise.all([
|
|
102
|
+
getColumns(connection, logger, schema, table),
|
|
103
|
+
getConstraints(connection, logger, schema, table),
|
|
104
|
+
getIndexes(connection, logger, schema, table),
|
|
105
|
+
getTableStats(connection, logger, schema, table)
|
|
106
|
+
]);
|
|
107
|
+
return {
|
|
108
|
+
table,
|
|
109
|
+
schema,
|
|
110
|
+
columns,
|
|
111
|
+
constraints,
|
|
112
|
+
indexes,
|
|
113
|
+
...stats
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
async function getColumns(connection, logger, schema, table) {
|
|
117
|
+
const query = `
|
|
118
|
+
SELECT
|
|
119
|
+
a.attname as name,
|
|
120
|
+
pg_catalog.format_type(a.atttypid, a.atttypmod) as type,
|
|
121
|
+
NOT a.attnotnull as nullable,
|
|
122
|
+
pg_catalog.pg_get_expr(d.adbin, d.adrelid) as default_value,
|
|
123
|
+
EXISTS(
|
|
124
|
+
SELECT 1 FROM pg_catalog.pg_constraint c
|
|
125
|
+
WHERE c.conrelid = a.attrelid
|
|
126
|
+
AND a.attnum = ANY(c.conkey)
|
|
127
|
+
AND c.contype = 'p'
|
|
128
|
+
) as is_primary_key,
|
|
129
|
+
EXISTS(
|
|
130
|
+
SELECT 1 FROM pg_catalog.pg_constraint c
|
|
131
|
+
WHERE c.conrelid = a.attrelid
|
|
132
|
+
AND a.attnum = ANY(c.conkey)
|
|
133
|
+
AND c.contype = 'f'
|
|
134
|
+
) as is_foreign_key
|
|
135
|
+
FROM pg_catalog.pg_attribute a
|
|
136
|
+
LEFT JOIN pg_catalog.pg_attrdef d ON a.attrelid = d.adrelid AND a.attnum = d.adnum
|
|
137
|
+
WHERE a.attrelid = $1::regclass
|
|
138
|
+
AND a.attnum > 0
|
|
139
|
+
AND NOT a.attisdropped
|
|
140
|
+
ORDER BY a.attnum;
|
|
141
|
+
`;
|
|
142
|
+
const result = await executeInternalQuery(connection, logger, {
|
|
143
|
+
query,
|
|
144
|
+
params: [`${schema}.${table}`]
|
|
145
|
+
});
|
|
146
|
+
return result.rows.map(row => ({
|
|
147
|
+
name: row.name,
|
|
148
|
+
type: row.type,
|
|
149
|
+
nullable: row.nullable,
|
|
150
|
+
default: row.default_value,
|
|
151
|
+
isPrimaryKey: row.is_primary_key,
|
|
152
|
+
isForeignKey: row.is_foreign_key
|
|
153
|
+
}));
|
|
154
|
+
}
|
|
155
|
+
async function getConstraints(connection, logger, schema, table) {
|
|
156
|
+
const query = `
|
|
157
|
+
SELECT
|
|
158
|
+
c.conname as name,
|
|
159
|
+
c.contype as type,
|
|
160
|
+
pg_catalog.pg_get_constraintdef(c.oid, true) as definition,
|
|
161
|
+
ARRAY(
|
|
162
|
+
SELECT a.attname
|
|
163
|
+
FROM unnest(c.conkey) k(n)
|
|
164
|
+
JOIN pg_catalog.pg_attribute a ON a.attrelid = c.conrelid AND a.attnum = k.n
|
|
165
|
+
) as columns
|
|
166
|
+
FROM pg_catalog.pg_constraint c
|
|
167
|
+
WHERE c.conrelid = $1::regclass
|
|
168
|
+
ORDER BY c.conname;
|
|
169
|
+
`;
|
|
170
|
+
const result = await executeInternalQuery(connection, logger, {
|
|
171
|
+
query,
|
|
172
|
+
params: [`${schema}.${table}`]
|
|
173
|
+
});
|
|
174
|
+
return result.rows.map(row => {
|
|
175
|
+
const typeMap = {
|
|
176
|
+
'p': 'PRIMARY KEY',
|
|
177
|
+
'f': 'FOREIGN KEY',
|
|
178
|
+
'u': 'UNIQUE',
|
|
179
|
+
'c': 'CHECK'
|
|
180
|
+
};
|
|
181
|
+
return {
|
|
182
|
+
name: row.name,
|
|
183
|
+
type: typeMap[row.type] || 'CHECK',
|
|
184
|
+
columns: row.columns,
|
|
185
|
+
definition: row.definition
|
|
186
|
+
};
|
|
187
|
+
});
|
|
188
|
+
}
|
|
189
|
+
async function getIndexes(connection, logger, schema, table) {
|
|
190
|
+
const query = `
|
|
191
|
+
SELECT
|
|
192
|
+
i.relname as name,
|
|
193
|
+
ARRAY(
|
|
194
|
+
SELECT a.attname
|
|
195
|
+
FROM pg_catalog.pg_attribute a
|
|
196
|
+
WHERE a.attrelid = i.oid
|
|
197
|
+
AND a.attnum > 0
|
|
198
|
+
ORDER BY a.attnum
|
|
199
|
+
) as columns,
|
|
200
|
+
am.amname as type,
|
|
201
|
+
ix.indisunique as unique,
|
|
202
|
+
ix.indisprimary as primary,
|
|
203
|
+
pg_size_pretty(pg_relation_size(i.oid)) as size
|
|
204
|
+
FROM pg_catalog.pg_index ix
|
|
205
|
+
JOIN pg_catalog.pg_class i ON i.oid = ix.indexrelid
|
|
206
|
+
JOIN pg_catalog.pg_am am ON am.oid = i.relam
|
|
207
|
+
WHERE ix.indrelid = $1::regclass
|
|
208
|
+
ORDER BY i.relname;
|
|
209
|
+
`;
|
|
210
|
+
const result = await executeInternalQuery(connection, logger, {
|
|
211
|
+
query,
|
|
212
|
+
params: [`${schema}.${table}`]
|
|
213
|
+
});
|
|
214
|
+
return result.rows.map(row => ({
|
|
215
|
+
name: row.name,
|
|
216
|
+
columns: row.columns,
|
|
217
|
+
type: row.type,
|
|
218
|
+
unique: row.unique,
|
|
219
|
+
primary: row.primary,
|
|
220
|
+
size: row.size
|
|
221
|
+
}));
|
|
222
|
+
}
|
|
223
|
+
async function getTableStats(connection, logger, schema, table) {
|
|
224
|
+
const query = `
|
|
225
|
+
SELECT
|
|
226
|
+
c.reltuples::bigint as estimated_row_count,
|
|
227
|
+
pg_size_pretty(pg_table_size(c.oid)) as disk_size,
|
|
228
|
+
pg_size_pretty(pg_indexes_size(c.oid)) as index_size,
|
|
229
|
+
pg_size_pretty(pg_total_relation_size(c.oid)) as total_size
|
|
230
|
+
FROM pg_catalog.pg_class c
|
|
231
|
+
WHERE c.oid = $1::regclass;
|
|
232
|
+
`;
|
|
233
|
+
const result = await executeInternalQuery(connection, logger, {
|
|
234
|
+
query,
|
|
235
|
+
params: [`${schema}.${table}`]
|
|
236
|
+
});
|
|
237
|
+
return result.rows[0] || {};
|
|
238
|
+
}
|
|
239
|
+
export const schemaTools = {
|
|
240
|
+
listSchemas: {
|
|
241
|
+
schema: ListSchemasSchema,
|
|
242
|
+
handler: listSchemas
|
|
243
|
+
},
|
|
244
|
+
listTables: {
|
|
245
|
+
schema: ListTablesSchema,
|
|
246
|
+
handler: listTables
|
|
247
|
+
},
|
|
248
|
+
describeTable: {
|
|
249
|
+
schema: DescribeTableSchema,
|
|
250
|
+
handler: describeTable
|
|
251
|
+
}
|
|
252
|
+
};
|
|
253
|
+
//# sourceMappingURL=schema.js.map
|