diff --git a/frontend/src/api/tools.ts b/frontend/src/api/tools.ts index 1dafeda..8e6672d 100644 --- a/frontend/src/api/tools.ts +++ b/frontend/src/api/tools.ts @@ -1,6 +1,7 @@ import { ApiError } from './errors'; -export interface QueryResult { +export interface StatementResult { + sql: string; columns: string[]; rows: any[][]; rowCount: number; @@ -18,11 +19,16 @@ interface McpResponse { }; } +interface StatementData { + sql: string; + rows: Record[]; + count: number; +} + interface ToolResultData { success: boolean; data: { - rows: Record[]; - count: number; + statements: StatementData[]; source_id: string; } | null; error: string | null; @@ -31,7 +37,7 @@ interface ToolResultData { export async function executeTool( toolName: string, args: Record -): Promise { +): Promise { const response = await fetch('/mcp', { method: 'POST', headers: { @@ -69,22 +75,29 @@ export async function executeTool( throw new ApiError(toolResult.error || 'Tool execution failed', 500); } - if (!toolResult.data || !toolResult.data.rows) { - return { columns: [], rows: [], rowCount: 0 }; + if (!toolResult.data || !toolResult.data.statements) { + return []; } - const rows = toolResult.data.rows; - if (rows.length === 0) { - // For INSERT/UPDATE/DELETE, rows is empty but count reflects affected rows - return { columns: [], rows: [], rowCount: toolResult.data.count }; - } + return toolResult.data.statements.map((stmt) => { + const rows = stmt.rows; + if (rows.length === 0) { + return { + sql: stmt.sql, + columns: [], + rows: [], + rowCount: stmt.count, + }; + } - const columns = Object.keys(rows[0]); - const rowArrays = rows.map((row) => columns.map((col) => row[col])); + const columns = Object.keys(rows[0]); + const rowArrays = rows.map((row) => columns.map((col) => row[col])); - return { - columns, - rows: rowArrays, - rowCount: toolResult.data.count, - }; + return { + sql: stmt.sql, + columns, + rows: rowArrays, + rowCount: stmt.count, + }; + }); } diff --git a/frontend/src/components/icons/ChevronLeftIcon.tsx b/frontend/src/components/icons/ChevronLeftIcon.tsx new file mode 100644 index 0000000..2afaa10 --- /dev/null +++ b/frontend/src/components/icons/ChevronLeftIcon.tsx @@ -0,0 +1,11 @@ +interface ChevronLeftIconProps { + className?: string; +} + +export default function ChevronLeftIcon({ className = 'w-4 h-4' }: ChevronLeftIconProps) { + return ( + + + + ); +} diff --git a/frontend/src/components/icons/ChevronRightIcon.tsx b/frontend/src/components/icons/ChevronRightIcon.tsx new file mode 100644 index 0000000..de0899c --- /dev/null +++ b/frontend/src/components/icons/ChevronRightIcon.tsx @@ -0,0 +1,11 @@ +interface ChevronRightIconProps { + className?: string; +} + +export default function ChevronRightIcon({ className = 'w-4 h-4' }: ChevronRightIconProps) { + return ( + + + + ); +} diff --git a/frontend/src/components/tool/ResultsTable.tsx b/frontend/src/components/tool/ResultsTable.tsx index f9cabaa..8e45a79 100644 --- a/frontend/src/components/tool/ResultsTable.tsx +++ b/frontend/src/components/tool/ResultsTable.tsx @@ -1,12 +1,11 @@ import { useRef, useState, useMemo } from 'react'; import { useVirtualizer } from '@tanstack/react-virtual'; -import type { QueryResult } from '../../api/tools'; +import type { StatementResult } from '../../api/tools'; interface ResultsTableProps { - result: QueryResult | null; + result: StatementResult | null; error: string | null; isLoading?: boolean; - executedSql?: string; executionTimeMs?: number; } @@ -53,7 +52,7 @@ function highlightText(text: string, searchTerm: string): React.ReactNode { return parts; } -export function ResultsTable({ result, error, isLoading, executedSql, executionTimeMs }: ResultsTableProps) { +export function ResultsTable({ result, error, isLoading, executionTimeMs }: ResultsTableProps) { const parentRef = useRef(null); const [searchTerm, setSearchTerm] = useState(''); @@ -198,9 +197,9 @@ export function ResultsTable({ result, error, isLoading, executedSql, executionT - {executedSql && executionTimeMs !== undefined && ( + {result.sql && executionTimeMs !== undefined && (
- {executedSql} + {result.sql} Executed in {formatExecutionTime(executionTimeMs)}
)} diff --git a/frontend/src/components/tool/ResultsTabs.tsx b/frontend/src/components/tool/ResultsTabs.tsx index d027f82..9b61aad 100644 --- a/frontend/src/components/tool/ResultsTabs.tsx +++ b/frontend/src/components/tool/ResultsTabs.tsx @@ -1,8 +1,10 @@ -import { useMemo } from 'react'; +import { useMemo, useRef, useState, useEffect, useCallback } from 'react'; import { cn } from '../../lib/utils'; import { ResultsTable } from './ResultsTable'; import type { ResultTab } from './types'; import XIcon from '../icons/XIcon'; +import ChevronLeftIcon from '../icons/ChevronLeftIcon'; +import ChevronRightIcon from '../icons/ChevronRightIcon'; interface ResultsTabsProps { tabs: ResultTab[]; @@ -21,6 +23,12 @@ function formatTimestamp(date: Date): string { }); } +function formatTabLabel(tab: ResultTab): string { + return formatTimestamp(tab.timestamp); +} + +const SCROLL_AMOUNT = 150; + export function ResultsTabs({ tabs, activeTabId, @@ -28,11 +36,51 @@ export function ResultsTabs({ onTabClose, isLoading, }: ResultsTabsProps) { + const scrollContainerRef = useRef(null); + const [canScrollLeft, setCanScrollLeft] = useState(false); + const [canScrollRight, setCanScrollRight] = useState(false); + const activeTab = useMemo( () => tabs.find((tab) => tab.id === activeTabId), [tabs, activeTabId] ); + const updateScrollButtons = useCallback(() => { + const container = scrollContainerRef.current; + if (!container) return; + + setCanScrollLeft(container.scrollLeft > 0); + setCanScrollRight( + container.scrollLeft < container.scrollWidth - container.clientWidth - 1 + ); + }, []); + + useEffect(() => { + updateScrollButtons(); + const container = scrollContainerRef.current; + if (!container) return; + + container.addEventListener('scroll', updateScrollButtons); + window.addEventListener('resize', updateScrollButtons); + + return () => { + container.removeEventListener('scroll', updateScrollButtons); + window.removeEventListener('resize', updateScrollButtons); + }; + }, [updateScrollButtons, tabs]); + + const scrollLeft = useCallback(() => { + const container = scrollContainerRef.current; + if (!container) return; + container.scrollBy({ left: -SCROLL_AMOUNT, behavior: 'smooth' }); + }, []); + + const scrollRight = useCallback(() => { + const container = scrollContainerRef.current; + if (!container) return; + container.scrollBy({ left: SCROLL_AMOUNT, behavior: 'smooth' }); + }, []); + // Loading state (no tabs yet) if (isLoading && tabs.length === 0) { return ( @@ -55,45 +103,80 @@ export function ResultsTabs({ return (
- {/* Tab bar */} -
- {tabs.map((tab) => ( + {/* Tab bar with scroll buttons */} +
+ {/* Left scroll button */} + {canScrollLeft && ( + )} + + {/* Scrollable tab container */} +
+ {tabs.map((tab) => ( + + ))} +
+ + {/* Right scroll button */} + {canScrollRight && ( + - ))} + )}
{/* Active tab content */} @@ -102,7 +185,6 @@ export function ResultsTabs({ result={activeTab.result} error={activeTab.error} isLoading={isLoading} - executedSql={activeTab.executedSql} executionTimeMs={activeTab.executionTimeMs} /> )} diff --git a/frontend/src/components/tool/types.ts b/frontend/src/components/tool/types.ts index 5b3d027..db7e06f 100644 --- a/frontend/src/components/tool/types.ts +++ b/frontend/src/components/tool/types.ts @@ -1,10 +1,9 @@ -import type { QueryResult } from '../../api/tools'; +import type { StatementResult } from '../../api/tools'; export interface ResultTab { id: string; timestamp: Date; - result: QueryResult | null; + result: StatementResult | null; error: string | null; - executedSql: string; executionTimeMs: number; } diff --git a/frontend/src/components/views/ToolDetailView.tsx b/frontend/src/components/views/ToolDetailView.tsx index 056faeb..97c327f 100644 --- a/frontend/src/components/views/ToolDetailView.tsx +++ b/frontend/src/components/views/ToolDetailView.tsx @@ -1,7 +1,7 @@ import { useEffect, useState, useCallback } from 'react'; import { useParams, Navigate, useSearchParams } from 'react-router-dom'; import { fetchSource } from '../../api/sources'; -import { executeTool, type QueryResult } from '../../api/tools'; +import { executeTool } from '../../api/tools'; import { ApiError } from '../../api/errors'; import type { Tool } from '../../types/datasource'; import { SqlEditor, ParameterForm, RunButton, ResultsTabs, type ResultTab } from '../tool'; @@ -206,37 +206,36 @@ export default function ToolDetailView() { const startTime = performance.now(); try { - let queryResult: QueryResult; - let sqlToExecute: string; - - if (toolType === 'execute_sql') { - sqlToExecute = sql; - queryResult = await executeTool(toolName, { sql }); - } else { - sqlToExecute = getSqlPreview(); - queryResult = await executeTool(toolName, params); - } + const results = toolType === 'execute_sql' + ? await executeTool(toolName, { sql }) + : await executeTool(toolName, params); const endTime = performance.now(); const duration = endTime - startTime; + const timestamp = new Date(); - const newTab: ResultTab = { + const newTabs: ResultTab[] = results.map((stmt, index) => ({ id: crypto.randomUUID(), - timestamp: new Date(), - result: queryResult, + timestamp: timestamp, + result: stmt, error: null, - executedSql: sqlToExecute, - executionTimeMs: duration, - }; - setResultTabs(prev => [newTab, ...prev]); - setActiveTabId(newTab.id); + executionTimeMs: index === 0 ? duration : 0, + })); + + setResultTabs(prev => [...newTabs, ...prev]); + setActiveTabId(newTabs[0]?.id ?? null); } catch (err) { + const sqlToExecute = toolType === 'execute_sql' ? sql : getSqlPreview(); const errorTab: ResultTab = { id: crypto.randomUUID(), timestamp: new Date(), - result: null, + result: { + sql: sqlToExecute, + columns: [], + rows: [], + rowCount: 0, + }, error: err instanceof Error ? err.message : 'Query failed', - executedSql: toolType === 'execute_sql' ? sql : getSqlPreview(), executionTimeMs: 0, }; setResultTabs(prev => [errorTab, ...prev]); diff --git a/src/connectors/interface.ts b/src/connectors/interface.ts index 67913b3..722cd08 100644 --- a/src/connectors/interface.ts +++ b/src/connectors/interface.ts @@ -7,7 +7,8 @@ export type ConnectorType = "postgres" | "mysql" | "mariadb" | "sqlite" | "sqlse * Database Connector Interface * This defines the contract that all database connectors must implement. */ -export interface SQLResult { +export interface StatementResult { + sql: string; rows: any[]; rowCount: number; } @@ -179,7 +180,7 @@ export interface Connector { getStoredProcedureDetail(procedureName: string, schema?: string): Promise; /** Execute a SQL query with execution options and optional parameters */ - executeSQL(sql: string, options: ExecuteOptions, parameters?: any[]): Promise; + executeSQL(sql: string, options: ExecuteOptions, parameters?: any[]): Promise; } /** diff --git a/src/connectors/mariadb/index.ts b/src/connectors/mariadb/index.ts index 27a5b6b..8b64c2f 100644 --- a/src/connectors/mariadb/index.ts +++ b/src/connectors/mariadb/index.ts @@ -4,7 +4,7 @@ import { ConnectorType, ConnectorRegistry, DSNParser, - SQLResult, + StatementResult, TableColumn, TableIndex, StoredProcedure, @@ -497,7 +497,7 @@ export class MariaDBConnector implements Connector { return rows[0].DB; } - async executeSQL(sql: string, options: ExecuteOptions, parameters?: any[]): Promise { + async executeSQL(sql: string, options: ExecuteOptions, parameters?: any[]): Promise { if (!this.pool) { throw new Error("Not connected to database"); } @@ -506,26 +506,23 @@ export class MariaDBConnector implements Connector { // This is critical for session-specific features like LAST_INSERT_ID() const conn = await this.pool.getConnection(); try { + // Split SQL into individual statements for per-statement result tracking + const statements = sql.split(';') + .map(statement => statement.trim()) + .filter(statement => statement.length > 0); + // Apply maxRows limit to SELECT queries if specified - let processedSQL = sql; + let processedStatements = statements; if (options.maxRows) { - // Handle multi-statement SQL by processing each statement individually - const statements = sql.split(';') - .map(statement => statement.trim()) - .filter(statement => statement.length > 0); - - const processedStatements = statements.map(statement => + processedStatements = statements.map(statement => SQLRowLimiter.applyMaxRows(statement, options.maxRows) ); - - processedSQL = processedStatements.join('; '); - if (sql.trim().endsWith(';')) { - processedSQL += ';'; - } } - // Use dedicated connection - MariaDB driver returns rows directly for single statements - // Pass parameters if provided + // Reconstruct SQL for execution + const processedSQL = processedStatements.join('; ') + (sql.trim().endsWith(';') ? ';' : ''); + + // Execute query with parameters if provided let results: any; if (parameters && parameters.length > 0) { try { @@ -540,10 +537,36 @@ export class MariaDBConnector implements Connector { results = await conn.query(processedSQL); } - // Parse results using shared utility that handles both single and multi-statement queries - const rows = parseQueryResults(results); - const rowCount = extractAffectedRows(results); - return { rows, rowCount }; + // Parse results into per-statement format + // MariaDB returns an array of results for multi-statement queries + const statementResults: StatementResult[] = []; + + if (statements.length === 1) { + // Single statement - results is either rows array or metadata object + const rows = parseQueryResults(results); + const rowCount = extractAffectedRows(results); + statementResults.push({ + sql: statements[0], + rows, + rowCount + }); + } else { + // Multi-statement - results is array of result sets + if (Array.isArray(results)) { + for (let i = 0; i < statements.length && i < results.length; i++) { + const result = results[i]; + const rows = parseQueryResults(result); + const rowCount = extractAffectedRows(result); + statementResults.push({ + sql: statements[i], + rows, + rowCount + }); + } + } + } + + return statementResults; } catch (error) { console.error("Error executing query:", error); throw error; diff --git a/src/connectors/mysql/index.ts b/src/connectors/mysql/index.ts index a7ec8b2..b780ae3 100644 --- a/src/connectors/mysql/index.ts +++ b/src/connectors/mysql/index.ts @@ -4,7 +4,7 @@ import { ConnectorType, ConnectorRegistry, DSNParser, - SQLResult, + StatementResult, TableColumn, TableIndex, StoredProcedure, @@ -504,7 +504,7 @@ export class MySQLConnector implements Connector { return rows[0].DB; } - async executeSQL(sql: string, options: ExecuteOptions, parameters?: any[]): Promise { + async executeSQL(sql: string, options: ExecuteOptions, parameters?: any[]): Promise { if (!this.pool) { throw new Error("Not connected to database"); } @@ -513,48 +513,86 @@ export class MySQLConnector implements Connector { // This is critical for session-specific features like LAST_INSERT_ID() const conn = await this.pool.getConnection(); try { - // Apply maxRows limit to SELECT queries if specified - let processedSQL = sql; - if (options.maxRows) { - // Handle multi-statement SQL by processing each statement individually - const statements = sql.split(';') - .map(statement => statement.trim()) - .filter(statement => statement.length > 0); + // Check if this is a multi-statement query + const statements = sql.split(';') + .map(statement => statement.trim()) + .filter(statement => statement.length > 0); - const processedStatements = statements.map(statement => - SQLRowLimiter.applyMaxRows(statement, options.maxRows) - ); + const results: StatementResult[] = []; - processedSQL = processedStatements.join('; '); - if (sql.trim().endsWith(';')) { - processedSQL += ';'; - } - } + if (statements.length === 1) { + // Single statement - apply maxRows if applicable + const processedStatement = SQLRowLimiter.applyMaxRows(statements[0], options.maxRows); - // Use dedicated connection with multipleStatements: true support - // Pass parameters if provided, with optional query timeout - let results: any; - if (parameters && parameters.length > 0) { - try { - results = await conn.query({ sql: processedSQL, timeout: this.queryTimeoutMs }, parameters); - } catch (error) { - console.error(`[MySQL executeSQL] ERROR: ${(error as Error).message}`); - console.error(`[MySQL executeSQL] SQL: ${processedSQL}`); - console.error(`[MySQL executeSQL] Parameters: ${JSON.stringify(parameters)}`); - throw error; + // Use parameters if provided + let queryResult: any; + if (parameters && parameters.length > 0) { + try { + queryResult = await conn.query({ sql: processedStatement, timeout: this.queryTimeoutMs }, parameters); + } catch (error) { + console.error(`[MySQL executeSQL] ERROR: ${(error as Error).message}`); + console.error(`[MySQL executeSQL] SQL: ${processedStatement}`); + console.error(`[MySQL executeSQL] Parameters: ${JSON.stringify(parameters)}`); + throw error; + } + } else { + queryResult = await conn.query({ sql: processedStatement, timeout: this.queryTimeoutMs }); } + + // MySQL2 returns results in format [rows, fields] + const [firstResult] = queryResult; + + // Parse results using shared utility + const rows = parseQueryResults(firstResult); + const rowCount = extractAffectedRows(firstResult); + results.push({ sql: statements[0], rows, rowCount }); } else { - results = await conn.query({ sql: processedSQL, timeout: this.queryTimeoutMs }); - } + // Multiple statements - parameters not supported for multi-statement queries + if (parameters && parameters.length > 0) { + throw new Error("Parameters are not supported for multi-statement queries in MySQL"); + } - // MySQL2 returns results in format [rows, fields] - // Extract the first element which contains the actual row data - const [firstResult] = results; + // Build complete SQL with all statements + const processedStatements = statements.map(statement => + SQLRowLimiter.applyMaxRows(statement, options.maxRows) + ); + const processedSQL = processedStatements.join('; ') + (sql.trim().endsWith(';') ? ';' : ''); + + // Execute all statements in one query (MySQL2 supports multipleStatements: true) + const queryResult = await conn.query({ sql: processedSQL, timeout: this.queryTimeoutMs }); + const [firstResult] = queryResult; + + // MySQL2 returns an array of results for multi-statement queries + // Each result can be either a rows array (SELECT) or metadata object (INSERT/UPDATE/DELETE) + if (Array.isArray(firstResult) && firstResult.length > 0) { + // Check if this is a multi-statement result + const isMultiStatement = firstResult.some((item: any) => + Array.isArray(item) || (typeof item === 'object' && ('affectedRows' in item || 'insertId' in item)) + ); + + if (isMultiStatement) { + // Process each statement's result + for (let i = 0; i < statements.length; i++) { + const statementResult = firstResult[i]; + const rows = parseQueryResults(statementResult); + const rowCount = extractAffectedRows(statementResult); + results.push({ sql: statements[i], rows, rowCount }); + } + } else { + // Single statement result (fallback) + const rows = parseQueryResults(firstResult); + const rowCount = extractAffectedRows(firstResult); + results.push({ sql: statements[0], rows, rowCount }); + } + } else { + // Handle metadata object (single non-SELECT statement) + const rows = parseQueryResults(firstResult); + const rowCount = extractAffectedRows(firstResult); + results.push({ sql: statements[0], rows, rowCount }); + } + } - // Parse results using shared utility that handles both single and multi-statement queries - const rows = parseQueryResults(firstResult); - const rowCount = extractAffectedRows(firstResult); - return { rows, rowCount }; + return results; } catch (error) { console.error("Error executing query:", error); throw error; diff --git a/src/connectors/postgres/index.ts b/src/connectors/postgres/index.ts index bfe0242..370a6b6 100644 --- a/src/connectors/postgres/index.ts +++ b/src/connectors/postgres/index.ts @@ -5,7 +5,7 @@ import { ConnectorType, ConnectorRegistry, DSNParser, - SQLResult, + StatementResult, TableColumn, TableIndex, StoredProcedure, @@ -427,7 +427,7 @@ export class PostgresConnector implements Connector { } - async executeSQL(sql: string, options: ExecuteOptions, parameters?: any[]): Promise { + async executeSQL(sql: string, options: ExecuteOptions, parameters?: any[]): Promise { if (!this.pool) { throw new Error("Not connected to database"); } @@ -457,8 +457,12 @@ export class PostgresConnector implements Connector { } else { result = await client.query(processedStatement); } - // Explicitly return rows and rowCount to ensure rowCount is preserved - return { rows: result.rows, rowCount: result.rowCount ?? result.rows.length }; + // Return single statement result in array format + return [{ + sql: statements[0], + rows: result.rows, + rowCount: result.rowCount ?? result.rows.length + }]; } else { // Multiple statements - parameters not supported for multi-statement queries if (parameters && parameters.length > 0) { @@ -466,8 +470,7 @@ export class PostgresConnector implements Connector { } // Execute all in same session for transaction consistency - let allRows: any[] = []; - let totalRowCount = 0; + const results: StatementResult[] = []; // Execute within a transaction to ensure session consistency await client.query('BEGIN'); @@ -477,14 +480,13 @@ export class PostgresConnector implements Connector { const processedStatement = SQLRowLimiter.applyMaxRows(statement, options.maxRows); const result = await client.query(processedStatement); - // Collect rows from SELECT/WITH/EXPLAIN statements - if (result.rows && result.rows.length > 0) { - allRows.push(...result.rows); - } - // Accumulate rowCount for INSERT/UPDATE/DELETE statements - if (result.rowCount) { - totalRowCount += result.rowCount; - } + + // Add each statement result to the array + results.push({ + sql: statement, + rows: result.rows || [], + rowCount: result.rowCount ?? result.rows?.length ?? 0 + }); } await client.query('COMMIT'); } catch (error) { @@ -492,7 +494,7 @@ export class PostgresConnector implements Connector { throw error; } - return { rows: allRows, rowCount: totalRowCount }; + return results; } } finally { client.release(); diff --git a/src/connectors/sqlite/index.ts b/src/connectors/sqlite/index.ts index ae2a6ab..d39567b 100644 --- a/src/connectors/sqlite/index.ts +++ b/src/connectors/sqlite/index.ts @@ -10,7 +10,7 @@ import { ConnectorType, ConnectorRegistry, DSNParser, - SQLResult, + StatementResult, TableColumn, TableIndex, StoredProcedure, @@ -18,6 +18,7 @@ import { ConnectorConfig, } from "../interface.js"; import Database from "better-sqlite3"; +import { isReadOnlySQL } from "../../utils/allowed-keywords.js"; import { quoteIdentifier } from "../../utils/identifier-quoter.js"; import { SafeURL } from "../../utils/safe-url.js"; import { obfuscateDSNPassword } from "../../utils/dsn-obfuscate.js"; @@ -378,7 +379,7 @@ export class SQLiteConnector implements Connector { } - async executeSQL(sql: string, options: ExecuteOptions, parameters?: any[]): Promise { + async executeSQL(sql: string, options: ExecuteOptions, parameters?: any[]): Promise { if (!this.db) { throw new Error("Not connected to SQLite database"); } @@ -389,31 +390,23 @@ export class SQLiteConnector implements Connector { .map(statement => statement.trim()) .filter(statement => statement.length > 0); + const results: StatementResult[] = []; + if (statements.length === 1) { // Single statement - determine if it returns data let processedStatement = statements[0]; - const trimmedStatement = statements[0].toLowerCase().trim(); - const isReadStatement = trimmedStatement.startsWith('select') || - trimmedStatement.startsWith('with') || - trimmedStatement.startsWith('explain') || - trimmedStatement.startsWith('analyze') || - (trimmedStatement.startsWith('pragma') && - (trimmedStatement.includes('table_info') || - trimmedStatement.includes('index_info') || - trimmedStatement.includes('index_list') || - trimmedStatement.includes('foreign_key_list'))); // Apply maxRows limit to SELECT queries if specified (not PRAGMA/ANALYZE) if (options.maxRows) { processedStatement = SQLRowLimiter.applyMaxRows(processedStatement, options.maxRows); } - if (isReadStatement) { + if (isReadOnlySQL(statements[0], this.id)) { // Pass parameters if provided if (parameters && parameters.length > 0) { try { const rows = this.db.prepare(processedStatement).all(...parameters); - return { rows, rowCount: rows.length }; + results.push({ sql: statements[0], rows, rowCount: rows.length }); } catch (error) { console.error(`[SQLite executeSQL] ERROR: ${(error as Error).message}`); console.error(`[SQLite executeSQL] SQL: ${processedStatement}`); @@ -422,7 +415,7 @@ export class SQLiteConnector implements Connector { } } else { const rows = this.db.prepare(processedStatement).all(); - return { rows, rowCount: rows.length }; + results.push({ sql: statements[0], rows, rowCount: rows.length }); } } else { // Use run() for statements that don't return data @@ -439,7 +432,7 @@ export class SQLiteConnector implements Connector { } else { result = this.db.prepare(processedStatement).run(); } - return { rows: [], rowCount: result.changes }; + results.push({ sql: statements[0], rows: [], rowCount: result.changes }); } } else { // Multiple statements - parameters not supported for multi-statement queries @@ -447,48 +440,21 @@ export class SQLiteConnector implements Connector { throw new Error("Parameters are not supported for multi-statement queries in SQLite"); } - // Use native .exec() for optimal performance - // Note: .exec() doesn't return results, so we need to handle SELECT statements differently - const readStatements = []; - const writeStatements = []; - - // Separate read and write operations - for (const statement of statements) { - const trimmedStatement = statement.toLowerCase().trim(); - if (trimmedStatement.startsWith('select') || - trimmedStatement.startsWith('with') || - trimmedStatement.startsWith('explain') || - trimmedStatement.startsWith('analyze') || - (trimmedStatement.startsWith('pragma') && - (trimmedStatement.includes('table_info') || - trimmedStatement.includes('index_info') || - trimmedStatement.includes('index_list') || - trimmedStatement.includes('foreign_key_list')))) { - readStatements.push(statement); + // Execute each statement individually and collect results + for (let statement of statements) { + if (isReadOnlySQL(statement, this.id)) { + // Apply maxRows limit to SELECT queries if specified + const processedStatement = SQLRowLimiter.applyMaxRows(statement, options.maxRows); + const rows = this.db.prepare(processedStatement).all(); + results.push({ sql: statement, rows, rowCount: rows.length }); } else { - writeStatements.push(statement); + const result = this.db.prepare(statement).run(); + results.push({ sql: statement, rows: [], rowCount: result.changes }); } } - - // Execute write statements individually to track changes - let totalChanges = 0; - for (const statement of writeStatements) { - const result = this.db.prepare(statement).run(); - totalChanges += result.changes; - } - - // Execute read statements individually to collect results - let allRows: any[] = []; - for (let statement of readStatements) { - // Apply maxRows limit to SELECT queries if specified - statement = SQLRowLimiter.applyMaxRows(statement, options.maxRows); - const result = this.db.prepare(statement).all(); - allRows.push(...result); - } - - // rowCount is total changes for writes, plus rows returned for reads - return { rows: allRows, rowCount: totalChanges + allRows.length }; } + + return results; } catch (error) { throw error; } diff --git a/src/connectors/sqlserver/index.ts b/src/connectors/sqlserver/index.ts index c72793d..da31208 100644 --- a/src/connectors/sqlserver/index.ts +++ b/src/connectors/sqlserver/index.ts @@ -4,7 +4,7 @@ import { ConnectorType, ConnectorRegistry, DSNParser, - SQLResult, + StatementResult, TableColumn, TableIndex, StoredProcedure, @@ -501,63 +501,96 @@ export class SQLServerConnector implements Connector { } } - async executeSQL(sqlQuery: string, options: ExecuteOptions, parameters?: any[]): Promise { + async executeSQL(sqlQuery: string, options: ExecuteOptions, parameters?: any[]): Promise { if (!this.connection) { throw new Error("Not connected to SQL Server database"); } try { - // Apply maxRows limit to SELECT queries if specified - let processedSQL = sqlQuery; - if (options.maxRows) { - processedSQL = SQLRowLimiter.applyMaxRowsForSQLServer(sqlQuery, options.maxRows); - } + // Check if this is a multi-statement query + const statements = sqlQuery.split(';') + .map(statement => statement.trim()) + .filter(statement => statement.length > 0); + + if (statements.length === 1) { + // Single statement - apply maxRows if applicable + let processedSQL = statements[0]; + if (options.maxRows) { + processedSQL = SQLRowLimiter.applyMaxRowsForSQLServer(processedSQL, options.maxRows); + } - // Create request and add parameters if provided - const request = this.connection.request(); - if (parameters && parameters.length > 0) { - // SQL Server uses @p1, @p2, etc. for parameters - parameters.forEach((param, index) => { - const paramName = `p${index + 1}`; - // Infer SQL Server type from JavaScript type - if (typeof param === 'string') { - request.input(paramName, sql.VarChar, param); - } else if (typeof param === 'number') { - if (Number.isInteger(param)) { - request.input(paramName, sql.Int, param); + // Create request and add parameters if provided + const request = this.connection.request(); + if (parameters && parameters.length > 0) { + // SQL Server uses @p1, @p2, etc. for parameters + parameters.forEach((param, index) => { + const paramName = `p${index + 1}`; + // Infer SQL Server type from JavaScript type + if (typeof param === 'string') { + request.input(paramName, sql.VarChar, param); + } else if (typeof param === 'number') { + if (Number.isInteger(param)) { + request.input(paramName, sql.Int, param); + } else { + request.input(paramName, sql.Float, param); + } + } else if (typeof param === 'boolean') { + request.input(paramName, sql.Bit, param); + } else if (param === null || param === undefined) { + request.input(paramName, sql.VarChar, param); + } else if (Array.isArray(param)) { + // For arrays, convert to JSON string + request.input(paramName, sql.VarChar, JSON.stringify(param)); } else { - request.input(paramName, sql.Float, param); + // For objects, convert to JSON string + request.input(paramName, sql.VarChar, JSON.stringify(param)); } - } else if (typeof param === 'boolean') { - request.input(paramName, sql.Bit, param); - } else if (param === null || param === undefined) { - request.input(paramName, sql.VarChar, param); - } else if (Array.isArray(param)) { - // For arrays, convert to JSON string - request.input(paramName, sql.VarChar, JSON.stringify(param)); - } else { - // For objects, convert to JSON string - request.input(paramName, sql.VarChar, JSON.stringify(param)); + }); + } + + let result; + try { + result = await request.query(processedSQL); + } catch (error) { + if (parameters && parameters.length > 0) { + console.error(`[SQL Server executeSQL] ERROR: ${(error as Error).message}`); + console.error(`[SQL Server executeSQL] SQL: ${processedSQL}`); + console.error(`[SQL Server executeSQL] Parameters: ${JSON.stringify(parameters)}`); } - }); - } + throw error; + } - let result; - try { - result = await request.query(processedSQL); - } catch (error) { + // Return single statement result in array format + return [{ + sql: statements[0], + rows: result.recordset || [], + rowCount: result.rowsAffected[0] || 0, + }]; + } else { + // Multiple statements - parameters not supported for multi-statement queries if (parameters && parameters.length > 0) { - console.error(`[SQL Server executeSQL] ERROR: ${(error as Error).message}`); - console.error(`[SQL Server executeSQL] SQL: ${processedSQL}`); - console.error(`[SQL Server executeSQL] Parameters: ${JSON.stringify(parameters)}`); + throw new Error("Parameters are not supported for multi-statement queries in SQL Server"); } - throw error; - } - return { - rows: result.recordset || [], - rowCount: result.rowsAffected[0] || 0, - }; + // Execute all statements + const results: StatementResult[] = []; + + for (let statement of statements) { + // Apply maxRows limit to SELECT queries if specified + const processedStatement = SQLRowLimiter.applyMaxRowsForSQLServer(statement, options.maxRows); + + const result = await this.connection.request().query(processedStatement); + + // Add each statement result to the array + results.push({ + sql: statement, + rows: result.recordset || [], + rowCount: result.rowsAffected[0] || 0, + }); + } + + return results; + } } catch (error) { throw new Error(`Failed to execute query: ${(error as Error).message}`); } diff --git a/src/tools/execute-sql.ts b/src/tools/execute-sql.ts index d00d2fc..3612d71 100644 --- a/src/tools/execute-sql.ts +++ b/src/tools/execute-sql.ts @@ -75,10 +75,13 @@ export function createExecuteSqlToolHandler(sourceId?: string) { }; result = await connector.executeSQL(sql, executeOptions); - // Build response data + // Build response data with per-statement results const responseData = { - rows: result.rows, - count: result.rowCount, + statements: result.map((r: any) => ({ + sql: r.sql, + rows: r.rows, + count: r.rowCount, + })), source_id: effectiveSourceId, };