diff --git a/frontend/src/components/SqlEditor.tsx b/frontend/src/components/SqlEditor.tsx index 94156ed..1cd1860 100644 --- a/frontend/src/components/SqlEditor.tsx +++ b/frontend/src/components/SqlEditor.tsx @@ -1,6 +1,6 @@ import { useRef, useEffect } from 'react'; import Editor, { Monaco, loader } from '@monaco-editor/react'; -import { databasesApi } from '@/services/api'; +import { schemaApi, TableInfo, SchemaData } from '@/services/api'; import * as monacoEditor from 'monaco-editor'; // Configure loader to use local Monaco @@ -13,64 +13,141 @@ interface SqlEditorProps { height?: string; } -// Cache for table names with 5-minute expiration -interface TableCache { - tables: string[]; +// Cache for schema with 5-minute expiration +interface SchemaCache { + schema: SchemaData; timestamp: number; } -const tablesCache = new Map(); -const CACHE_DURATION = 5 * 60 * 1000; // 5 minutes in milliseconds +const schemaCache = new Map(); +const CACHE_DURATION = 5 * 60 * 1000; // 5 minutes -const getCachedTables = async (databaseId: string): Promise => { - const cached = tablesCache.get(databaseId); +const getCachedSchema = async (databaseId: string): Promise => { + const cached = schemaCache.get(databaseId); const now = Date.now(); - // Return cached data if it exists and is not expired if (cached && (now - cached.timestamp) < CACHE_DURATION) { - return cached.tables; + return cached.schema; } - // Fetch fresh data try { - const { data } = await databasesApi.getTables(databaseId); - tablesCache.set(databaseId, { - tables: data.tables, - timestamp: now, - }); - return data.tables; + const { data } = await schemaApi.getSchema(databaseId); + if (data.success && data.data) { + schemaCache.set(databaseId, { + schema: data.data, + timestamp: now, + }); + return data.data; + } + return null; } catch (error) { - console.error('Failed to fetch table names:', error); - // Return cached data if available, even if expired, as fallback - return cached?.tables || []; + console.error('Failed to fetch schema:', error); + return cached?.schema || null; } }; -// Global flag to ensure we only register the completion provider once +// Global state for completion provider let completionProviderRegistered = false; -let currentDatabaseId: string | undefined; +let currentSchema: SchemaData | null = null; + +// Parse SQL to find table aliases (e.g., FROM users u, JOIN orders o) +function parseTableAliases(sql: string): Map { + const aliases = new Map(); + + // Match patterns like: FROM table_name alias, FROM table_name AS alias + // JOIN table_name alias, JOIN table_name AS alias + const patterns = [ + /(?:FROM|JOIN)\s+(\w+)(?:\s+(?:AS\s+)?(\w+))?/gi, + /,\s*(\w+)(?:\s+(?:AS\s+)?(\w+))?/gi, + ]; + + for (const pattern of patterns) { + let match; + while ((match = pattern.exec(sql)) !== null) { + const tableName = match[1].toLowerCase(); + const alias = match[2]?.toLowerCase(); + if (alias && alias !== 'on' && alias !== 'where' && alias !== 'join' && alias !== 'left' && alias !== 'inner' && alias !== 'right') { + aliases.set(alias, tableName); + } + // Also map table name to itself for direct references + aliases.set(tableName, tableName); + } + } + + return aliases; +} + +// Find tables mentioned in the query +function findTablesInQuery(sql: string, schema: SchemaData): TableInfo[] { + const tables: TableInfo[] = []; + const sqlLower = sql.toLowerCase(); + + for (const table of schema.tables) { + if (sqlLower.includes(table.name.toLowerCase())) { + tables.push(table); + } + } + + return tables; +} + +// Get FK suggestions for ON clause +function getFkSuggestions( + tablesInQuery: TableInfo[], + schema: SchemaData, + monaco: Monaco, + range: any +): any[] { + const suggestions: any[] = []; + + for (const table of tablesInQuery) { + for (const fk of table.foreign_keys) { + // Find the referenced table + const refTable = schema.tables.find(t => t.name === fk.references_table); + if (refTable && tablesInQuery.some(t => t.name === refTable.name)) { + const joinCondition = `${table.name}.${fk.column} = ${refTable.name}.${fk.references_column}`; + suggestions.push({ + label: joinCondition, + kind: monaco.languages.CompletionItemKind.Snippet, + insertText: joinCondition, + detail: `FK: ${table.name} → ${refTable.name}`, + documentation: `Foreign key relationship`, + range, + sortText: '0' + joinCondition, // Sort FK suggestions first + }); + } + } + } + + return suggestions; +} export default function SqlEditor({ value, onChange, databaseId, height }: SqlEditorProps) { const editorRef = useRef(null); const monacoRef = useRef(null); - // Update current database ID when it changes + // Fetch schema when database changes useEffect(() => { - currentDatabaseId = databaseId; + if (databaseId) { + getCachedSchema(databaseId).then(schema => { + currentSchema = schema; + }); + } else { + currentSchema = null; + } }, [databaseId]); const handleEditorDidMount = (editor: any, monaco: Monaco) => { editorRef.current = editor; monacoRef.current = monaco; - // Register completion provider only once if (completionProviderRegistered) { return; } completionProviderRegistered = true; - // Configure SQL language features monaco.languages.registerCompletionItemProvider('sql', { + triggerCharacters: ['.', ' '], provideCompletionItems: async (model, position) => { const word = model.getWordUntilPosition(position); const range = { @@ -80,42 +157,143 @@ export default function SqlEditor({ value, onChange, databaseId, height }: SqlEd endColumn: word.endColumn, }; - let suggestions: any[] = [ - // SQL Keywords - { label: 'SELECT', kind: monaco.languages.CompletionItemKind.Keyword, insertText: 'SELECT ', range }, - { label: 'FROM', kind: monaco.languages.CompletionItemKind.Keyword, insertText: 'FROM ', range }, - { label: 'WHERE', kind: monaco.languages.CompletionItemKind.Keyword, insertText: 'WHERE ', range }, - { label: 'JOIN', kind: monaco.languages.CompletionItemKind.Keyword, insertText: 'JOIN ', range }, - { label: 'LEFT JOIN', kind: monaco.languages.CompletionItemKind.Keyword, insertText: 'LEFT JOIN ', range }, - { label: 'INNER JOIN', kind: monaco.languages.CompletionItemKind.Keyword, insertText: 'INNER JOIN ', range }, - { label: 'GROUP BY', kind: monaco.languages.CompletionItemKind.Keyword, insertText: 'GROUP BY ', range }, - { label: 'ORDER BY', kind: monaco.languages.CompletionItemKind.Keyword, insertText: 'ORDER BY ', range }, - { label: 'HAVING', kind: monaco.languages.CompletionItemKind.Keyword, insertText: 'HAVING ', range }, - { label: 'LIMIT', kind: monaco.languages.CompletionItemKind.Keyword, insertText: 'LIMIT ', range }, - { label: 'OFFSET', kind: monaco.languages.CompletionItemKind.Keyword, insertText: 'OFFSET ', range }, - { label: 'AND', kind: monaco.languages.CompletionItemKind.Keyword, insertText: 'AND ', range }, - { label: 'OR', kind: monaco.languages.CompletionItemKind.Keyword, insertText: 'OR ', range }, - { label: 'NOT', kind: monaco.languages.CompletionItemKind.Keyword, insertText: 'NOT ', range }, - { label: 'IN', kind: monaco.languages.CompletionItemKind.Keyword, insertText: 'IN ', range }, - { label: 'LIKE', kind: monaco.languages.CompletionItemKind.Keyword, insertText: 'LIKE ', range }, - { label: 'COUNT', kind: monaco.languages.CompletionItemKind.Function, insertText: 'COUNT()', range }, - { label: 'SUM', kind: monaco.languages.CompletionItemKind.Function, insertText: 'SUM()', range }, - { label: 'AVG', kind: monaco.languages.CompletionItemKind.Function, insertText: 'AVG()', range }, - { label: 'MAX', kind: monaco.languages.CompletionItemKind.Function, insertText: 'MAX()', range }, - { label: 'MIN', kind: monaco.languages.CompletionItemKind.Function, insertText: 'MIN()', range }, + const textUntilPosition = model.getValueInRange({ + startLineNumber: 1, + startColumn: 1, + endLineNumber: position.lineNumber, + endColumn: position.column, + }); + + const lineContent = model.getLineContent(position.lineNumber); + const textBeforeCursor = lineContent.substring(0, position.column - 1); + + let suggestions: any[] = []; + + // Check if we're after a dot (table.column completion) + const dotMatch = textBeforeCursor.match(/(\w+)\.\s*$/); + if (dotMatch && currentSchema) { + const prefix = dotMatch[1].toLowerCase(); + const aliases = parseTableAliases(textUntilPosition); + const tableName = aliases.get(prefix) || prefix; + + const table = currentSchema.tables.find(t => t.name.toLowerCase() === tableName); + if (table) { + // Return only columns for this table + return { + suggestions: table.columns.map(col => ({ + label: col.name, + kind: monaco.languages.CompletionItemKind.Field, + insertText: col.name, + detail: `${col.type}${col.is_primary ? ' (PK)' : ''}${!col.nullable ? ' NOT NULL' : ''}`, + documentation: col.comment || undefined, + range, + sortText: col.is_primary ? '0' + col.name : '1' + col.name, + })), + }; + } + } + + // Check if we're after ON keyword (FK suggestions) + const onMatch = textBeforeCursor.match(/\bON\s+$/i); + if (onMatch && currentSchema) { + const tablesInQuery = findTablesInQuery(textUntilPosition, currentSchema); + const fkSuggestions = getFkSuggestions(tablesInQuery, currentSchema, monaco, range); + if (fkSuggestions.length > 0) { + return { suggestions: fkSuggestions }; + } + } + + // SQL Keywords + const keywords = [ + 'SELECT', 'FROM', 'WHERE', 'JOIN', 'LEFT JOIN', 'RIGHT JOIN', 'INNER JOIN', + 'FULL OUTER JOIN', 'CROSS JOIN', 'ON', 'AND', 'OR', 'NOT', 'IN', 'LIKE', + 'BETWEEN', 'IS NULL', 'IS NOT NULL', 'GROUP BY', 'ORDER BY', 'HAVING', + 'LIMIT', 'OFFSET', 'UNION', 'UNION ALL', 'EXCEPT', 'INTERSECT', + 'INSERT INTO', 'VALUES', 'UPDATE', 'SET', 'DELETE FROM', + 'CREATE TABLE', 'ALTER TABLE', 'DROP TABLE', 'TRUNCATE', + 'DISTINCT', 'AS', 'ASC', 'DESC', 'NULLS FIRST', 'NULLS LAST', + 'CASE', 'WHEN', 'THEN', 'ELSE', 'END', 'COALESCE', 'NULLIF', + 'EXISTS', 'NOT EXISTS', 'ANY', 'ALL', ]; - // Fetch table names from database if databaseId is provided (with caching) - if (currentDatabaseId) { - const tables = await getCachedTables(currentDatabaseId); - const tableSuggestions = tables.map(table => ({ - label: table, - kind: monaco.languages.CompletionItemKind.Class, - insertText: table, - detail: 'Table', - range, - })); - suggestions = [...suggestions, ...tableSuggestions]; + suggestions = keywords.map(kw => ({ + label: kw, + kind: monaco.languages.CompletionItemKind.Keyword, + insertText: kw + ' ', + range, + sortText: '2' + kw, + })); + + // SQL Functions + const functions = [ + { name: 'COUNT', snippet: 'COUNT($0)' }, + { name: 'SUM', snippet: 'SUM($0)' }, + { name: 'AVG', snippet: 'AVG($0)' }, + { name: 'MAX', snippet: 'MAX($0)' }, + { name: 'MIN', snippet: 'MIN($0)' }, + { name: 'COALESCE', snippet: 'COALESCE($1, $0)' }, + { name: 'NULLIF', snippet: 'NULLIF($1, $0)' }, + { name: 'CAST', snippet: 'CAST($1 AS $0)' }, + { name: 'CONCAT', snippet: 'CONCAT($0)' }, + { name: 'SUBSTRING', snippet: 'SUBSTRING($1 FROM $2 FOR $0)' }, + { name: 'TRIM', snippet: 'TRIM($0)' }, + { name: 'UPPER', snippet: 'UPPER($0)' }, + { name: 'LOWER', snippet: 'LOWER($0)' }, + { name: 'LENGTH', snippet: 'LENGTH($0)' }, + { name: 'NOW', snippet: 'NOW()' }, + { name: 'CURRENT_DATE', snippet: 'CURRENT_DATE' }, + { name: 'CURRENT_TIMESTAMP', snippet: 'CURRENT_TIMESTAMP' }, + { name: 'DATE_TRUNC', snippet: "DATE_TRUNC('$1', $0)" }, + { name: 'EXTRACT', snippet: 'EXTRACT($1 FROM $0)' }, + { name: 'TO_CHAR', snippet: "TO_CHAR($1, '$0')" }, + { name: 'TO_DATE', snippet: "TO_DATE($1, '$0')" }, + { name: 'ARRAY_AGG', snippet: 'ARRAY_AGG($0)' }, + { name: 'STRING_AGG', snippet: "STRING_AGG($1, '$0')" }, + { name: 'JSON_AGG', snippet: 'JSON_AGG($0)' }, + { name: 'ROW_NUMBER', snippet: 'ROW_NUMBER() OVER ($0)' }, + { name: 'RANK', snippet: 'RANK() OVER ($0)' }, + { name: 'DENSE_RANK', snippet: 'DENSE_RANK() OVER ($0)' }, + { name: 'LAG', snippet: 'LAG($1) OVER ($0)' }, + { name: 'LEAD', snippet: 'LEAD($1) OVER ($0)' }, + ]; + + suggestions.push(...functions.map(fn => ({ + label: fn.name, + kind: monaco.languages.CompletionItemKind.Function, + insertText: fn.snippet, + insertTextRules: monaco.languages.CompletionItemInsertTextRule.InsertAsSnippet, + detail: 'Function', + range, + sortText: '3' + fn.name, + }))); + + // Add tables and columns from schema + if (currentSchema) { + // Tables + for (const table of currentSchema.tables) { + suggestions.push({ + label: table.name, + kind: monaco.languages.CompletionItemKind.Class, + insertText: table.name, + detail: `Table (${table.columns.length} columns)`, + documentation: table.comment || undefined, + range, + sortText: '1' + table.name, + }); + + // All columns (with table prefix for context) + for (const col of table.columns) { + suggestions.push({ + label: `${table.name}.${col.name}`, + kind: monaco.languages.CompletionItemKind.Field, + insertText: `${table.name}.${col.name}`, + detail: col.type, + documentation: col.comment || undefined, + range, + sortText: '4' + table.name + col.name, + }); + } + } } return { suggestions };