modified: frontend/src/components/SqlEditor.tsx
This commit is contained in:
@@ -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, {
|
||||||
timestamp: now,
|
schema: data.data,
|
||||||
});
|
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({
|
||||||
// SQL Keywords
|
startLineNumber: 1,
|
||||||
{ label: 'SELECT', kind: monaco.languages.CompletionItemKind.Keyword, insertText: 'SELECT ', range },
|
startColumn: 1,
|
||||||
{ label: 'FROM', kind: monaco.languages.CompletionItemKind.Keyword, insertText: 'FROM ', range },
|
endLineNumber: position.lineNumber,
|
||||||
{ label: 'WHERE', kind: monaco.languages.CompletionItemKind.Keyword, insertText: 'WHERE ', range },
|
endColumn: position.column,
|
||||||
{ 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 },
|
const lineContent = model.getLineContent(position.lineNumber);
|
||||||
{ label: 'GROUP BY', kind: monaco.languages.CompletionItemKind.Keyword, insertText: 'GROUP BY ', range },
|
const textBeforeCursor = lineContent.substring(0, position.column - 1);
|
||||||
{ label: 'ORDER BY', kind: monaco.languages.CompletionItemKind.Keyword, insertText: 'ORDER BY ', range },
|
|
||||||
{ label: 'HAVING', kind: monaco.languages.CompletionItemKind.Keyword, insertText: 'HAVING ', range },
|
let suggestions: any[] = [];
|
||||||
{ label: 'LIMIT', kind: monaco.languages.CompletionItemKind.Keyword, insertText: 'LIMIT ', range },
|
|
||||||
{ label: 'OFFSET', kind: monaco.languages.CompletionItemKind.Keyword, insertText: 'OFFSET ', range },
|
// Check if we're after a dot (table.column completion)
|
||||||
{ label: 'AND', kind: monaco.languages.CompletionItemKind.Keyword, insertText: 'AND ', range },
|
const dotMatch = textBeforeCursor.match(/(\w+)\.\s*$/);
|
||||||
{ label: 'OR', kind: monaco.languages.CompletionItemKind.Keyword, insertText: 'OR ', range },
|
if (dotMatch && currentSchema) {
|
||||||
{ label: 'NOT', kind: monaco.languages.CompletionItemKind.Keyword, insertText: 'NOT ', range },
|
const prefix = dotMatch[1].toLowerCase();
|
||||||
{ label: 'IN', kind: monaco.languages.CompletionItemKind.Keyword, insertText: 'IN ', range },
|
const aliases = parseTableAliases(textUntilPosition);
|
||||||
{ label: 'LIKE', kind: monaco.languages.CompletionItemKind.Keyword, insertText: 'LIKE ', range },
|
const tableName = aliases.get(prefix) || prefix;
|
||||||
{ label: 'COUNT', kind: monaco.languages.CompletionItemKind.Function, insertText: 'COUNT()', range },
|
|
||||||
{ label: 'SUM', kind: monaco.languages.CompletionItemKind.Function, insertText: 'SUM()', range },
|
const table = currentSchema.tables.find(t => t.name.toLowerCase() === tableName);
|
||||||
{ label: 'AVG', kind: monaco.languages.CompletionItemKind.Function, insertText: 'AVG()', range },
|
if (table) {
|
||||||
{ label: 'MAX', kind: monaco.languages.CompletionItemKind.Function, insertText: 'MAX()', range },
|
// Return only columns for this table
|
||||||
{ label: 'MIN', kind: monaco.languages.CompletionItemKind.Function, insertText: 'MIN()', range },
|
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)
|
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,
|
range,
|
||||||
kind: monaco.languages.CompletionItemKind.Class,
|
sortText: '2' + kw,
|
||||||
insertText: table,
|
}));
|
||||||
detail: 'Table',
|
|
||||||
range,
|
// SQL Functions
|
||||||
}));
|
const functions = [
|
||||||
suggestions = [...suggestions, ...tableSuggestions];
|
{ 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 };
|
||||||
|
|||||||
Reference in New Issue
Block a user