modified: frontend/src/components/SqlEditor.tsx
This commit is contained in:
@@ -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 };
|
||||
|
||||
Reference in New Issue
Block a user