pglens 1.0.0 → 2.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/CHANGELOG.md +37 -7
- package/README.md +38 -145
- package/bin/pglens +143 -7
- package/client/app.js +1141 -79
- package/client/index.html +133 -35
- package/client/styles.css +980 -49
- package/package.json +1 -1
- package/src/db/connection.js +163 -27
- package/src/routes/api.js +288 -9
- package/src/server.js +72 -30
- package/pglens-1.0.0.tgz +0 -0
package/package.json
CHANGED
package/src/db/connection.js
CHANGED
|
@@ -1,13 +1,16 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Database Connection Pool Manager
|
|
3
3
|
*
|
|
4
|
-
* Manages PostgreSQL connection
|
|
4
|
+
* Manages PostgreSQL connection pools using postgres library.
|
|
5
5
|
* Provides connection pooling for efficient database access.
|
|
6
|
+
* Supports multiple simultaneous connections.
|
|
6
7
|
*/
|
|
7
8
|
|
|
8
9
|
const postgres = require('postgres');
|
|
10
|
+
const crypto = require('crypto');
|
|
9
11
|
|
|
10
|
-
|
|
12
|
+
// Map to store multiple connections: id -> { pool, name, connectionString }
|
|
13
|
+
const connections = new Map();
|
|
11
14
|
|
|
12
15
|
/**
|
|
13
16
|
* Create a wrapper that provides pg-compatible query interface.
|
|
@@ -25,49 +28,98 @@ function createPoolWrapper(sqlClient) {
|
|
|
25
28
|
};
|
|
26
29
|
}
|
|
27
30
|
|
|
31
|
+
/**
|
|
32
|
+
* Extract database name from connection string
|
|
33
|
+
* @param {string} connectionString
|
|
34
|
+
* @returns {string} Database name or 'Unknown'
|
|
35
|
+
*/
|
|
36
|
+
function getDatabaseName(connectionString) {
|
|
37
|
+
try {
|
|
38
|
+
const url = new URL(connectionString);
|
|
39
|
+
return url.pathname.replace(/^\//, '') || 'postgres';
|
|
40
|
+
} catch (e) {
|
|
41
|
+
return 'postgres';
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
28
45
|
/**
|
|
29
46
|
* Create a new connection pool.
|
|
30
|
-
* Closes existing pool if one exists.
|
|
31
47
|
* @param {string} connectionString - PostgreSQL connection string
|
|
32
48
|
* @param {string} sslMode - SSL mode: disable, require, prefer, verify-ca, verify-full
|
|
33
|
-
* @
|
|
49
|
+
* @param {string} [customName] - Optional custom name for the connection
|
|
50
|
+
* @returns {Promise<{id: string, name: string}>} The created connection info
|
|
34
51
|
*/
|
|
35
|
-
function createPool(connectionString, sslMode = 'prefer') {
|
|
36
|
-
if (sql) {
|
|
37
|
-
sql.end();
|
|
38
|
-
}
|
|
39
|
-
|
|
52
|
+
function createPool(connectionString, sslMode = 'prefer', customName = null) {
|
|
40
53
|
const sslConfig = getSslConfig(sslMode);
|
|
41
54
|
const poolConfig = {
|
|
42
55
|
max: 10,
|
|
43
56
|
idle_timeout: 30,
|
|
44
57
|
connect_timeout: 10,
|
|
58
|
+
timeout: 30, // Query timeout in seconds
|
|
45
59
|
};
|
|
46
60
|
|
|
47
61
|
if (sslConfig !== null) {
|
|
48
62
|
poolConfig.ssl = sslConfig;
|
|
49
63
|
}
|
|
50
64
|
|
|
51
|
-
sql = postgres(connectionString, poolConfig);
|
|
65
|
+
const sql = postgres(connectionString, poolConfig);
|
|
52
66
|
|
|
53
67
|
// Test the connection
|
|
54
68
|
return sql`SELECT NOW()`
|
|
55
69
|
.then(() => {
|
|
56
70
|
console.log('✓ Connected to PostgreSQL database');
|
|
57
|
-
|
|
71
|
+
|
|
72
|
+
// Check if this connection string already exists
|
|
73
|
+
for (const [existingId, existingConn] of connections.entries()) {
|
|
74
|
+
if (existingConn.connectionString === connectionString) {
|
|
75
|
+
// If a custom name is provided and different from existing, update it
|
|
76
|
+
if (customName && customName !== existingConn.name) {
|
|
77
|
+
existingConn.name = customName;
|
|
78
|
+
}
|
|
79
|
+
// Return the existing connection details
|
|
80
|
+
sql.end();
|
|
81
|
+
return { id: existingId, name: existingConn.name, reused: true };
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const id = crypto.randomUUID();
|
|
86
|
+
const name = customName || getDatabaseName(connectionString);
|
|
87
|
+
|
|
88
|
+
connections.set(id, {
|
|
89
|
+
pool: sql,
|
|
90
|
+
name,
|
|
91
|
+
connectionString,
|
|
92
|
+
sslMode
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
return { id, name };
|
|
58
96
|
})
|
|
59
97
|
.catch((err) => {
|
|
60
98
|
console.error('✗ Failed to connect to PostgreSQL database:', err.message);
|
|
61
99
|
const recommendation = getSslModeRecommendation(err, sslMode);
|
|
62
100
|
if (recommendation) {
|
|
63
|
-
console.error(`\n💡 SSL Mode Recommendation: Try using
|
|
64
|
-
console.error(` Current SSL mode: ${sslMode}`);
|
|
65
|
-
console.error(` Suggested command: Add --sslmode ${recommendation} to your command\n`);
|
|
101
|
+
console.error(`\n💡 SSL Mode Recommendation: Try using sslmode '${recommendation}'`);
|
|
66
102
|
}
|
|
67
103
|
throw err;
|
|
68
104
|
});
|
|
69
105
|
}
|
|
70
106
|
|
|
107
|
+
/**
|
|
108
|
+
* Check if a specific connection is active.
|
|
109
|
+
* @param {string} connectionId - Connection ID
|
|
110
|
+
* @returns {Promise<boolean>} True if connected
|
|
111
|
+
*/
|
|
112
|
+
function checkConnection(connectionId) {
|
|
113
|
+
const conn = connections.get(connectionId);
|
|
114
|
+
if (!conn) {
|
|
115
|
+
return Promise.resolve(false);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
return conn.pool`SELECT 1`
|
|
119
|
+
.then(() => true)
|
|
120
|
+
.catch(() => false);
|
|
121
|
+
}
|
|
122
|
+
|
|
71
123
|
/**
|
|
72
124
|
* Analyze connection error and recommend appropriate SSL mode.
|
|
73
125
|
* @param {Error} error - Connection error object
|
|
@@ -170,31 +222,115 @@ function getSslConfig(sslMode) {
|
|
|
170
222
|
}
|
|
171
223
|
|
|
172
224
|
/**
|
|
173
|
-
* Get
|
|
174
|
-
* @
|
|
175
|
-
* @
|
|
225
|
+
* Get a connection pool by ID.
|
|
226
|
+
* @param {string} connectionId - The connection ID
|
|
227
|
+
* @returns {object|null} The connection pool wrapper or null if not found
|
|
176
228
|
*/
|
|
177
|
-
function getPool() {
|
|
178
|
-
|
|
179
|
-
|
|
229
|
+
function getPool(connectionId) {
|
|
230
|
+
const conn = connections.get(connectionId);
|
|
231
|
+
if (!conn) {
|
|
232
|
+
return null;
|
|
180
233
|
}
|
|
181
|
-
return createPoolWrapper(
|
|
234
|
+
return createPoolWrapper(conn.pool);
|
|
182
235
|
}
|
|
183
236
|
|
|
184
237
|
/**
|
|
185
|
-
*
|
|
238
|
+
* Get list of all active connections.
|
|
239
|
+
* @returns {Array<{id: string, name: string}>} List of connections
|
|
240
|
+
*/
|
|
241
|
+
function getConnections() {
|
|
242
|
+
const result = [];
|
|
243
|
+
for (const [id, conn] of connections.entries()) {
|
|
244
|
+
result.push({
|
|
245
|
+
id,
|
|
246
|
+
name: conn.name,
|
|
247
|
+
connectionString: conn.connectionString,
|
|
248
|
+
sslMode: conn.sslMode
|
|
249
|
+
});
|
|
250
|
+
}
|
|
251
|
+
return result;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* Update an existing connection.
|
|
256
|
+
* @param {string} id - Connection ID to update
|
|
257
|
+
* @param {string} connectionString - New connection string
|
|
258
|
+
* @param {string} sslMode - New SSL mode
|
|
259
|
+
* @param {string} name - New name
|
|
260
|
+
* @returns {Promise<{id: string, name: string}>} Updated connection info
|
|
261
|
+
*/
|
|
262
|
+
async function updateConnection(id, connectionString, sslMode, name) {
|
|
263
|
+
const existingConn = connections.get(id);
|
|
264
|
+
if (!existingConn) {
|
|
265
|
+
throw new Error('Connection not found');
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
const sslConfig = getSslConfig(sslMode);
|
|
269
|
+
const poolConfig = {
|
|
270
|
+
max: 10,
|
|
271
|
+
idle_timeout: 30,
|
|
272
|
+
connect_timeout: 10,
|
|
273
|
+
timeout: 30, // Query timeout in seconds
|
|
274
|
+
};
|
|
275
|
+
|
|
276
|
+
if (sslConfig !== null) {
|
|
277
|
+
poolConfig.ssl = sslConfig;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
const sql = postgres(connectionString, poolConfig);
|
|
281
|
+
|
|
282
|
+
// Test the new connection
|
|
283
|
+
return sql`SELECT NOW()`
|
|
284
|
+
.then(async () => {
|
|
285
|
+
console.log('✓ Updated connection to PostgreSQL database');
|
|
286
|
+
|
|
287
|
+
// Close old pool
|
|
288
|
+
await existingConn.pool.end();
|
|
289
|
+
|
|
290
|
+
// Update map with new pool and details
|
|
291
|
+
connections.set(id, {
|
|
292
|
+
pool: sql,
|
|
293
|
+
name: name || getDatabaseName(connectionString),
|
|
294
|
+
connectionString,
|
|
295
|
+
sslMode
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
return { id, name: connections.get(id).name };
|
|
299
|
+
})
|
|
300
|
+
.catch((err) => {
|
|
301
|
+
console.error('✗ Failed to update connection:', err.message);
|
|
302
|
+
throw err;
|
|
303
|
+
});
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/**
|
|
307
|
+
* Close a specific connection pool.
|
|
308
|
+
* @param {string} connectionId - The connection ID to close
|
|
186
309
|
* @returns {Promise} Promise that resolves when pool is closed
|
|
187
310
|
*/
|
|
188
|
-
function closePool() {
|
|
189
|
-
if (
|
|
190
|
-
|
|
311
|
+
async function closePool(connectionId) {
|
|
312
|
+
if (connectionId) {
|
|
313
|
+
const conn = connections.get(connectionId);
|
|
314
|
+
if (conn) {
|
|
315
|
+
await conn.pool.end();
|
|
316
|
+
connections.delete(connectionId);
|
|
317
|
+
}
|
|
318
|
+
} else {
|
|
319
|
+
// Close all connections (e.g., on server shutdown)
|
|
320
|
+
const promises = [];
|
|
321
|
+
for (const conn of connections.values()) {
|
|
322
|
+
promises.push(conn.pool.end());
|
|
323
|
+
}
|
|
324
|
+
await Promise.all(promises);
|
|
325
|
+
connections.clear();
|
|
191
326
|
}
|
|
192
|
-
return Promise.resolve();
|
|
193
327
|
}
|
|
194
328
|
|
|
195
329
|
module.exports = {
|
|
196
330
|
createPool,
|
|
197
331
|
getPool,
|
|
198
332
|
closePool,
|
|
333
|
+
checkConnection,
|
|
334
|
+
getConnections,
|
|
335
|
+
updateConnection
|
|
199
336
|
};
|
|
200
|
-
|
package/src/routes/api.js
CHANGED
|
@@ -14,10 +14,119 @@
|
|
|
14
14
|
*/
|
|
15
15
|
|
|
16
16
|
const express = require('express');
|
|
17
|
-
const { getPool } = require('../db/connection');
|
|
17
|
+
const { getPool, createPool, closePool, checkConnection, getConnections, updateConnection } = require('../db/connection');
|
|
18
18
|
|
|
19
19
|
const router = express.Router();
|
|
20
20
|
|
|
21
|
+
/**
|
|
22
|
+
* Middleware to check if connected to database
|
|
23
|
+
*/
|
|
24
|
+
const requireConnection = async (req, res, next) => {
|
|
25
|
+
const connectionId = req.headers['x-connection-id'];
|
|
26
|
+
if (!connectionId) {
|
|
27
|
+
return res.status(400).json({ error: 'Connection ID header required' });
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const pool = getPool(connectionId);
|
|
31
|
+
if (!pool) {
|
|
32
|
+
return res.status(503).json({ error: 'Not connected to database or invalid connection ID' });
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
req.pool = pool;
|
|
36
|
+
next();
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* POST /api/connect
|
|
41
|
+
* Connect to a PostgreSQL database
|
|
42
|
+
*/
|
|
43
|
+
router.post('/connect', async (req, res) => {
|
|
44
|
+
const { url, sslMode, name } = req.body;
|
|
45
|
+
|
|
46
|
+
if (!url) {
|
|
47
|
+
return res.status(400).json({ error: 'Connection string is required' });
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
try {
|
|
51
|
+
const { id, name: connectionName } = await createPool(url, sslMode || 'prefer', name);
|
|
52
|
+
res.json({ connected: true, connectionId: id, name: connectionName });
|
|
53
|
+
} catch (error) {
|
|
54
|
+
res.status(400).json({
|
|
55
|
+
connected: false,
|
|
56
|
+
error: error.message
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* PUT /api/connections/:id
|
|
63
|
+
* Update an existing connection
|
|
64
|
+
*/
|
|
65
|
+
router.put('/connections/:id', async (req, res) => {
|
|
66
|
+
const { id } = req.params;
|
|
67
|
+
const { url, sslMode, name } = req.body;
|
|
68
|
+
|
|
69
|
+
if (!url) {
|
|
70
|
+
return res.status(400).json({ error: 'Connection string is required' });
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
try {
|
|
74
|
+
const { name: connectionName } = await updateConnection(id, url, sslMode || 'prefer', name);
|
|
75
|
+
res.json({ updated: true, connectionId: id, name: connectionName });
|
|
76
|
+
} catch (error) {
|
|
77
|
+
res.status(400).json({
|
|
78
|
+
updated: false,
|
|
79
|
+
error: error.message
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* GET /api/connections
|
|
86
|
+
* List active connections
|
|
87
|
+
*/
|
|
88
|
+
router.get('/connections', (req, res) => {
|
|
89
|
+
const connections = getConnections();
|
|
90
|
+
res.json({ connections });
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* POST /api/disconnect
|
|
95
|
+
* Disconnect from a database
|
|
96
|
+
*/
|
|
97
|
+
router.post('/disconnect', async (req, res) => {
|
|
98
|
+
const connectionId = req.body.connectionId || req.headers['x-connection-id'];
|
|
99
|
+
|
|
100
|
+
if (!connectionId) {
|
|
101
|
+
return res.status(400).json({ error: 'Connection ID required' });
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
try {
|
|
105
|
+
await closePool(connectionId);
|
|
106
|
+
res.json({ connected: false });
|
|
107
|
+
} catch (error) {
|
|
108
|
+
res.status(500).json({ error: error.message });
|
|
109
|
+
}
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* GET /api/status
|
|
114
|
+
* Check connection status
|
|
115
|
+
*/
|
|
116
|
+
router.get('/status', async (req, res) => {
|
|
117
|
+
const connectionId = req.headers['x-connection-id'];
|
|
118
|
+
if (!connectionId) {
|
|
119
|
+
return res.json({ connected: false });
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
try {
|
|
123
|
+
const connected = await checkConnection(connectionId);
|
|
124
|
+
res.json({ connected });
|
|
125
|
+
} catch (error) {
|
|
126
|
+
res.json({ connected: false, error: error.message });
|
|
127
|
+
}
|
|
128
|
+
});
|
|
129
|
+
|
|
21
130
|
/**
|
|
22
131
|
* Sanitize table name to prevent SQL injection.
|
|
23
132
|
* Only allows alphanumeric characters, underscores, and dots.
|
|
@@ -40,9 +149,9 @@ function sanitizeTableName(tableName) {
|
|
|
40
149
|
*
|
|
41
150
|
* Response: { tables: string[] }
|
|
42
151
|
*/
|
|
43
|
-
router.get('/tables', async (req, res) => {
|
|
152
|
+
router.get('/tables', requireConnection, async (req, res) => {
|
|
44
153
|
try {
|
|
45
|
-
const pool =
|
|
154
|
+
const pool = req.pool;
|
|
46
155
|
const result = await pool.query(`
|
|
47
156
|
SELECT table_name
|
|
48
157
|
FROM information_schema.tables
|
|
@@ -88,6 +197,147 @@ async function getPrimaryKeyColumn(pool, tableName) {
|
|
|
88
197
|
}
|
|
89
198
|
}
|
|
90
199
|
|
|
200
|
+
/**
|
|
201
|
+
* Get foreign key relationships for a table.
|
|
202
|
+
* Queries information_schema to get foreign key constraints and their references.
|
|
203
|
+
* @param {Pool} pool - Database connection pool
|
|
204
|
+
* @param {string} tableName - Name of the table
|
|
205
|
+
* @returns {Promise<Object>} Object mapping column names to their foreign key references { table, column }
|
|
206
|
+
*/
|
|
207
|
+
async function getForeignKeyRelations(pool, tableName) {
|
|
208
|
+
try {
|
|
209
|
+
const fkQuery = `
|
|
210
|
+
SELECT
|
|
211
|
+
kcu.column_name,
|
|
212
|
+
ccu.table_name AS foreign_table_name,
|
|
213
|
+
ccu.column_name AS foreign_column_name
|
|
214
|
+
FROM information_schema.table_constraints AS tc
|
|
215
|
+
JOIN information_schema.key_column_usage AS kcu
|
|
216
|
+
ON tc.constraint_name = kcu.constraint_name
|
|
217
|
+
AND tc.table_schema = kcu.table_schema
|
|
218
|
+
JOIN information_schema.constraint_column_usage AS ccu
|
|
219
|
+
ON ccu.constraint_name = tc.constraint_name
|
|
220
|
+
AND ccu.table_schema = tc.table_schema
|
|
221
|
+
WHERE tc.constraint_type = 'FOREIGN KEY'
|
|
222
|
+
AND tc.table_name = $1
|
|
223
|
+
AND tc.table_schema = 'public';
|
|
224
|
+
`;
|
|
225
|
+
const result = await pool.query(fkQuery, [tableName]);
|
|
226
|
+
const foreignKeys = {};
|
|
227
|
+
result.rows.forEach(row => {
|
|
228
|
+
foreignKeys[row.column_name] = {
|
|
229
|
+
table: row.foreign_table_name,
|
|
230
|
+
column: row.foreign_column_name
|
|
231
|
+
};
|
|
232
|
+
});
|
|
233
|
+
return foreignKeys;
|
|
234
|
+
} catch (error) {
|
|
235
|
+
console.error('Error getting foreign key relations:', error);
|
|
236
|
+
return {};
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* Get all primary key columns for a table.
|
|
242
|
+
* @param {Pool} pool - Database connection pool
|
|
243
|
+
* @param {string} tableName - Name of the table
|
|
244
|
+
* @returns {Promise<Set>} Set of primary key column names
|
|
245
|
+
*/
|
|
246
|
+
async function getPrimaryKeyColumns(pool, tableName) {
|
|
247
|
+
try {
|
|
248
|
+
const pkQuery = `
|
|
249
|
+
SELECT kcu.column_name
|
|
250
|
+
FROM information_schema.table_constraints tc
|
|
251
|
+
JOIN information_schema.key_column_usage kcu
|
|
252
|
+
ON tc.constraint_name = kcu.constraint_name
|
|
253
|
+
AND tc.table_schema = kcu.table_schema
|
|
254
|
+
WHERE tc.constraint_type = 'PRIMARY KEY'
|
|
255
|
+
AND tc.table_name = $1
|
|
256
|
+
AND tc.table_schema = 'public';
|
|
257
|
+
`;
|
|
258
|
+
const result = await pool.query(pkQuery, [tableName]);
|
|
259
|
+
const pkColumns = new Set();
|
|
260
|
+
result.rows.forEach(row => {
|
|
261
|
+
pkColumns.add(row.column_name);
|
|
262
|
+
});
|
|
263
|
+
return pkColumns;
|
|
264
|
+
} catch (error) {
|
|
265
|
+
console.error('Error getting primary key columns:', error);
|
|
266
|
+
return new Set();
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/**
|
|
271
|
+
* Get all unique constraint columns for a table.
|
|
272
|
+
* @param {Pool} pool - Database connection pool
|
|
273
|
+
* @param {string} tableName - Name of the table
|
|
274
|
+
* @returns {Promise<Set>} Set of unique constraint column names
|
|
275
|
+
*/
|
|
276
|
+
async function getUniqueColumns(pool, tableName) {
|
|
277
|
+
try {
|
|
278
|
+
const uniqueQuery = `
|
|
279
|
+
SELECT kcu.column_name
|
|
280
|
+
FROM information_schema.table_constraints tc
|
|
281
|
+
JOIN information_schema.key_column_usage kcu
|
|
282
|
+
ON tc.constraint_name = kcu.constraint_name
|
|
283
|
+
AND tc.table_schema = kcu.table_schema
|
|
284
|
+
WHERE tc.constraint_type = 'UNIQUE'
|
|
285
|
+
AND tc.table_name = $1
|
|
286
|
+
AND tc.table_schema = 'public';
|
|
287
|
+
`;
|
|
288
|
+
const result = await pool.query(uniqueQuery, [tableName]);
|
|
289
|
+
const uniqueColumns = new Set();
|
|
290
|
+
result.rows.forEach(row => {
|
|
291
|
+
uniqueColumns.add(row.column_name);
|
|
292
|
+
});
|
|
293
|
+
return uniqueColumns;
|
|
294
|
+
} catch (error) {
|
|
295
|
+
console.error('Error getting unique columns:', error);
|
|
296
|
+
return new Set();
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/**
|
|
301
|
+
* Get column metadata (datatypes and key relationships) for a table.
|
|
302
|
+
* Queries information_schema.columns to get column names and their data types,
|
|
303
|
+
* and includes key relationship information (primary keys, foreign keys, unique constraints).
|
|
304
|
+
* @param {Pool} pool - Database connection pool
|
|
305
|
+
* @param {string} tableName - Name of the table
|
|
306
|
+
* @returns {Promise<Object>} Object mapping column names to metadata objects with dataType, isPrimaryKey, isForeignKey, foreignKeyRef, isUnique
|
|
307
|
+
*/
|
|
308
|
+
async function getColumnMetadata(pool, tableName) {
|
|
309
|
+
try {
|
|
310
|
+
const metadataQuery = `
|
|
311
|
+
SELECT column_name, data_type
|
|
312
|
+
FROM information_schema.columns
|
|
313
|
+
WHERE table_schema = 'public'
|
|
314
|
+
AND table_name = $1
|
|
315
|
+
ORDER BY ordinal_position;
|
|
316
|
+
`;
|
|
317
|
+
const result = await pool.query(metadataQuery, [tableName]);
|
|
318
|
+
|
|
319
|
+
const primaryKeyColumns = await getPrimaryKeyColumns(pool, tableName);
|
|
320
|
+
const foreignKeyRelations = await getForeignKeyRelations(pool, tableName);
|
|
321
|
+
const uniqueColumns = await getUniqueColumns(pool, tableName);
|
|
322
|
+
|
|
323
|
+
const columns = {};
|
|
324
|
+
result.rows.forEach(row => {
|
|
325
|
+
const columnName = row.column_name;
|
|
326
|
+
columns[columnName] = {
|
|
327
|
+
dataType: row.data_type,
|
|
328
|
+
isPrimaryKey: primaryKeyColumns.has(columnName),
|
|
329
|
+
isForeignKey: !!foreignKeyRelations[columnName],
|
|
330
|
+
foreignKeyRef: foreignKeyRelations[columnName] || null,
|
|
331
|
+
isUnique: uniqueColumns.has(columnName)
|
|
332
|
+
};
|
|
333
|
+
});
|
|
334
|
+
return columns;
|
|
335
|
+
} catch (error) {
|
|
336
|
+
console.error('Error getting column metadata:', error);
|
|
337
|
+
return {};
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
|
|
91
341
|
/**
|
|
92
342
|
* GET /api/tables/:tableName
|
|
93
343
|
*
|
|
@@ -110,22 +360,36 @@ async function getPrimaryKeyColumn(pool, tableName) {
|
|
|
110
360
|
* limit: number,
|
|
111
361
|
* isApproximate: boolean,
|
|
112
362
|
* nextCursor: string|null,
|
|
113
|
-
* hasPrimaryKey: boolean
|
|
363
|
+
* hasPrimaryKey: boolean,
|
|
364
|
+
* columns: Object - Map of column names to metadata objects with:
|
|
365
|
+
* - dataType: string
|
|
366
|
+
* - isPrimaryKey: boolean
|
|
367
|
+
* - isForeignKey: boolean
|
|
368
|
+
* - foreignKeyRef: { table: string, column: string } | null
|
|
369
|
+
* - isUnique: boolean
|
|
114
370
|
* }
|
|
115
371
|
*/
|
|
116
|
-
router.get('/tables/:tableName', async (req, res) => {
|
|
372
|
+
router.get('/tables/:tableName', requireConnection, async (req, res) => {
|
|
117
373
|
try {
|
|
118
374
|
const tableName = sanitizeTableName(req.params.tableName);
|
|
119
375
|
const page = parseInt(req.query.page || '1', 10);
|
|
120
376
|
const limit = parseInt(req.query.limit || '100', 10);
|
|
121
377
|
const cursor = req.query.cursor;
|
|
378
|
+
const sortColumn = req.query.sortColumn ? req.query.sortColumn : null;
|
|
379
|
+
const sortDirection = req.query.sortDirection === 'desc' ? 'DESC' : 'ASC';
|
|
122
380
|
|
|
123
381
|
if (page < 1 || limit < 1) {
|
|
124
382
|
return res.status(400).json({ error: 'Page and limit must be positive integers' });
|
|
125
383
|
}
|
|
126
384
|
|
|
127
|
-
const pool =
|
|
385
|
+
const pool = req.pool;
|
|
128
386
|
const primaryKeyColumn = await getPrimaryKeyColumn(pool, tableName);
|
|
387
|
+
const columnMetadata = await getColumnMetadata(pool, tableName);
|
|
388
|
+
|
|
389
|
+
// Validate sortColumn if provided
|
|
390
|
+
if (sortColumn && !columnMetadata[sortColumn]) {
|
|
391
|
+
return res.status(400).json({ error: 'Invalid sort column' });
|
|
392
|
+
}
|
|
129
393
|
|
|
130
394
|
const countQuery = `SELECT COUNT(*) as total FROM "${tableName}"`;
|
|
131
395
|
const countResult = await pool.query(countQuery);
|
|
@@ -135,7 +399,22 @@ router.get('/tables/:tableName', async (req, res) => {
|
|
|
135
399
|
let dataResult;
|
|
136
400
|
let nextCursor = null;
|
|
137
401
|
|
|
138
|
-
if (
|
|
402
|
+
if (sortColumn) {
|
|
403
|
+
// Custom Sorting: Use OFFSET-based pagination
|
|
404
|
+
// Always add primary key (if exists) as secondary sort for stability
|
|
405
|
+
const offset = (page - 1) * limit;
|
|
406
|
+
let orderByClause = `ORDER BY "${sortColumn}" ${sortDirection}`;
|
|
407
|
+
|
|
408
|
+
if (primaryKeyColumn && sortColumn !== primaryKeyColumn) {
|
|
409
|
+
orderByClause += `, "${primaryKeyColumn}" ASC`;
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
const query = `SELECT * FROM "${tableName}" ${orderByClause} LIMIT $1 OFFSET $2`;
|
|
413
|
+
dataResult = await pool.query(query, [limit, offset]);
|
|
414
|
+
|
|
415
|
+
// Cursor not valid for custom sorts in this simple implementation
|
|
416
|
+
nextCursor = null;
|
|
417
|
+
} else if (primaryKeyColumn && cursor) {
|
|
139
418
|
// Cursor-based pagination: WHERE id > cursor (most efficient for forward navigation)
|
|
140
419
|
const cursorQuery = `SELECT * FROM "${tableName}" WHERE "${primaryKeyColumn}" > $1 ORDER BY "${primaryKeyColumn}" ASC LIMIT $2`;
|
|
141
420
|
const cursorParams = [cursor, limit];
|
|
@@ -173,7 +452,7 @@ router.get('/tables/:tableName', async (req, res) => {
|
|
|
173
452
|
}
|
|
174
453
|
dataResult = await pool.query(query, queryParams);
|
|
175
454
|
|
|
176
|
-
// Calculate cursor for next page if primary key exists
|
|
455
|
+
// Calculate cursor for next page if primary key exists (only if default sort)
|
|
177
456
|
if (primaryKeyColumn && dataResult.rows.length > 0) {
|
|
178
457
|
const lastRow = dataResult.rows[dataResult.rows.length - 1];
|
|
179
458
|
nextCursor = lastRow[primaryKeyColumn];
|
|
@@ -188,6 +467,7 @@ router.get('/tables/:tableName', async (req, res) => {
|
|
|
188
467
|
isApproximate,
|
|
189
468
|
nextCursor,
|
|
190
469
|
hasPrimaryKey: !!primaryKeyColumn,
|
|
470
|
+
columns: columnMetadata,
|
|
191
471
|
};
|
|
192
472
|
res.json(responseData);
|
|
193
473
|
} catch (error) {
|
|
@@ -197,4 +477,3 @@ router.get('/tables/:tableName', async (req, res) => {
|
|
|
197
477
|
});
|
|
198
478
|
|
|
199
479
|
module.exports = router;
|
|
200
|
-
|