353 lines
12 KiB
TypeScript
353 lines
12 KiB
TypeScript
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<string, SchemaCache>();
|
|
const CACHE_DURATION = 5 * 60 * 1000; // 5 minutes
|
|
|
|
const getCachedSchema = async (databaseId: string): Promise<SchemaData | null> => {
|
|
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<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,
|
|
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<string, string>();
|
|
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<any>(null);
|
|
const monacoRef = useRef<Monaco | null>(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 (
|
|
<div className="border border-gray-300 rounded-lg overflow-hidden" style={{ height: height || '100%' }}>
|
|
<Editor
|
|
height="100%"
|
|
language="sql"
|
|
path={tabId}
|
|
value={value}
|
|
onChange={(value) => 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',
|
|
}}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|