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 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<string, TableCache>();
const CACHE_DURATION = 5 * 60 * 1000; // 5 minutes in milliseconds
const schemaCache = new Map<string, SchemaCache>();
const CACHE_DURATION = 5 * 60 * 1000; // 5 minutes
const getCachedTables = async (databaseId: string): Promise<string[]> => {
const cached = tablesCache.get(databaseId);
const getCachedSchema = async (databaseId: string): Promise<SchemaData | null> => {
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<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) {
const editorRef = useRef<any>(null);
const monacoRef = useRef<Monaco | null>(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 };