import { useRef, useEffect } from 'react'; import Editor, { Monaco, loader } from '@monaco-editor/react'; import { schemaApi, TableInfo, SchemaData } from '@/services/api'; import * as monacoEditor from 'monaco-editor'; // Configure loader to use local Monaco loader.config({ monaco: monacoEditor }); interface SqlEditorProps { value: string; onChange: (value: string) => void; databaseId?: string; height?: string; tabId?: string; } // Cache for schema with 5-minute expiration interface SchemaCache { schema: SchemaData; timestamp: number; } const schemaCache = new Map(); const CACHE_DURATION = 5 * 60 * 1000; // 5 minutes const getCachedSchema = async (databaseId: string): Promise => { const cached = schemaCache.get(databaseId); const now = Date.now(); if (cached && (now - cached.timestamp) < CACHE_DURATION) { return cached.schema; } try { 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 schema:', error); return cached?.schema || null; } }; // Global state for completion provider let completionProviderRegistered = false; 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, sql: string ): any[] { const suggestions: any[] = []; const aliases = parseTableAliases(sql); // Create reverse alias map: tableName -> alias (or tableName if no alias) const tableToAlias = new Map(); for (const [alias, tableName] of aliases) { // Prefer shorter alias over table name const existing = tableToAlias.get(tableName); if (!existing || alias.length < existing.length) { tableToAlias.set(tableName, alias); } } 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)) { // Use alias if available, otherwise table name const sourceAlias = tableToAlias.get(table.name) || table.name; const targetAlias = tableToAlias.get(refTable.name) || refTable.name; const joinCondition = `${sourceAlias}.${fk.column} = ${targetAlias}.${fk.references_column}`; suggestions.push({ label: joinCondition, kind: monaco.languages.CompletionItemKind.Snippet, insertText: joinCondition, detail: `FK: ${table.name}.${fk.column} → ${refTable.name}.${fk.references_column}`, documentation: `Foreign key relationship between ${table.name} and ${refTable.name}`, range, sortText: '0' + joinCondition, }); } } } return suggestions; } export default function SqlEditor({ value, onChange, databaseId, height, tabId }: SqlEditorProps) { const editorRef = useRef(null); const monacoRef = useRef(null); // Fetch schema when database changes useEffect(() => { if (databaseId) { getCachedSchema(databaseId).then(schema => { currentSchema = schema; }); } else { currentSchema = null; } }, [databaseId]); const handleEditorDidMount = (editor: any, monaco: Monaco) => { editorRef.current = editor; monacoRef.current = monaco; if (completionProviderRegistered) { return; } completionProviderRegistered = true; monaco.languages.registerCompletionItemProvider('sql', { triggerCharacters: ['.', ' '], provideCompletionItems: async (model, position) => { const word = model.getWordUntilPosition(position); const range = { startLineNumber: position.lineNumber, endLineNumber: position.lineNumber, startColumn: word.startColumn, endColumn: word.endColumn, }; 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) // Match: "ON ", "ON ", "on ", after JOIN ... ON const fullText = model.getValue(); const onMatch = textBeforeCursor.match(/\bON\s*$/i) || textUntilPosition.match(/\bJOIN\s+\w+\s+\w*\s+ON\s*$/i); if (onMatch && currentSchema) { const tablesInQuery = findTablesInQuery(fullText, currentSchema); const fkSuggestions = getFkSuggestions(tablesInQuery, currentSchema, monaco, range, fullText); // Add FK suggestions to the top, but also include other suggestions if (fkSuggestions.length > 0) { suggestions = [...fkSuggestions, ...suggestions]; return { suggestions }; } } // 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', ]; 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 }; }, }); }; return (
onChange(value || '')} onMount={handleEditorDidMount} theme="vs-light" options={{ minimap: { enabled: false }, fontSize: 14, lineNumbers: 'on', scrollBeyondLastLine: false, automaticLayout: true, tabSize: 2, wordWrap: 'on', formatOnPaste: true, formatOnType: true, suggestOnTriggerCharacters: true, quickSuggestions: true, acceptSuggestionOnEnter: 'on', }} />
); }