modified: frontend/src/components/SqlEditor.tsx

This commit is contained in:
2026-01-28 01:04:22 +03:00
parent a306477d5d
commit fba8069b13

View File

@@ -1,6 +1,6 @@
import { useRef, useEffect } from 'react'; import { useRef, useEffect } from 'react';
import Editor, { Monaco, loader } from '@monaco-editor/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'; import * as monacoEditor from 'monaco-editor';
// Configure loader to use local Monaco // Configure loader to use local Monaco
@@ -13,64 +13,141 @@ interface SqlEditorProps {
height?: string; height?: string;
} }
// Cache for table names with 5-minute expiration // Cache for schema with 5-minute expiration
interface TableCache { interface SchemaCache {
tables: string[]; schema: SchemaData;
timestamp: number; timestamp: number;
} }
const tablesCache = new Map<string, TableCache>(); const schemaCache = new Map<string, SchemaCache>();
const CACHE_DURATION = 5 * 60 * 1000; // 5 minutes in milliseconds const CACHE_DURATION = 5 * 60 * 1000; // 5 minutes
const getCachedTables = async (databaseId: string): Promise<string[]> => { const getCachedSchema = async (databaseId: string): Promise<SchemaData | null> => {
const cached = tablesCache.get(databaseId); const cached = schemaCache.get(databaseId);
const now = Date.now(); const now = Date.now();
// Return cached data if it exists and is not expired
if (cached && (now - cached.timestamp) < CACHE_DURATION) { if (cached && (now - cached.timestamp) < CACHE_DURATION) {
return cached.tables; return cached.schema;
} }
// Fetch fresh data
try { try {
const { data } = await databasesApi.getTables(databaseId); const { data } = await schemaApi.getSchema(databaseId);
tablesCache.set(databaseId, { if (data.success && data.data) {
tables: data.tables, schemaCache.set(databaseId, {
schema: data.data,
timestamp: now, timestamp: now,
}); });
return data.tables; return data.data;
}
return null;
} catch (error) { } catch (error) {
console.error('Failed to fetch table names:', error); console.error('Failed to fetch schema:', error);
// Return cached data if available, even if expired, as fallback return cached?.schema || null;
return cached?.tables || [];
} }
}; };
// Global flag to ensure we only register the completion provider once // Global state for completion provider
let completionProviderRegistered = false; 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<string, string> {
const aliases = new Map<string, string>();
// 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) { export default function SqlEditor({ value, onChange, databaseId, height }: SqlEditorProps) {
const editorRef = useRef<any>(null); const editorRef = useRef<any>(null);
const monacoRef = useRef<Monaco | null>(null); const monacoRef = useRef<Monaco | null>(null);
// Update current database ID when it changes // Fetch schema when database changes
useEffect(() => { useEffect(() => {
currentDatabaseId = databaseId; if (databaseId) {
getCachedSchema(databaseId).then(schema => {
currentSchema = schema;
});
} else {
currentSchema = null;
}
}, [databaseId]); }, [databaseId]);
const handleEditorDidMount = (editor: any, monaco: Monaco) => { const handleEditorDidMount = (editor: any, monaco: Monaco) => {
editorRef.current = editor; editorRef.current = editor;
monacoRef.current = monaco; monacoRef.current = monaco;
// Register completion provider only once
if (completionProviderRegistered) { if (completionProviderRegistered) {
return; return;
} }
completionProviderRegistered = true; completionProviderRegistered = true;
// Configure SQL language features
monaco.languages.registerCompletionItemProvider('sql', { monaco.languages.registerCompletionItemProvider('sql', {
triggerCharacters: ['.', ' '],
provideCompletionItems: async (model, position) => { provideCompletionItems: async (model, position) => {
const word = model.getWordUntilPosition(position); const word = model.getWordUntilPosition(position);
const range = { const range = {
@@ -80,42 +157,143 @@ export default function SqlEditor({ value, onChange, databaseId, height }: SqlEd
endColumn: word.endColumn, endColumn: word.endColumn,
}; };
let suggestions: any[] = [ 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 // SQL Keywords
{ label: 'SELECT', kind: monaco.languages.CompletionItemKind.Keyword, insertText: 'SELECT ', range }, const keywords = [
{ label: 'FROM', kind: monaco.languages.CompletionItemKind.Keyword, insertText: 'FROM ', range }, 'SELECT', 'FROM', 'WHERE', 'JOIN', 'LEFT JOIN', 'RIGHT JOIN', 'INNER JOIN',
{ label: 'WHERE', kind: monaco.languages.CompletionItemKind.Keyword, insertText: 'WHERE ', range }, 'FULL OUTER JOIN', 'CROSS JOIN', 'ON', 'AND', 'OR', 'NOT', 'IN', 'LIKE',
{ label: 'JOIN', kind: monaco.languages.CompletionItemKind.Keyword, insertText: 'JOIN ', range }, 'BETWEEN', 'IS NULL', 'IS NOT NULL', 'GROUP BY', 'ORDER BY', 'HAVING',
{ label: 'LEFT JOIN', kind: monaco.languages.CompletionItemKind.Keyword, insertText: 'LEFT JOIN ', range }, 'LIMIT', 'OFFSET', 'UNION', 'UNION ALL', 'EXCEPT', 'INTERSECT',
{ label: 'INNER JOIN', kind: monaco.languages.CompletionItemKind.Keyword, insertText: 'INNER JOIN ', range }, 'INSERT INTO', 'VALUES', 'UPDATE', 'SET', 'DELETE FROM',
{ label: 'GROUP BY', kind: monaco.languages.CompletionItemKind.Keyword, insertText: 'GROUP BY ', range }, 'CREATE TABLE', 'ALTER TABLE', 'DROP TABLE', 'TRUNCATE',
{ label: 'ORDER BY', kind: monaco.languages.CompletionItemKind.Keyword, insertText: 'ORDER BY ', range }, 'DISTINCT', 'AS', 'ASC', 'DESC', 'NULLS FIRST', 'NULLS LAST',
{ label: 'HAVING', kind: monaco.languages.CompletionItemKind.Keyword, insertText: 'HAVING ', range }, 'CASE', 'WHEN', 'THEN', 'ELSE', 'END', 'COALESCE', 'NULLIF',
{ label: 'LIMIT', kind: monaco.languages.CompletionItemKind.Keyword, insertText: 'LIMIT ', range }, 'EXISTS', 'NOT EXISTS', 'ANY', 'ALL',
{ 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 },
]; ];
// Fetch table names from database if databaseId is provided (with caching) suggestions = keywords.map(kw => ({
if (currentDatabaseId) { label: kw,
const tables = await getCachedTables(currentDatabaseId); kind: monaco.languages.CompletionItemKind.Keyword,
const tableSuggestions = tables.map(table => ({ insertText: kw + ' ',
label: table,
kind: monaco.languages.CompletionItemKind.Class,
insertText: table,
detail: 'Table',
range, range,
sortText: '2' + kw,
})); }));
suggestions = [...suggestions, ...tableSuggestions];
// 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 { suggestions };