Files
api_builder/frontend/src/components/SqlEditor.tsx

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>
);
}