UNPKG

plugin-postgresql-connector

Version:

NocoBase plugin for connecting to external PostgreSQL databases

490 lines (425 loc) 14.1 kB
import { PoolClient } from 'pg'; import { ConnectionManager } from './ConnectionManager'; import { format } from 'sql-formatter'; export interface QueryResult { rows: any[]; rowCount: number; fields?: Array<{ name: string; dataTypeID: number; dataTypeSize: number; dataTypeName?: string; }>; executionTime: number; query: string; formattedQuery?: string; } export interface QueryParameter { name: string; value: any; type?: string; } export interface QueryOptions { timeout?: number; maxRows?: number; formatQuery?: boolean; includeMetadata?: boolean; } export interface SchemaInfo { tables: Array<{ table_name: string; table_type: string; table_schema: string; }>; views: Array<{ view_name: string; view_definition?: string; table_schema: string; }>; functions: Array<{ function_name: string; routine_type: string; data_type?: string; routine_definition?: string; }>; procedures: Array<{ procedure_name: string; routine_type: string; routine_definition?: string; }>; } export abstract class QueryStrategy { abstract execute( client: PoolClient, query: string, params: any[], options: QueryOptions ): Promise<QueryResult>; protected getDataTypeName(dataTypeID: number): string { const typeMap: { [key: number]: string } = { 16: 'BOOLEAN', 17: 'BYTEA', 20: 'BIGINT', 21: 'SMALLINT', 23: 'INTEGER', 25: 'TEXT', 700: 'REAL', 701: 'DOUBLE_PRECISION', 1043: 'VARCHAR', 1082: 'DATE', 1083: 'TIME', 1114: 'TIMESTAMP', 1184: 'TIMESTAMPTZ', 1700: 'NUMERIC', 2950: 'UUID', 3802: 'JSONB', 114: 'JSON', }; return typeMap[dataTypeID] || 'UNKNOWN'; } protected formatExecutionTime(startTime: number): number { return Date.now() - startTime; } } export class SelectQueryStrategy extends QueryStrategy { async execute( client: PoolClient, query: string, params: any[] = [], options: QueryOptions = {} ): Promise<QueryResult> { const startTime = Date.now(); try { // Apply row limit if specified let finalQuery = query; if (options.maxRows && !query.toLowerCase().includes('limit')) { finalQuery = `${query} LIMIT ${options.maxRows}`; } const result = await client.query(finalQuery, params); return { rows: result.rows, rowCount: result.rowCount || 0, fields: result.fields?.map(field => ({ name: field.name, dataTypeID: field.dataTypeID, dataTypeSize: field.dataTypeSize, dataTypeName: this.getDataTypeName(field.dataTypeID), })), executionTime: this.formatExecutionTime(startTime), query: finalQuery, formattedQuery: options.formatQuery ? format(finalQuery) : undefined, }; } catch (error) { throw new Error(`SELECT query failed: ${error instanceof Error ? error.message : 'Unknown error'}`); } } } export class ModifyQueryStrategy extends QueryStrategy { async execute( client: PoolClient, query: string, params: any[] = [], options: QueryOptions = {} ): Promise<QueryResult> { const startTime = Date.now(); try { const result = await client.query(query, params); return { rows: result.rows || [], rowCount: result.rowCount || 0, fields: result.fields?.map(field => ({ name: field.name, dataTypeID: field.dataTypeID, dataTypeSize: field.dataTypeSize, dataTypeName: this.getDataTypeName(field.dataTypeID), })), executionTime: this.formatExecutionTime(startTime), query, formattedQuery: options.formatQuery ? format(query) : undefined, }; } catch (error) { throw new Error(`Modify query failed: ${error instanceof Error ? error.message : 'Unknown error'}`); } } } export class ProcedureQueryStrategy extends QueryStrategy { async execute( client: PoolClient, query: string, params: any[] = [], options: QueryOptions = {} ): Promise<QueryResult> { const startTime = Date.now(); try { // For stored procedures, we might need to use CALL syntax const result = await client.query(query, params); return { rows: result.rows || [], rowCount: result.rowCount || 0, fields: result.fields?.map(field => ({ name: field.name, dataTypeID: field.dataTypeID, dataTypeSize: field.dataTypeSize, dataTypeName: this.getDataTypeName(field.dataTypeID), })), executionTime: this.formatExecutionTime(startTime), query, formattedQuery: options.formatQuery ? format(query) : undefined, }; } catch (error) { throw new Error(`Procedure execution failed: ${error instanceof Error ? error.message : 'Unknown error'}`); } } } export class QueryExecutor { private queryCache: Map<string, { result: QueryResult; timestamp: number }> = new Map(); private readonly cacheTTL: number = 300000; // 5 minutes constructor(private connectionManager: ConnectionManager) {} async executeQuery( connectionId: string, query: string, params: any[] = [], options: QueryOptions = {} ): Promise<QueryResult> { const cleanQuery = this.validateAndCleanQuery(query); const queryType = this.detectQueryType(cleanQuery); // Check cache for SELECT queries if (queryType === 'SELECT' && this.shouldUseCache(cleanQuery, params)) { const cached = this.getCachedResult(cleanQuery, params); if (cached) { return cached; } } const client = await this.connectionManager.getConnection(connectionId); try { const startTime = Date.now(); // Set query timeout if specified if (options.timeout) { await client.query(`SET statement_timeout = ${options.timeout}`); } // Apply row limit if specified and not already present let finalQuery = cleanQuery; if (options.maxRows && !cleanQuery.toLowerCase().includes('limit')) { finalQuery = `${cleanQuery} LIMIT ${options.maxRows}`; } const result = await client.query(finalQuery, params); const executionTime = Date.now() - startTime; const queryResult: QueryResult = { rows: result.rows, rowCount: result.rowCount || 0, fields: result.fields?.map(field => ({ name: field.name, dataTypeID: field.dataTypeID, dataTypeSize: field.dataTypeSize, dataTypeName: this.getDataTypeName(field.dataTypeID), })), executionTime, query: finalQuery, formattedQuery: options.formatQuery ? format(finalQuery) : undefined, }; // Cache SELECT query results if (queryType === 'SELECT') { this.cacheResult(cleanQuery, params, queryResult); } return queryResult; } finally { client.release(); // Reset timeout if (options.timeout) { await client.query('RESET statement_timeout'); } } } async executeProcedure( connectionId: string, procedureName: string, params: any[] = [], options: QueryOptions = {} ): Promise<QueryResult> { const paramPlaceholders = params.map((_, index) => `$${index + 1}`).join(', '); const query = `CALL ${procedureName}(${paramPlaceholders})`; return this.executeQuery(connectionId, query, params, options); } async executeFunction( connectionId: string, functionName: string, params: any[] = [], options: QueryOptions = {} ): Promise<QueryResult> { const paramPlaceholders = params.map((_, index) => `$${index + 1}`).join(', '); const query = `SELECT ${functionName}(${paramPlaceholders})`; return this.executeQuery(connectionId, query, params, options); } async getViewData( connectionId: string, viewName: string, limit: number = 100, options: QueryOptions = {} ): Promise<QueryResult> { const query = `SELECT * FROM ${viewName} LIMIT $1`; return this.executeQuery(connectionId, query, [limit], options); } async getTableData( connectionId: string, tableName: string, limit: number = 100, offset: number = 0, orderBy?: string, options: QueryOptions = {} ): Promise<QueryResult> { let query = `SELECT * FROM ${tableName}`; const params: any[] = []; let paramIndex = 1; if (orderBy) { query += ` ORDER BY ${orderBy}`; } query += ` LIMIT $${paramIndex++}`; params.push(limit); if (offset > 0) { query += ` OFFSET $${paramIndex}`; params.push(offset); } return this.executeQuery(connectionId, query, params, options); } private validateAndCleanQuery(query: string): string { const trimmedQuery = query.trim(); if (!trimmedQuery) { throw new Error('Query cannot be empty'); } // Basic security checks const dangerousPatterns = [ /\b(DROP|TRUNCATE|ALTER)\s+/i, /\b(CREATE|DROP)\s+(USER|ROLE)\s+/i, /\b(GRANT|REVOKE)\s+/i, /\bSHUTDOWN\b/i, /\bRESTART\b/i, ]; for (const pattern of dangerousPatterns) { if (pattern.test(trimmedQuery)) { throw new Error('Potentially dangerous SQL statement detected'); } } return trimmedQuery; } private detectQueryType(query: string): string { const upperQuery = query.toUpperCase().trim(); if (upperQuery.startsWith('SELECT')) return 'SELECT'; if (upperQuery.startsWith('INSERT')) return 'INSERT'; if (upperQuery.startsWith('UPDATE')) return 'UPDATE'; if (upperQuery.startsWith('DELETE')) return 'DELETE'; if (upperQuery.startsWith('CALL')) return 'CALL'; if (upperQuery.startsWith('WITH')) return 'SELECT'; // CTE queries // Check for function calls if (upperQuery.startsWith('SELECT') && upperQuery.includes('(')) { return 'FUNCTION'; } return 'SELECT'; // Default to SELECT for safety } private getDataTypeName(dataTypeID: number): string { const typeMap: { [key: number]: string } = { 16: 'BOOLEAN', 17: 'BYTEA', 20: 'BIGINT', 21: 'SMALLINT', 23: 'INTEGER', 25: 'TEXT', 700: 'REAL', 701: 'DOUBLE_PRECISION', 1043: 'VARCHAR', 1082: 'DATE', 1083: 'TIME', 1114: 'TIMESTAMP', 1184: 'TIMESTAMPTZ', 1700: 'NUMERIC', 2950: 'UUID', 3802: 'JSONB', 114: 'JSON', }; return typeMap[dataTypeID] || 'UNKNOWN'; } private shouldUseCache(query: string, params: any[]): boolean { // Only cache simple SELECT queries without parameters that might change frequently const upperQuery = query.toUpperCase(); // Don't cache queries with NOW(), CURRENT_TIMESTAMP, etc. const nonCacheablePatterns = [ /\bNOW\(\)/i, /\bCURRENT_TIMESTAMP\b/i, /\bCURRENT_DATE\b/i, /\bRANDOM\(\)/i, /\bGEN_RANDOM_UUID\(\)/i, ]; return !nonCacheablePatterns.some(pattern => pattern.test(upperQuery)); } private getCachedResult(query: string, params: any[]): QueryResult | null { const cacheKey = this.generateCacheKey(query, params); const cached = this.queryCache.get(cacheKey); if (cached && Date.now() - cached.timestamp < this.cacheTTL) { return { ...cached.result }; // Return a copy } if (cached) { this.queryCache.delete(cacheKey); } return null; } private cacheResult(query: string, params: any[], result: QueryResult): void { const cacheKey = this.generateCacheKey(query, params); this.queryCache.set(cacheKey, { result: { ...result }, // Store a copy timestamp: Date.now(), }); // Cleanup old cache entries if cache gets too large if (this.queryCache.size > 100) { this.cleanupCache(); } } private generateCacheKey(query: string, params: any[]): string { return `${query}:${JSON.stringify(params)}`; } private cleanupCache(): void { const now = Date.now(); for (const [key, value] of this.queryCache) { if (now - value.timestamp > this.cacheTTL) { this.queryCache.delete(key); } } } /** * Get query execution statistics */ getStatistics(): object { return { cacheSize: this.queryCache.size, supportedOperations: ['SELECT', 'INSERT', 'UPDATE', 'DELETE', 'CALL', 'PROCEDURE', 'FUNCTION'], cacheTTL: this.cacheTTL, }; } /** * Clear query cache */ clearCache(): void { this.queryCache.clear(); } /** * Analyze query complexity (basic implementation) */ analyzeQuery(query: string): object { const upperQuery = query.toUpperCase(); const complexity = { joins: (upperQuery.match(/\bJOIN\b/g) || []).length, subqueries: (upperQuery.match(/\bSELECT\b/g) || []).length - 1, aggregations: (upperQuery.match(/\b(COUNT|SUM|AVG|MIN|MAX|GROUP BY)\b/g) || []).length, orderBy: upperQuery.includes('ORDER BY'), limit: upperQuery.includes('LIMIT'), estimated_complexity: 'low', }; // Simple complexity estimation const score = complexity.joins * 2 + complexity.subqueries * 3 + complexity.aggregations; if (score > 10) { complexity.estimated_complexity = 'high'; } else if (score > 5) { complexity.estimated_complexity = 'medium'; } return complexity; } } export default QueryExecutor;