@indiekitai/pg-dash 0.3.7 → 0.3.9
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 +2 -2
- package/README.zh-CN.md +2 -2
- package/dist/cli.js +1087 -823
- package/dist/cli.js.map +1 -1
- package/dist/mcp.js +332 -23
- package/dist/mcp.js.map +1 -1
- package/package.json +1 -1
package/dist/mcp.js
CHANGED
|
@@ -63,6 +63,30 @@ async function getTables(pool2) {
|
|
|
63
63
|
}
|
|
64
64
|
|
|
65
65
|
// src/server/queries/schema.ts
|
|
66
|
+
async function getSchemaTables(pool2) {
|
|
67
|
+
const client = await pool2.connect();
|
|
68
|
+
try {
|
|
69
|
+
const r = await client.query(`
|
|
70
|
+
SELECT
|
|
71
|
+
c.relname AS name,
|
|
72
|
+
n.nspname AS schema,
|
|
73
|
+
pg_size_pretty(pg_total_relation_size(c.oid)) AS total_size,
|
|
74
|
+
pg_total_relation_size(c.oid) AS total_size_bytes,
|
|
75
|
+
pg_size_pretty(pg_relation_size(c.oid)) AS table_size,
|
|
76
|
+
pg_size_pretty(pg_total_relation_size(c.oid) - pg_relation_size(c.oid)) AS index_size,
|
|
77
|
+
s.n_live_tup AS row_count,
|
|
78
|
+
obj_description(c.oid) AS description
|
|
79
|
+
FROM pg_class c
|
|
80
|
+
JOIN pg_namespace n ON c.relnamespace = n.oid
|
|
81
|
+
LEFT JOIN pg_stat_user_tables s ON s.relid = c.oid
|
|
82
|
+
WHERE c.relkind = 'r' AND n.nspname NOT IN ('pg_catalog', 'information_schema')
|
|
83
|
+
ORDER BY pg_total_relation_size(c.oid) DESC
|
|
84
|
+
`);
|
|
85
|
+
return r.rows;
|
|
86
|
+
} finally {
|
|
87
|
+
client.release();
|
|
88
|
+
}
|
|
89
|
+
}
|
|
66
90
|
async function getSchemaTableDetail(pool2, tableName) {
|
|
67
91
|
const client = await pool2.connect();
|
|
68
92
|
try {
|
|
@@ -160,6 +184,26 @@ async function getSchemaTableDetail(pool2, tableName) {
|
|
|
160
184
|
client.release();
|
|
161
185
|
}
|
|
162
186
|
}
|
|
187
|
+
async function getSchemaEnums(pool2) {
|
|
188
|
+
const client = await pool2.connect();
|
|
189
|
+
try {
|
|
190
|
+
const r = await client.query(`
|
|
191
|
+
SELECT
|
|
192
|
+
t.typname AS name,
|
|
193
|
+
n.nspname AS schema,
|
|
194
|
+
array_agg(e.enumlabel ORDER BY e.enumsortorder) AS values
|
|
195
|
+
FROM pg_type t
|
|
196
|
+
JOIN pg_namespace n ON t.typnamespace = n.oid
|
|
197
|
+
JOIN pg_enum e ON t.oid = e.enumtypid
|
|
198
|
+
WHERE n.nspname NOT IN ('pg_catalog', 'information_schema')
|
|
199
|
+
GROUP BY t.typname, n.nspname
|
|
200
|
+
ORDER BY t.typname
|
|
201
|
+
`);
|
|
202
|
+
return r.rows;
|
|
203
|
+
} finally {
|
|
204
|
+
client.release();
|
|
205
|
+
}
|
|
206
|
+
}
|
|
163
207
|
|
|
164
208
|
// src/server/queries/activity.ts
|
|
165
209
|
async function getActivity(pool2) {
|
|
@@ -989,6 +1033,140 @@ function diffSnapshots(prev, current) {
|
|
|
989
1033
|
|
|
990
1034
|
// src/server/env-differ.ts
|
|
991
1035
|
import { Pool } from "pg";
|
|
1036
|
+
|
|
1037
|
+
// src/server/schema-diff.ts
|
|
1038
|
+
function diffSnapshots2(oldSnap, newSnap) {
|
|
1039
|
+
const changes = [];
|
|
1040
|
+
const oldTableMap = new Map(oldSnap.tables.map((t) => [`${t.schema}.${t.name}`, t]));
|
|
1041
|
+
const newTableMap = new Map(newSnap.tables.map((t) => [`${t.schema}.${t.name}`, t]));
|
|
1042
|
+
for (const [key, t] of newTableMap) {
|
|
1043
|
+
if (!oldTableMap.has(key)) {
|
|
1044
|
+
changes.push({ change_type: "added", object_type: "table", table_name: key, detail: `Table ${key} added` });
|
|
1045
|
+
}
|
|
1046
|
+
}
|
|
1047
|
+
for (const [key] of oldTableMap) {
|
|
1048
|
+
if (!newTableMap.has(key)) {
|
|
1049
|
+
changes.push({ change_type: "removed", object_type: "table", table_name: key, detail: `Table ${key} removed` });
|
|
1050
|
+
}
|
|
1051
|
+
}
|
|
1052
|
+
for (const [key, newTable] of newTableMap) {
|
|
1053
|
+
const oldTable = oldTableMap.get(key);
|
|
1054
|
+
if (!oldTable) continue;
|
|
1055
|
+
const oldCols = new Map(oldTable.columns.map((c) => [c.name, c]));
|
|
1056
|
+
const newCols = new Map(newTable.columns.map((c) => [c.name, c]));
|
|
1057
|
+
for (const [name, col] of newCols) {
|
|
1058
|
+
const oldCol = oldCols.get(name);
|
|
1059
|
+
if (!oldCol) {
|
|
1060
|
+
changes.push({ change_type: "added", object_type: "column", table_name: key, detail: `Column ${name} added (${col.type})` });
|
|
1061
|
+
} else {
|
|
1062
|
+
if (oldCol.type !== col.type) {
|
|
1063
|
+
changes.push({ change_type: "modified", object_type: "column", table_name: key, detail: `Column ${name} type changed: ${oldCol.type} \u2192 ${col.type}` });
|
|
1064
|
+
}
|
|
1065
|
+
if (oldCol.nullable !== col.nullable) {
|
|
1066
|
+
changes.push({ change_type: "modified", object_type: "column", table_name: key, detail: `Column ${name} nullable changed: ${oldCol.nullable} \u2192 ${col.nullable}` });
|
|
1067
|
+
}
|
|
1068
|
+
if (oldCol.default_value !== col.default_value) {
|
|
1069
|
+
changes.push({ change_type: "modified", object_type: "column", table_name: key, detail: `Column ${name} default changed: ${oldCol.default_value ?? "NULL"} \u2192 ${col.default_value ?? "NULL"}` });
|
|
1070
|
+
}
|
|
1071
|
+
}
|
|
1072
|
+
}
|
|
1073
|
+
for (const name of oldCols.keys()) {
|
|
1074
|
+
if (!newCols.has(name)) {
|
|
1075
|
+
changes.push({ change_type: "removed", object_type: "column", table_name: key, detail: `Column ${name} removed` });
|
|
1076
|
+
}
|
|
1077
|
+
}
|
|
1078
|
+
const oldIdx = new Map(oldTable.indexes.map((i) => [i.name, i]));
|
|
1079
|
+
const newIdx = new Map(newTable.indexes.map((i) => [i.name, i]));
|
|
1080
|
+
for (const [name, idx] of newIdx) {
|
|
1081
|
+
if (!oldIdx.has(name)) {
|
|
1082
|
+
changes.push({ change_type: "added", object_type: "index", table_name: key, detail: `Index ${name} added` });
|
|
1083
|
+
} else if (oldIdx.get(name).definition !== idx.definition) {
|
|
1084
|
+
changes.push({ change_type: "modified", object_type: "index", table_name: key, detail: `Index ${name} definition changed` });
|
|
1085
|
+
}
|
|
1086
|
+
}
|
|
1087
|
+
for (const name of oldIdx.keys()) {
|
|
1088
|
+
if (!newIdx.has(name)) {
|
|
1089
|
+
changes.push({ change_type: "removed", object_type: "index", table_name: key, detail: `Index ${name} removed` });
|
|
1090
|
+
}
|
|
1091
|
+
}
|
|
1092
|
+
const oldCon = new Map(oldTable.constraints.map((c) => [c.name, c]));
|
|
1093
|
+
const newCon = new Map(newTable.constraints.map((c) => [c.name, c]));
|
|
1094
|
+
for (const [name, con] of newCon) {
|
|
1095
|
+
if (!oldCon.has(name)) {
|
|
1096
|
+
changes.push({ change_type: "added", object_type: "constraint", table_name: key, detail: `Constraint ${name} added (${con.type})` });
|
|
1097
|
+
} else if (oldCon.get(name).definition !== con.definition) {
|
|
1098
|
+
changes.push({ change_type: "modified", object_type: "constraint", table_name: key, detail: `Constraint ${name} definition changed` });
|
|
1099
|
+
}
|
|
1100
|
+
}
|
|
1101
|
+
for (const name of oldCon.keys()) {
|
|
1102
|
+
if (!newCon.has(name)) {
|
|
1103
|
+
changes.push({ change_type: "removed", object_type: "constraint", table_name: key, detail: `Constraint ${name} removed` });
|
|
1104
|
+
}
|
|
1105
|
+
}
|
|
1106
|
+
}
|
|
1107
|
+
const oldEnums = new Map((oldSnap.enums || []).map((e) => [`${e.schema}.${e.name}`, e]));
|
|
1108
|
+
const newEnums = new Map((newSnap.enums || []).map((e) => [`${e.schema}.${e.name}`, e]));
|
|
1109
|
+
for (const [key, en] of newEnums) {
|
|
1110
|
+
const oldEn = oldEnums.get(key);
|
|
1111
|
+
if (!oldEn) {
|
|
1112
|
+
changes.push({ change_type: "added", object_type: "enum", table_name: null, detail: `Enum ${key} added (${en.values.join(", ")})` });
|
|
1113
|
+
} else {
|
|
1114
|
+
const added = en.values.filter((v) => !oldEn.values.includes(v));
|
|
1115
|
+
const removed = oldEn.values.filter((v) => !en.values.includes(v));
|
|
1116
|
+
for (const v of added) {
|
|
1117
|
+
changes.push({ change_type: "modified", object_type: "enum", table_name: null, detail: `Enum ${key}: value '${v}' added` });
|
|
1118
|
+
}
|
|
1119
|
+
for (const v of removed) {
|
|
1120
|
+
changes.push({ change_type: "modified", object_type: "enum", table_name: null, detail: `Enum ${key}: value '${v}' removed` });
|
|
1121
|
+
}
|
|
1122
|
+
}
|
|
1123
|
+
}
|
|
1124
|
+
for (const key of oldEnums.keys()) {
|
|
1125
|
+
if (!newEnums.has(key)) {
|
|
1126
|
+
changes.push({ change_type: "removed", object_type: "enum", table_name: null, detail: `Enum ${key} removed` });
|
|
1127
|
+
}
|
|
1128
|
+
}
|
|
1129
|
+
return changes;
|
|
1130
|
+
}
|
|
1131
|
+
|
|
1132
|
+
// src/server/schema-tracker.ts
|
|
1133
|
+
async function buildLiveSnapshot(pool2) {
|
|
1134
|
+
const tables = await getSchemaTables(pool2);
|
|
1135
|
+
const enums = await getSchemaEnums(pool2);
|
|
1136
|
+
const detailedTables = await Promise.all(
|
|
1137
|
+
tables.map(async (t) => {
|
|
1138
|
+
const detail = await getSchemaTableDetail(pool2, `${t.schema}.${t.name}`);
|
|
1139
|
+
if (!detail) return null;
|
|
1140
|
+
return {
|
|
1141
|
+
name: detail.name,
|
|
1142
|
+
schema: detail.schema,
|
|
1143
|
+
columns: detail.columns.map((c) => ({
|
|
1144
|
+
name: c.name,
|
|
1145
|
+
type: c.type,
|
|
1146
|
+
nullable: c.nullable,
|
|
1147
|
+
default_value: c.default_value
|
|
1148
|
+
})),
|
|
1149
|
+
indexes: detail.indexes.map((i) => ({
|
|
1150
|
+
name: i.name,
|
|
1151
|
+
definition: i.definition,
|
|
1152
|
+
is_unique: i.is_unique,
|
|
1153
|
+
is_primary: i.is_primary
|
|
1154
|
+
})),
|
|
1155
|
+
constraints: detail.constraints.map((c) => ({
|
|
1156
|
+
name: c.name,
|
|
1157
|
+
type: c.type,
|
|
1158
|
+
definition: c.definition
|
|
1159
|
+
}))
|
|
1160
|
+
};
|
|
1161
|
+
})
|
|
1162
|
+
);
|
|
1163
|
+
return {
|
|
1164
|
+
tables: detailedTables.filter(Boolean),
|
|
1165
|
+
enums: enums.map((e) => ({ name: e.name, schema: e.schema, values: e.values }))
|
|
1166
|
+
};
|
|
1167
|
+
}
|
|
1168
|
+
|
|
1169
|
+
// src/server/env-differ.ts
|
|
992
1170
|
async function fetchTables(pool2) {
|
|
993
1171
|
const res = await pool2.query(`
|
|
994
1172
|
SELECT table_name
|
|
@@ -1009,7 +1187,7 @@ async function fetchColumns(pool2) {
|
|
|
1009
1187
|
}
|
|
1010
1188
|
async function fetchIndexes(pool2) {
|
|
1011
1189
|
const res = await pool2.query(`
|
|
1012
|
-
SELECT tablename, indexname
|
|
1190
|
+
SELECT tablename, indexname, indexdef
|
|
1013
1191
|
FROM pg_indexes
|
|
1014
1192
|
WHERE schemaname = 'public' AND indexname NOT LIKE '%_pkey'
|
|
1015
1193
|
ORDER BY tablename, indexname
|
|
@@ -1050,6 +1228,8 @@ function diffColumns(sourceCols, targetCols, commonTables) {
|
|
|
1050
1228
|
const missingColumns = [];
|
|
1051
1229
|
const extraColumns = [];
|
|
1052
1230
|
const typeDiffs = [];
|
|
1231
|
+
const nullableDiffs = [];
|
|
1232
|
+
const defaultDiffs = [];
|
|
1053
1233
|
for (const [colName, srcInfo] of srcMap) {
|
|
1054
1234
|
if (!tgtMap.has(colName)) {
|
|
1055
1235
|
missingColumns.push(srcInfo);
|
|
@@ -1058,6 +1238,12 @@ function diffColumns(sourceCols, targetCols, commonTables) {
|
|
|
1058
1238
|
if (srcInfo.type !== tgtInfo.type) {
|
|
1059
1239
|
typeDiffs.push({ column: colName, sourceType: srcInfo.type, targetType: tgtInfo.type });
|
|
1060
1240
|
}
|
|
1241
|
+
if (srcInfo.nullable !== tgtInfo.nullable) {
|
|
1242
|
+
nullableDiffs.push({ column: colName, sourceNullable: srcInfo.nullable, targetNullable: tgtInfo.nullable });
|
|
1243
|
+
}
|
|
1244
|
+
if ((srcInfo.default ?? null) !== (tgtInfo.default ?? null)) {
|
|
1245
|
+
defaultDiffs.push({ column: colName, sourceDefault: srcInfo.default ?? null, targetDefault: tgtInfo.default ?? null });
|
|
1246
|
+
}
|
|
1061
1247
|
}
|
|
1062
1248
|
}
|
|
1063
1249
|
for (const [colName, tgtInfo] of tgtMap) {
|
|
@@ -1065,8 +1251,8 @@ function diffColumns(sourceCols, targetCols, commonTables) {
|
|
|
1065
1251
|
extraColumns.push(tgtInfo);
|
|
1066
1252
|
}
|
|
1067
1253
|
}
|
|
1068
|
-
if (missingColumns.length > 0 || extraColumns.length > 0 || typeDiffs.length > 0) {
|
|
1069
|
-
diffs.push({ table, missingColumns, extraColumns, typeDiffs });
|
|
1254
|
+
if (missingColumns.length > 0 || extraColumns.length > 0 || typeDiffs.length > 0 || nullableDiffs.length > 0 || defaultDiffs.length > 0) {
|
|
1255
|
+
diffs.push({ table, missingColumns, extraColumns, typeDiffs, nullableDiffs, defaultDiffs });
|
|
1070
1256
|
}
|
|
1071
1257
|
}
|
|
1072
1258
|
return diffs;
|
|
@@ -1074,8 +1260,8 @@ function diffColumns(sourceCols, targetCols, commonTables) {
|
|
|
1074
1260
|
function groupIndexesByTable(indexes) {
|
|
1075
1261
|
const map = /* @__PURE__ */ new Map();
|
|
1076
1262
|
for (const idx of indexes) {
|
|
1077
|
-
if (!map.has(idx.tablename)) map.set(idx.tablename, /* @__PURE__ */ new
|
|
1078
|
-
map.get(idx.tablename).
|
|
1263
|
+
if (!map.has(idx.tablename)) map.set(idx.tablename, /* @__PURE__ */ new Map());
|
|
1264
|
+
map.get(idx.tablename).set(idx.indexname, idx.indexdef);
|
|
1079
1265
|
}
|
|
1080
1266
|
return map;
|
|
1081
1267
|
}
|
|
@@ -1089,12 +1275,21 @@ function diffIndexes(sourceIdxs, targetIdxs, commonTables) {
|
|
|
1089
1275
|
]);
|
|
1090
1276
|
for (const table of allTables) {
|
|
1091
1277
|
if (!commonTables.includes(table)) continue;
|
|
1092
|
-
const
|
|
1093
|
-
const
|
|
1094
|
-
const missingIndexes = [...
|
|
1095
|
-
const extraIndexes = [...
|
|
1096
|
-
|
|
1097
|
-
|
|
1278
|
+
const srcMap = srcByTable.get(table) ?? /* @__PURE__ */ new Map();
|
|
1279
|
+
const tgtMap = tgtByTable.get(table) ?? /* @__PURE__ */ new Map();
|
|
1280
|
+
const missingIndexes = [...srcMap.keys()].filter((i) => !tgtMap.has(i));
|
|
1281
|
+
const extraIndexes = [...tgtMap.keys()].filter((i) => !srcMap.has(i));
|
|
1282
|
+
const modifiedIndexes = [];
|
|
1283
|
+
for (const [name, srcDef] of srcMap) {
|
|
1284
|
+
if (tgtMap.has(name)) {
|
|
1285
|
+
const tgtDef = tgtMap.get(name);
|
|
1286
|
+
if (srcDef !== tgtDef) {
|
|
1287
|
+
modifiedIndexes.push({ name, sourceDef: srcDef, targetDef: tgtDef });
|
|
1288
|
+
}
|
|
1289
|
+
}
|
|
1290
|
+
}
|
|
1291
|
+
if (missingIndexes.length > 0 || extraIndexes.length > 0 || modifiedIndexes.length > 0) {
|
|
1292
|
+
diffs.push({ table, missingIndexes, extraIndexes, modifiedIndexes });
|
|
1098
1293
|
}
|
|
1099
1294
|
}
|
|
1100
1295
|
return diffs;
|
|
@@ -1102,11 +1297,13 @@ function diffIndexes(sourceIdxs, targetIdxs, commonTables) {
|
|
|
1102
1297
|
function countSchemaDrifts(schema) {
|
|
1103
1298
|
let n = schema.missingTables.length + schema.extraTables.length;
|
|
1104
1299
|
for (const cd of schema.columnDiffs) {
|
|
1105
|
-
n += cd.missingColumns.length + cd.extraColumns.length + cd.typeDiffs.length;
|
|
1300
|
+
n += cd.missingColumns.length + cd.extraColumns.length + cd.typeDiffs.length + cd.nullableDiffs.length + cd.defaultDiffs.length;
|
|
1106
1301
|
}
|
|
1107
1302
|
for (const id of schema.indexDiffs) {
|
|
1108
|
-
n += id.missingIndexes.length + id.extraIndexes.length;
|
|
1303
|
+
n += id.missingIndexes.length + id.extraIndexes.length + id.modifiedIndexes.length;
|
|
1109
1304
|
}
|
|
1305
|
+
n += (schema.constraintDiffs ?? []).length;
|
|
1306
|
+
n += (schema.enumDiffs ?? []).length;
|
|
1110
1307
|
return n;
|
|
1111
1308
|
}
|
|
1112
1309
|
async function diffEnvironments(sourceConn, targetConn, options) {
|
|
@@ -1119,22 +1316,46 @@ async function diffEnvironments(sourceConn, targetConn, options) {
|
|
|
1119
1316
|
sourceCols,
|
|
1120
1317
|
targetCols,
|
|
1121
1318
|
sourceIdxs,
|
|
1122
|
-
targetIdxs
|
|
1319
|
+
targetIdxs,
|
|
1320
|
+
sourceSnap,
|
|
1321
|
+
targetSnap
|
|
1123
1322
|
] = await Promise.all([
|
|
1124
1323
|
fetchTables(sourcePool),
|
|
1125
1324
|
fetchTables(targetPool),
|
|
1126
1325
|
fetchColumns(sourcePool),
|
|
1127
1326
|
fetchColumns(targetPool),
|
|
1128
1327
|
fetchIndexes(sourcePool),
|
|
1129
|
-
fetchIndexes(targetPool)
|
|
1328
|
+
fetchIndexes(targetPool),
|
|
1329
|
+
buildLiveSnapshot(sourcePool).catch(() => null),
|
|
1330
|
+
buildLiveSnapshot(targetPool).catch(() => null)
|
|
1130
1331
|
]);
|
|
1131
1332
|
const { missingTables, extraTables } = diffTables(sourceTables, targetTables);
|
|
1132
|
-
const sourceSet = new Set(sourceTables);
|
|
1133
1333
|
const targetSet = new Set(targetTables);
|
|
1134
1334
|
const commonTables = sourceTables.filter((t) => targetSet.has(t));
|
|
1135
1335
|
const columnDiffs = diffColumns(sourceCols, targetCols, commonTables);
|
|
1136
1336
|
const indexDiffs = diffIndexes(sourceIdxs, targetIdxs, commonTables);
|
|
1137
|
-
const
|
|
1337
|
+
const constraintDiffs = [];
|
|
1338
|
+
const enumDiffs = [];
|
|
1339
|
+
if (sourceSnap && targetSnap) {
|
|
1340
|
+
const snapChanges = diffSnapshots2(sourceSnap, targetSnap);
|
|
1341
|
+
for (const c of snapChanges) {
|
|
1342
|
+
if (c.object_type === "constraint") {
|
|
1343
|
+
constraintDiffs.push({
|
|
1344
|
+
table: c.table_name ?? null,
|
|
1345
|
+
type: c.change_type === "added" ? "extra" : c.change_type === "removed" ? "missing" : "modified",
|
|
1346
|
+
name: c.detail.split(" ")[1] ?? c.detail,
|
|
1347
|
+
detail: c.detail
|
|
1348
|
+
});
|
|
1349
|
+
} else if (c.object_type === "enum") {
|
|
1350
|
+
enumDiffs.push({
|
|
1351
|
+
type: c.change_type === "added" ? "extra" : c.change_type === "removed" ? "missing" : "modified",
|
|
1352
|
+
name: c.detail.split(" ")[1] ?? c.detail,
|
|
1353
|
+
detail: c.detail
|
|
1354
|
+
});
|
|
1355
|
+
}
|
|
1356
|
+
}
|
|
1357
|
+
}
|
|
1358
|
+
const schema = { missingTables, extraTables, columnDiffs, indexDiffs, constraintDiffs, enumDiffs };
|
|
1138
1359
|
const schemaDrifts = countSchemaDrifts(schema);
|
|
1139
1360
|
let health;
|
|
1140
1361
|
if (options?.includeHealth) {
|
|
@@ -1276,12 +1497,23 @@ async function analyzeExplainPlan(explainJson, pool2) {
|
|
|
1276
1497
|
if (pool2) {
|
|
1277
1498
|
existingIndexCols = await getExistingIndexColumns(pool2, scan.table);
|
|
1278
1499
|
}
|
|
1279
|
-
|
|
1280
|
-
|
|
1281
|
-
|
|
1282
|
-
|
|
1283
|
-
|
|
1284
|
-
|
|
1500
|
+
const uncoveredCols = cols.filter(
|
|
1501
|
+
(col) => !existingIndexCols.some((idxCols) => idxCols.length > 0 && idxCols[0] === col)
|
|
1502
|
+
);
|
|
1503
|
+
if (uncoveredCols.length === 0) continue;
|
|
1504
|
+
const benefit = rateBenefit(scan.rowCount);
|
|
1505
|
+
if (uncoveredCols.length >= 2) {
|
|
1506
|
+
const idxName = `idx_${scan.table}_${uncoveredCols.join("_")}`;
|
|
1507
|
+
const sql = `CREATE INDEX CONCURRENTLY ${idxName} ON ${scan.table} (${uncoveredCols.join(", ")})`;
|
|
1508
|
+
result.missingIndexes.push({
|
|
1509
|
+
table: scan.table,
|
|
1510
|
+
columns: uncoveredCols,
|
|
1511
|
+
reason: `Seq Scan with multi-column filter (${uncoveredCols.join(", ")}) on ${fmtRows(scan.rowCount)} rows \u2014 composite index preferred`,
|
|
1512
|
+
sql,
|
|
1513
|
+
estimatedBenefit: benefit
|
|
1514
|
+
});
|
|
1515
|
+
} else {
|
|
1516
|
+
const col = uncoveredCols[0];
|
|
1285
1517
|
const idxName = `idx_${scan.table}_${col}`;
|
|
1286
1518
|
const sql = `CREATE INDEX CONCURRENTLY ${idxName} ON ${scan.table} (${col})`;
|
|
1287
1519
|
result.missingIndexes.push({
|
|
@@ -1471,6 +1703,83 @@ function staticCheck(sql) {
|
|
|
1471
1703
|
lineNumber: findLineNumber(sql, m.index)
|
|
1472
1704
|
});
|
|
1473
1705
|
}
|
|
1706
|
+
const alterTypeRe = /\bALTER\s+TABLE\s+(?:IF\s+EXISTS\s+)?([\w."]+)\s+ALTER\s+(?:COLUMN\s+)?[\w"]+\s+TYPE\b/gi;
|
|
1707
|
+
while ((m = alterTypeRe.exec(sql)) !== null) {
|
|
1708
|
+
const table = bareTable(m[1]);
|
|
1709
|
+
issues.push({
|
|
1710
|
+
severity: "warning",
|
|
1711
|
+
code: "ALTER_COLUMN_TYPE",
|
|
1712
|
+
message: "ALTER COLUMN TYPE rewrites the entire table and acquires an exclusive lock.",
|
|
1713
|
+
suggestion: "Consider using a new column + backfill + rename strategy to avoid downtime.",
|
|
1714
|
+
lineNumber: findLineNumber(sql, m.index),
|
|
1715
|
+
tableName: table
|
|
1716
|
+
});
|
|
1717
|
+
}
|
|
1718
|
+
const dropColRe = /\bALTER\s+TABLE\s+(?:IF\s+EXISTS\s+)?([\w."]+)\s+DROP\s+(?:COLUMN\s+)(?:IF\s+EXISTS\s+)?[\w"]+\b/gi;
|
|
1719
|
+
while ((m = dropColRe.exec(sql)) !== null) {
|
|
1720
|
+
const table = bareTable(m[1]);
|
|
1721
|
+
issues.push({
|
|
1722
|
+
severity: "info",
|
|
1723
|
+
code: "DROP_COLUMN",
|
|
1724
|
+
message: "DROP COLUMN is safe in PostgreSQL (no table rewrite), but may break application code referencing that column.",
|
|
1725
|
+
suggestion: "Ensure no application code references this column before dropping it.",
|
|
1726
|
+
lineNumber: findLineNumber(sql, m.index),
|
|
1727
|
+
tableName: table
|
|
1728
|
+
});
|
|
1729
|
+
}
|
|
1730
|
+
const renameTableRe = /ALTER\s+TABLE\s+(?:IF\s+EXISTS\s+)?(\w+)\s+RENAME\s+TO\s+(\w+)/gi;
|
|
1731
|
+
while ((m = renameTableRe.exec(sql)) !== null) {
|
|
1732
|
+
const oldName = m[1];
|
|
1733
|
+
const newName = m[2];
|
|
1734
|
+
issues.push({
|
|
1735
|
+
severity: "warning",
|
|
1736
|
+
code: "RENAME_TABLE",
|
|
1737
|
+
message: `Renaming table "${oldName}" to "${newName}" breaks application code referencing the old name`,
|
|
1738
|
+
suggestion: "Deploy application code that handles both names before renaming, or use a view with the old name after renaming.",
|
|
1739
|
+
lineNumber: findLineNumber(sql, m.index),
|
|
1740
|
+
tableName: oldName
|
|
1741
|
+
});
|
|
1742
|
+
}
|
|
1743
|
+
const renameColumnRe = /ALTER\s+TABLE\s+(?:IF\s+EXISTS\s+)?(\w+)\s+RENAME\s+COLUMN\s+(\w+)\s+TO\s+(\w+)/gi;
|
|
1744
|
+
while ((m = renameColumnRe.exec(sql)) !== null) {
|
|
1745
|
+
const table = m[1];
|
|
1746
|
+
const oldCol = m[2];
|
|
1747
|
+
const newCol = m[3];
|
|
1748
|
+
issues.push({
|
|
1749
|
+
severity: "warning",
|
|
1750
|
+
code: "RENAME_COLUMN",
|
|
1751
|
+
message: `Renaming column "${oldCol}" to "${newCol}" on table "${table}" breaks application code referencing the old column name`,
|
|
1752
|
+
suggestion: "Add new column, backfill data, update application to use new column, then drop old column (expand/contract pattern).",
|
|
1753
|
+
lineNumber: findLineNumber(sql, m.index),
|
|
1754
|
+
tableName: table
|
|
1755
|
+
});
|
|
1756
|
+
}
|
|
1757
|
+
const addConRe = /\bALTER\s+TABLE\s+(?:IF\s+EXISTS\s+)?([\w."]+)\s+ADD\s+CONSTRAINT\b[^;]*(;|$)/gi;
|
|
1758
|
+
while ((m = addConRe.exec(sql)) !== null) {
|
|
1759
|
+
const fragment = m[0];
|
|
1760
|
+
const table = bareTable(m[1]);
|
|
1761
|
+
const fragUpper = fragment.toUpperCase();
|
|
1762
|
+
if (!/\bNOT\s+VALID\b/.test(fragUpper)) {
|
|
1763
|
+
issues.push({
|
|
1764
|
+
severity: "warning",
|
|
1765
|
+
code: "ADD_CONSTRAINT_SCANS_TABLE",
|
|
1766
|
+
message: "ADD CONSTRAINT validates all existing rows and holds an exclusive lock during the scan.",
|
|
1767
|
+
suggestion: "Use ADD CONSTRAINT ... NOT VALID to skip validation, then VALIDATE CONSTRAINT in a separate transaction.",
|
|
1768
|
+
lineNumber: findLineNumber(sql, m.index),
|
|
1769
|
+
tableName: table
|
|
1770
|
+
});
|
|
1771
|
+
}
|
|
1772
|
+
}
|
|
1773
|
+
const hasTransaction = /\bBEGIN\b/i.test(sql) || /\bSTART\s+TRANSACTION\b/i.test(sql);
|
|
1774
|
+
const hasConcurrently = /\bCREATE\s+(?:UNIQUE\s+)?INDEX\s+CONCURRENTLY\b/i.test(sql);
|
|
1775
|
+
if (hasTransaction && hasConcurrently) {
|
|
1776
|
+
issues.push({
|
|
1777
|
+
severity: "error",
|
|
1778
|
+
code: "CONCURRENTLY_IN_TRANSACTION",
|
|
1779
|
+
message: "CREATE INDEX CONCURRENTLY cannot run inside a transaction block. It will fail at runtime.",
|
|
1780
|
+
suggestion: "Remove the BEGIN/COMMIT wrapper, or use a migration tool that runs CONCURRENTLY outside transactions."
|
|
1781
|
+
});
|
|
1782
|
+
}
|
|
1474
1783
|
const truncRe = /\bTRUNCATE\b/gi;
|
|
1475
1784
|
while ((m = truncRe.exec(sql)) !== null) {
|
|
1476
1785
|
issues.push({
|