@indiekitai/pg-toolkit 0.1.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/README.md +128 -0
- package/dist/analyze.js +168 -0
- package/dist/chunk-K3NQKI34.js +10 -0
- package/dist/cli.js +270 -0
- package/dist/index.cjs +220 -0
- package/dist/index.d.cts +31 -0
- package/dist/index.d.ts +31 -0
- package/dist/index.js +178 -0
- package/dist/mcp.js +13874 -0
- package/package.json +59 -0
package/dist/index.cjs
ADDED
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __create = Object.create;
|
|
3
|
+
var __defProp = Object.defineProperty;
|
|
4
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
5
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
6
|
+
var __getProtoOf = Object.getPrototypeOf;
|
|
7
|
+
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
8
|
+
var __export = (target, all) => {
|
|
9
|
+
for (var name in all)
|
|
10
|
+
__defProp(target, name, { get: all[name], enumerable: true });
|
|
11
|
+
};
|
|
12
|
+
var __copyProps = (to, from, except, desc) => {
|
|
13
|
+
if (from && typeof from === "object" || typeof from === "function") {
|
|
14
|
+
for (let key of __getOwnPropNames(from))
|
|
15
|
+
if (!__hasOwnProp.call(to, key) && key !== except)
|
|
16
|
+
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
|
17
|
+
}
|
|
18
|
+
return to;
|
|
19
|
+
};
|
|
20
|
+
var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
|
|
21
|
+
// If the importer is in node compatibility mode or this is not an ESM
|
|
22
|
+
// file that has been converted to a CommonJS file using a Babel-
|
|
23
|
+
// compatible transform (i.e. "__esModule" has not been set), then set
|
|
24
|
+
// "default" to the CommonJS "module.exports" for node compatibility.
|
|
25
|
+
isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
|
|
26
|
+
mod
|
|
27
|
+
));
|
|
28
|
+
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
|
|
29
|
+
|
|
30
|
+
// src/index.ts
|
|
31
|
+
var index_exports = {};
|
|
32
|
+
__export(index_exports, {
|
|
33
|
+
PgInspector: () => import_pg_inspect.PgInspector,
|
|
34
|
+
PgMonitor: () => import_pg_top.PgMonitor,
|
|
35
|
+
analyze: () => analyze,
|
|
36
|
+
computeDiff: () => import_pg_diff.computeDiff,
|
|
37
|
+
diff: () => import_pg_diff.diff,
|
|
38
|
+
formatAnalyzeResult: () => formatAnalyzeResult,
|
|
39
|
+
inspect: () => import_pg_inspect.inspect,
|
|
40
|
+
inspectSchema: () => import_pg_diff.inspectSchema
|
|
41
|
+
});
|
|
42
|
+
module.exports = __toCommonJS(index_exports);
|
|
43
|
+
var import_pg_inspect = require("@indiekitai/pg-inspect");
|
|
44
|
+
var import_pg_diff = require("@indiekitai/pg-diff");
|
|
45
|
+
|
|
46
|
+
// src/analyze.ts
|
|
47
|
+
var EAV_ENTITY_PATTERNS = /^(entity|item|record|submission|object|resource|parent)[_]?id$/i;
|
|
48
|
+
var EAV_KEY_PATTERNS = /^(key|name|attribute|field|property|type|kind|label)[_]?(name|key|id)?$/i;
|
|
49
|
+
var EAV_VALUE_PATTERNS = /^(value|data|content|payload|text)$/i;
|
|
50
|
+
function detectEav(tableName, columns) {
|
|
51
|
+
const colNames = columns.map((c) => c.columnName);
|
|
52
|
+
const entityCol = colNames.find((c) => EAV_ENTITY_PATTERNS.test(c));
|
|
53
|
+
const keyCol = colNames.find((c) => EAV_KEY_PATTERNS.test(c));
|
|
54
|
+
const valueCol = colNames.find((c) => EAV_VALUE_PATTERNS.test(c));
|
|
55
|
+
if (entityCol && keyCol && valueCol) {
|
|
56
|
+
return {
|
|
57
|
+
type: "eav_pattern",
|
|
58
|
+
severity: "high",
|
|
59
|
+
table: tableName,
|
|
60
|
+
description: `Table "${tableName}" looks like an EAV table (entity=${entityCol}, key=${keyCol}, value=${valueCol}). Querying parent + this table typically causes N+1.`,
|
|
61
|
+
suggestion: `Use CTE + JSON_AGG to aggregate rows by "${entityCol}" in a single query.`,
|
|
62
|
+
sql: `WITH aggregated AS (
|
|
63
|
+
SELECT
|
|
64
|
+
"${entityCol}",
|
|
65
|
+
JSON_AGG(JSON_BUILD_OBJECT('${keyCol}', "${keyCol}", '${valueCol}', "${valueCol}")) AS attrs
|
|
66
|
+
FROM "${tableName}"
|
|
67
|
+
GROUP BY "${entityCol}"
|
|
68
|
+
)
|
|
69
|
+
SELECT p.*, COALESCE(a.attrs, '[]'::json) AS attrs
|
|
70
|
+
FROM <parent_table> p
|
|
71
|
+
LEFT JOIN aggregated a ON a."${entityCol}" = p.id;`
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
return null;
|
|
75
|
+
}
|
|
76
|
+
function detectNPlus1FromFks(fks) {
|
|
77
|
+
const parentToChildren = /* @__PURE__ */ new Map();
|
|
78
|
+
for (const fk of fks) {
|
|
79
|
+
const list = parentToChildren.get(fk.parentTable) || [];
|
|
80
|
+
list.push(fk);
|
|
81
|
+
parentToChildren.set(fk.parentTable, list);
|
|
82
|
+
}
|
|
83
|
+
const warnings = [];
|
|
84
|
+
for (const [parentTable, children] of parentToChildren) {
|
|
85
|
+
for (const fk of children) {
|
|
86
|
+
if (fk.childTable === parentTable) continue;
|
|
87
|
+
warnings.push({
|
|
88
|
+
type: "n_plus_1_risk",
|
|
89
|
+
severity: "medium",
|
|
90
|
+
table: fk.childTable,
|
|
91
|
+
parentTable,
|
|
92
|
+
description: `"${fk.childTable}".${fk.childColumn} \u2192 "${parentTable}".${fk.parentColumn}. Loading ${parentTable} list + ${fk.childTable} details per row = N+1.`,
|
|
93
|
+
suggestion: `Use CTE + JSON_AGG to pre-aggregate "${fk.childTable}" rows per "${fk.childColumn}".`,
|
|
94
|
+
sql: `WITH child_agg AS (
|
|
95
|
+
SELECT "${fk.childColumn}", JSON_AGG(t.*) AS children
|
|
96
|
+
FROM "${fk.childTable}" t
|
|
97
|
+
GROUP BY "${fk.childColumn}"
|
|
98
|
+
)
|
|
99
|
+
SELECT p.*, COALESCE(c.children, '[]'::json) AS ${fk.childTable}
|
|
100
|
+
FROM "${parentTable}" p
|
|
101
|
+
LEFT JOIN child_agg c ON c."${fk.childColumn}" = p."${fk.parentColumn}";`
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
const uniqueChildren = new Set(children.map((c) => c.childTable));
|
|
105
|
+
if (uniqueChildren.size > 2) {
|
|
106
|
+
warnings.push({
|
|
107
|
+
type: "multi_fk_parent",
|
|
108
|
+
severity: "low",
|
|
109
|
+
table: parentTable,
|
|
110
|
+
description: `"${parentTable}" has ${uniqueChildren.size} child tables referencing it. Loading all children per row compounds N+1.`,
|
|
111
|
+
suggestion: `Consider multiple CTEs in a single query to fetch all child data at once.`
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
return warnings;
|
|
116
|
+
}
|
|
117
|
+
async function analyze(connectionString) {
|
|
118
|
+
const pg = await import("pg");
|
|
119
|
+
const Pool = pg.default?.Pool || pg.Pool;
|
|
120
|
+
const pool = new Pool({ connectionString });
|
|
121
|
+
try {
|
|
122
|
+
const dbResult = await pool.query("SELECT current_database() AS db");
|
|
123
|
+
const database = dbResult.rows[0].db;
|
|
124
|
+
const colResult = await pool.query(`
|
|
125
|
+
SELECT table_name, column_name, data_type, is_nullable = 'YES' AS is_nullable
|
|
126
|
+
FROM information_schema.columns
|
|
127
|
+
WHERE table_schema = 'public'
|
|
128
|
+
ORDER BY table_name, ordinal_position
|
|
129
|
+
`);
|
|
130
|
+
const columnsByTable = /* @__PURE__ */ new Map();
|
|
131
|
+
for (const row of colResult.rows) {
|
|
132
|
+
const list = columnsByTable.get(row.table_name) || [];
|
|
133
|
+
list.push({
|
|
134
|
+
tableName: row.table_name,
|
|
135
|
+
columnName: row.column_name,
|
|
136
|
+
dataType: row.data_type,
|
|
137
|
+
isNullable: row.is_nullable
|
|
138
|
+
});
|
|
139
|
+
columnsByTable.set(row.table_name, list);
|
|
140
|
+
}
|
|
141
|
+
const fkResult = await pool.query(`
|
|
142
|
+
SELECT
|
|
143
|
+
tc.constraint_name,
|
|
144
|
+
tc.table_name AS child_table,
|
|
145
|
+
kcu.column_name AS child_column,
|
|
146
|
+
ccu.table_name AS parent_table,
|
|
147
|
+
ccu.column_name AS parent_column
|
|
148
|
+
FROM information_schema.table_constraints tc
|
|
149
|
+
JOIN information_schema.key_column_usage kcu
|
|
150
|
+
ON tc.constraint_name = kcu.constraint_name AND tc.table_schema = kcu.table_schema
|
|
151
|
+
JOIN information_schema.constraint_column_usage ccu
|
|
152
|
+
ON tc.constraint_name = ccu.constraint_name AND tc.table_schema = ccu.table_schema
|
|
153
|
+
WHERE tc.constraint_type = 'FOREIGN KEY' AND tc.table_schema = 'public'
|
|
154
|
+
`);
|
|
155
|
+
const fks = fkResult.rows.map((r) => ({
|
|
156
|
+
constraintName: r.constraint_name,
|
|
157
|
+
childTable: r.child_table,
|
|
158
|
+
childColumn: r.child_column,
|
|
159
|
+
parentTable: r.parent_table,
|
|
160
|
+
parentColumn: r.parent_column
|
|
161
|
+
}));
|
|
162
|
+
const warnings = [];
|
|
163
|
+
for (const [tableName, columns] of columnsByTable) {
|
|
164
|
+
const eav = detectEav(tableName, columns);
|
|
165
|
+
if (eav) warnings.push(eav);
|
|
166
|
+
}
|
|
167
|
+
warnings.push(...detectNPlus1FromFks(fks));
|
|
168
|
+
const severityOrder = { high: 0, medium: 1, low: 2 };
|
|
169
|
+
warnings.sort((a, b) => severityOrder[a.severity] - severityOrder[b.severity]);
|
|
170
|
+
return {
|
|
171
|
+
database,
|
|
172
|
+
analyzedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
173
|
+
tablesScanned: columnsByTable.size,
|
|
174
|
+
warnings
|
|
175
|
+
};
|
|
176
|
+
} finally {
|
|
177
|
+
await pool.end();
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
function formatAnalyzeResult(result, json) {
|
|
181
|
+
if (json) return JSON.stringify(result, null, 2);
|
|
182
|
+
const lines = [];
|
|
183
|
+
lines.push(`\u{1F50D} N+1 Analysis for "${result.database}"`);
|
|
184
|
+
lines.push(` Scanned ${result.tablesScanned} tables at ${result.analyzedAt}`);
|
|
185
|
+
lines.push("");
|
|
186
|
+
if (result.warnings.length === 0) {
|
|
187
|
+
lines.push("\u2705 No N+1 patterns detected.");
|
|
188
|
+
return lines.join("\n");
|
|
189
|
+
}
|
|
190
|
+
lines.push(`\u26A0\uFE0F Found ${result.warnings.length} potential issue(s):
|
|
191
|
+
`);
|
|
192
|
+
const severityIcon = { high: "\u{1F534}", medium: "\u{1F7E1}", low: "\u{1F535}" };
|
|
193
|
+
for (const w of result.warnings) {
|
|
194
|
+
lines.push(`${severityIcon[w.severity]} [${w.severity.toUpperCase()}] ${w.type}`);
|
|
195
|
+
lines.push(` ${w.description}`);
|
|
196
|
+
lines.push(` \u{1F4A1} ${w.suggestion}`);
|
|
197
|
+
if (w.sql) {
|
|
198
|
+
lines.push(" \u{1F4DD} Suggested SQL:");
|
|
199
|
+
for (const sqlLine of w.sql.split("\n")) {
|
|
200
|
+
lines.push(` ${sqlLine}`);
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
lines.push("");
|
|
204
|
+
}
|
|
205
|
+
return lines.join("\n");
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// src/index.ts
|
|
209
|
+
var import_pg_top = require("@indiekitai/pg-top");
|
|
210
|
+
// Annotate the CommonJS export names for ESM import in node:
|
|
211
|
+
0 && (module.exports = {
|
|
212
|
+
PgInspector,
|
|
213
|
+
PgMonitor,
|
|
214
|
+
analyze,
|
|
215
|
+
computeDiff,
|
|
216
|
+
diff,
|
|
217
|
+
formatAnalyzeResult,
|
|
218
|
+
inspect,
|
|
219
|
+
inspectSchema
|
|
220
|
+
});
|
package/dist/index.d.cts
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
export { ConnectionConfig, PgInspector, inspect } from '@indiekitai/pg-inspect';
|
|
2
|
+
export { DiffOptions, DiffResult, computeDiff, diff, inspectSchema } from '@indiekitai/pg-diff';
|
|
3
|
+
export { Activity, DbStats, MonitorOptions, PgMonitor } from '@indiekitai/pg-top';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* pg-toolkit analyze — Detect N+1 query patterns and suggest CTE + JSON_AGG optimizations.
|
|
7
|
+
*
|
|
8
|
+
* Scans database schema for:
|
|
9
|
+
* - Many-to-one relationships (foreign keys)
|
|
10
|
+
* - EAV-pattern tables (entity_id + key/name + value columns)
|
|
11
|
+
* - Tables with multiple FKs pointing to the same parent
|
|
12
|
+
*/
|
|
13
|
+
interface AnalyzeWarning {
|
|
14
|
+
type: 'n_plus_1_risk' | 'eav_pattern' | 'multi_fk_parent';
|
|
15
|
+
severity: 'high' | 'medium' | 'low';
|
|
16
|
+
table: string;
|
|
17
|
+
parentTable?: string;
|
|
18
|
+
description: string;
|
|
19
|
+
suggestion: string;
|
|
20
|
+
sql?: string;
|
|
21
|
+
}
|
|
22
|
+
interface AnalyzeResult {
|
|
23
|
+
database: string;
|
|
24
|
+
analyzedAt: string;
|
|
25
|
+
tablesScanned: number;
|
|
26
|
+
warnings: AnalyzeWarning[];
|
|
27
|
+
}
|
|
28
|
+
declare function analyze(connectionString: string): Promise<AnalyzeResult>;
|
|
29
|
+
declare function formatAnalyzeResult(result: AnalyzeResult, json: boolean): string;
|
|
30
|
+
|
|
31
|
+
export { type AnalyzeResult, type AnalyzeWarning, analyze, formatAnalyzeResult };
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
export { ConnectionConfig, PgInspector, inspect } from '@indiekitai/pg-inspect';
|
|
2
|
+
export { DiffOptions, DiffResult, computeDiff, diff, inspectSchema } from '@indiekitai/pg-diff';
|
|
3
|
+
export { Activity, DbStats, MonitorOptions, PgMonitor } from '@indiekitai/pg-top';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* pg-toolkit analyze — Detect N+1 query patterns and suggest CTE + JSON_AGG optimizations.
|
|
7
|
+
*
|
|
8
|
+
* Scans database schema for:
|
|
9
|
+
* - Many-to-one relationships (foreign keys)
|
|
10
|
+
* - EAV-pattern tables (entity_id + key/name + value columns)
|
|
11
|
+
* - Tables with multiple FKs pointing to the same parent
|
|
12
|
+
*/
|
|
13
|
+
interface AnalyzeWarning {
|
|
14
|
+
type: 'n_plus_1_risk' | 'eav_pattern' | 'multi_fk_parent';
|
|
15
|
+
severity: 'high' | 'medium' | 'low';
|
|
16
|
+
table: string;
|
|
17
|
+
parentTable?: string;
|
|
18
|
+
description: string;
|
|
19
|
+
suggestion: string;
|
|
20
|
+
sql?: string;
|
|
21
|
+
}
|
|
22
|
+
interface AnalyzeResult {
|
|
23
|
+
database: string;
|
|
24
|
+
analyzedAt: string;
|
|
25
|
+
tablesScanned: number;
|
|
26
|
+
warnings: AnalyzeWarning[];
|
|
27
|
+
}
|
|
28
|
+
declare function analyze(connectionString: string): Promise<AnalyzeResult>;
|
|
29
|
+
declare function formatAnalyzeResult(result: AnalyzeResult, json: boolean): string;
|
|
30
|
+
|
|
31
|
+
export { type AnalyzeResult, type AnalyzeWarning, analyze, formatAnalyzeResult };
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
// src/index.ts
|
|
2
|
+
import { PgInspector, inspect } from "@indiekitai/pg-inspect";
|
|
3
|
+
import { inspectSchema, computeDiff, diff } from "@indiekitai/pg-diff";
|
|
4
|
+
|
|
5
|
+
// src/analyze.ts
|
|
6
|
+
var EAV_ENTITY_PATTERNS = /^(entity|item|record|submission|object|resource|parent)[_]?id$/i;
|
|
7
|
+
var EAV_KEY_PATTERNS = /^(key|name|attribute|field|property|type|kind|label)[_]?(name|key|id)?$/i;
|
|
8
|
+
var EAV_VALUE_PATTERNS = /^(value|data|content|payload|text)$/i;
|
|
9
|
+
function detectEav(tableName, columns) {
|
|
10
|
+
const colNames = columns.map((c) => c.columnName);
|
|
11
|
+
const entityCol = colNames.find((c) => EAV_ENTITY_PATTERNS.test(c));
|
|
12
|
+
const keyCol = colNames.find((c) => EAV_KEY_PATTERNS.test(c));
|
|
13
|
+
const valueCol = colNames.find((c) => EAV_VALUE_PATTERNS.test(c));
|
|
14
|
+
if (entityCol && keyCol && valueCol) {
|
|
15
|
+
return {
|
|
16
|
+
type: "eav_pattern",
|
|
17
|
+
severity: "high",
|
|
18
|
+
table: tableName,
|
|
19
|
+
description: `Table "${tableName}" looks like an EAV table (entity=${entityCol}, key=${keyCol}, value=${valueCol}). Querying parent + this table typically causes N+1.`,
|
|
20
|
+
suggestion: `Use CTE + JSON_AGG to aggregate rows by "${entityCol}" in a single query.`,
|
|
21
|
+
sql: `WITH aggregated AS (
|
|
22
|
+
SELECT
|
|
23
|
+
"${entityCol}",
|
|
24
|
+
JSON_AGG(JSON_BUILD_OBJECT('${keyCol}', "${keyCol}", '${valueCol}', "${valueCol}")) AS attrs
|
|
25
|
+
FROM "${tableName}"
|
|
26
|
+
GROUP BY "${entityCol}"
|
|
27
|
+
)
|
|
28
|
+
SELECT p.*, COALESCE(a.attrs, '[]'::json) AS attrs
|
|
29
|
+
FROM <parent_table> p
|
|
30
|
+
LEFT JOIN aggregated a ON a."${entityCol}" = p.id;`
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
return null;
|
|
34
|
+
}
|
|
35
|
+
function detectNPlus1FromFks(fks) {
|
|
36
|
+
const parentToChildren = /* @__PURE__ */ new Map();
|
|
37
|
+
for (const fk of fks) {
|
|
38
|
+
const list = parentToChildren.get(fk.parentTable) || [];
|
|
39
|
+
list.push(fk);
|
|
40
|
+
parentToChildren.set(fk.parentTable, list);
|
|
41
|
+
}
|
|
42
|
+
const warnings = [];
|
|
43
|
+
for (const [parentTable, children] of parentToChildren) {
|
|
44
|
+
for (const fk of children) {
|
|
45
|
+
if (fk.childTable === parentTable) continue;
|
|
46
|
+
warnings.push({
|
|
47
|
+
type: "n_plus_1_risk",
|
|
48
|
+
severity: "medium",
|
|
49
|
+
table: fk.childTable,
|
|
50
|
+
parentTable,
|
|
51
|
+
description: `"${fk.childTable}".${fk.childColumn} \u2192 "${parentTable}".${fk.parentColumn}. Loading ${parentTable} list + ${fk.childTable} details per row = N+1.`,
|
|
52
|
+
suggestion: `Use CTE + JSON_AGG to pre-aggregate "${fk.childTable}" rows per "${fk.childColumn}".`,
|
|
53
|
+
sql: `WITH child_agg AS (
|
|
54
|
+
SELECT "${fk.childColumn}", JSON_AGG(t.*) AS children
|
|
55
|
+
FROM "${fk.childTable}" t
|
|
56
|
+
GROUP BY "${fk.childColumn}"
|
|
57
|
+
)
|
|
58
|
+
SELECT p.*, COALESCE(c.children, '[]'::json) AS ${fk.childTable}
|
|
59
|
+
FROM "${parentTable}" p
|
|
60
|
+
LEFT JOIN child_agg c ON c."${fk.childColumn}" = p."${fk.parentColumn}";`
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
const uniqueChildren = new Set(children.map((c) => c.childTable));
|
|
64
|
+
if (uniqueChildren.size > 2) {
|
|
65
|
+
warnings.push({
|
|
66
|
+
type: "multi_fk_parent",
|
|
67
|
+
severity: "low",
|
|
68
|
+
table: parentTable,
|
|
69
|
+
description: `"${parentTable}" has ${uniqueChildren.size} child tables referencing it. Loading all children per row compounds N+1.`,
|
|
70
|
+
suggestion: `Consider multiple CTEs in a single query to fetch all child data at once.`
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
return warnings;
|
|
75
|
+
}
|
|
76
|
+
async function analyze(connectionString) {
|
|
77
|
+
const pg = await import("pg");
|
|
78
|
+
const Pool = pg.default?.Pool || pg.Pool;
|
|
79
|
+
const pool = new Pool({ connectionString });
|
|
80
|
+
try {
|
|
81
|
+
const dbResult = await pool.query("SELECT current_database() AS db");
|
|
82
|
+
const database = dbResult.rows[0].db;
|
|
83
|
+
const colResult = await pool.query(`
|
|
84
|
+
SELECT table_name, column_name, data_type, is_nullable = 'YES' AS is_nullable
|
|
85
|
+
FROM information_schema.columns
|
|
86
|
+
WHERE table_schema = 'public'
|
|
87
|
+
ORDER BY table_name, ordinal_position
|
|
88
|
+
`);
|
|
89
|
+
const columnsByTable = /* @__PURE__ */ new Map();
|
|
90
|
+
for (const row of colResult.rows) {
|
|
91
|
+
const list = columnsByTable.get(row.table_name) || [];
|
|
92
|
+
list.push({
|
|
93
|
+
tableName: row.table_name,
|
|
94
|
+
columnName: row.column_name,
|
|
95
|
+
dataType: row.data_type,
|
|
96
|
+
isNullable: row.is_nullable
|
|
97
|
+
});
|
|
98
|
+
columnsByTable.set(row.table_name, list);
|
|
99
|
+
}
|
|
100
|
+
const fkResult = await pool.query(`
|
|
101
|
+
SELECT
|
|
102
|
+
tc.constraint_name,
|
|
103
|
+
tc.table_name AS child_table,
|
|
104
|
+
kcu.column_name AS child_column,
|
|
105
|
+
ccu.table_name AS parent_table,
|
|
106
|
+
ccu.column_name AS parent_column
|
|
107
|
+
FROM information_schema.table_constraints tc
|
|
108
|
+
JOIN information_schema.key_column_usage kcu
|
|
109
|
+
ON tc.constraint_name = kcu.constraint_name AND tc.table_schema = kcu.table_schema
|
|
110
|
+
JOIN information_schema.constraint_column_usage ccu
|
|
111
|
+
ON tc.constraint_name = ccu.constraint_name AND tc.table_schema = ccu.table_schema
|
|
112
|
+
WHERE tc.constraint_type = 'FOREIGN KEY' AND tc.table_schema = 'public'
|
|
113
|
+
`);
|
|
114
|
+
const fks = fkResult.rows.map((r) => ({
|
|
115
|
+
constraintName: r.constraint_name,
|
|
116
|
+
childTable: r.child_table,
|
|
117
|
+
childColumn: r.child_column,
|
|
118
|
+
parentTable: r.parent_table,
|
|
119
|
+
parentColumn: r.parent_column
|
|
120
|
+
}));
|
|
121
|
+
const warnings = [];
|
|
122
|
+
for (const [tableName, columns] of columnsByTable) {
|
|
123
|
+
const eav = detectEav(tableName, columns);
|
|
124
|
+
if (eav) warnings.push(eav);
|
|
125
|
+
}
|
|
126
|
+
warnings.push(...detectNPlus1FromFks(fks));
|
|
127
|
+
const severityOrder = { high: 0, medium: 1, low: 2 };
|
|
128
|
+
warnings.sort((a, b) => severityOrder[a.severity] - severityOrder[b.severity]);
|
|
129
|
+
return {
|
|
130
|
+
database,
|
|
131
|
+
analyzedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
132
|
+
tablesScanned: columnsByTable.size,
|
|
133
|
+
warnings
|
|
134
|
+
};
|
|
135
|
+
} finally {
|
|
136
|
+
await pool.end();
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
function formatAnalyzeResult(result, json) {
|
|
140
|
+
if (json) return JSON.stringify(result, null, 2);
|
|
141
|
+
const lines = [];
|
|
142
|
+
lines.push(`\u{1F50D} N+1 Analysis for "${result.database}"`);
|
|
143
|
+
lines.push(` Scanned ${result.tablesScanned} tables at ${result.analyzedAt}`);
|
|
144
|
+
lines.push("");
|
|
145
|
+
if (result.warnings.length === 0) {
|
|
146
|
+
lines.push("\u2705 No N+1 patterns detected.");
|
|
147
|
+
return lines.join("\n");
|
|
148
|
+
}
|
|
149
|
+
lines.push(`\u26A0\uFE0F Found ${result.warnings.length} potential issue(s):
|
|
150
|
+
`);
|
|
151
|
+
const severityIcon = { high: "\u{1F534}", medium: "\u{1F7E1}", low: "\u{1F535}" };
|
|
152
|
+
for (const w of result.warnings) {
|
|
153
|
+
lines.push(`${severityIcon[w.severity]} [${w.severity.toUpperCase()}] ${w.type}`);
|
|
154
|
+
lines.push(` ${w.description}`);
|
|
155
|
+
lines.push(` \u{1F4A1} ${w.suggestion}`);
|
|
156
|
+
if (w.sql) {
|
|
157
|
+
lines.push(" \u{1F4DD} Suggested SQL:");
|
|
158
|
+
for (const sqlLine of w.sql.split("\n")) {
|
|
159
|
+
lines.push(` ${sqlLine}`);
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
lines.push("");
|
|
163
|
+
}
|
|
164
|
+
return lines.join("\n");
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// src/index.ts
|
|
168
|
+
import { PgMonitor } from "@indiekitai/pg-top";
|
|
169
|
+
export {
|
|
170
|
+
PgInspector,
|
|
171
|
+
PgMonitor,
|
|
172
|
+
analyze,
|
|
173
|
+
computeDiff,
|
|
174
|
+
diff,
|
|
175
|
+
formatAnalyzeResult,
|
|
176
|
+
inspect,
|
|
177
|
+
inspectSchema
|
|
178
|
+
};
|