import React, { useRef, useEffect, useCallback } from 'react'; import { EditorState, Compartment } from '@codemirror/state'; import type { Extension } from '@codemirror/state'; import { EditorView, keymap, lineNumbers, placeholder as cmPlaceholder } from '@codemirror/view'; import { sql, SQLDialect } from '@codemirror/lang-sql'; import { oneDark } from '@codemirror/theme-one-dark'; import { defaultKeymap, history, historyKeymap } from '@codemirror/commands'; import { syntaxHighlighting, defaultHighlightStyle, bracketMatching, } from '@codemirror/language'; import { closeBrackets, closeBracketsKeymap } from '@codemirror/autocomplete'; import { searchKeymap, highlightSelectionMatches } from '@codemirror/search'; // --------------------------------------------------------------------------- // Types // --------------------------------------------------------------------------- export interface SqlEditorProps { value: string; onChange: (val: string) => void; placeholder?: string; language?: 'sql' | 'redis' | 'mongodb'; height?: string; readOnly?: boolean; onExecute?: () => void; } // --------------------------------------------------------------------------- // Language configs // --------------------------------------------------------------------------- const REDIS_KEYWORDS = [ 'GET', 'SET', 'KEYS', 'HGET', 'HSET', 'HGETALL', 'HDEL', 'HMSET', 'ZADD', 'ZRANGE', 'ZREM', 'ZRANK', 'ZSCORE', 'ZCARD', 'LPUSH', 'RPUSH', 'LPOP', 'RPOP', 'LRANGE', 'LLEN', 'SADD', 'SMEMBERS', 'SREM', 'SCARD', 'SISMEMBER', 'INFO', 'CLIENT', 'CONFIG', 'DEL', 'EXISTS', 'EXPIRE', 'TTL', 'TYPE', 'RENAME', 'DBSIZE', 'FLUSHDB', 'FLUSHALL', 'PING', 'ECHO', 'SELECT', 'MOVE', 'OBJECT', 'SORT', 'MULTI', 'EXEC', 'DISCARD', 'WATCH', 'UNWATCH', 'PUBLISH', 'SUBSCRIBE', 'UNSUBSCRIBE', 'PSUBSCRIBE', 'SCAN', 'HSCAN', 'SSCAN', 'ZSCAN', 'INCR', 'DECR', 'INCRBY', 'DECRBY', 'INCRBYFLOAT', 'APPEND', 'STRLEN', 'MGET', 'MSET', 'SETNX', 'SETEX', 'AUTH', 'QUIT', 'BGSAVE', 'SAVE', 'LASTSAVE', 'SLOWLOG', 'MONITOR', 'DEBUG', 'SHUTDOWN', 'SLAVEOF', 'REPLICAOF', ]; const MONGODB_KEYWORDS = [ // CRUD 'find', 'findOne', 'insertOne', 'insertMany', 'updateOne', 'updateMany', 'deleteOne', 'deleteMany', 'replaceOne', 'findOneAndUpdate', 'findOneAndDelete', 'findOneAndReplace', 'bulkWrite', 'aggregate', 'countDocuments', 'distinct', // Aggregation stages 'match', 'group', 'project', 'sort', 'limit', 'skip', 'unwind', 'lookup', 'addFields', 'set', 'unset', 'facet', 'bucket', 'bucketAuto', 'sortByCount', 'graphLookup', 'out', 'merge', 'replaceRoot', 'sample', 'count', // Operators 'eq', 'ne', 'gt', 'gte', 'lt', 'lte', 'in', 'nin', 'and', 'or', 'not', 'nor', 'exists', 'type', 'regex', 'expr', 'cond', 'switch', 'if', 'then', 'else', 'let', 'map', 'filter', 'reduce', 'range', 'concatArrays', 'avg', 'sum', 'min', 'max', 'push', 'addToSet', 'first', 'last', // Index / admin 'createIndex', 'dropIndex', 'getIndexes', 'explain', 'hint', // Collection / DB 'use', 'show', 'db', 'collection', 'collections', 'databases', // JS keywords commonly used 'function', 'return', 'var', 'let', 'const', 'if', 'else', 'for', 'while', 'true', 'false', 'null', 'undefined', 'new', 'this', 'typeof', 'instanceof', 'ObjectId', 'Date', 'ISODate', 'NumberLong', 'NumberInt', 'NumberDecimal', 'Regex', 'BinData', 'Timestamp', 'MinKey', 'MaxKey', 'Symbol', 'DBRef', ]; function getSQLExtension(language: SqlEditorProps['language']): Extension { const lang = language ?? 'sql'; if (lang === 'redis') { const dialect = SQLDialect.define({ keywords: REDIS_KEYWORDS.join(' '), }); return sql({ dialect }); } if (lang === 'mongodb') { const dialect = SQLDialect.define({ keywords: MONGODB_KEYWORDS.join(' '), }); return sql({ dialect }); } return sql(); } // --------------------------------------------------------------------------- // Custom theme to match app CSS variables // --------------------------------------------------------------------------- const appTheme = EditorView.theme({ '&': { fontSize: '13px', backgroundColor: 'var(--dg-bg-primary)', color: 'var(--dg-text-primary)', border: '1px solid var(--dg-border)', borderRadius: '6px', }, '.cm-content': { caretColor: 'var(--dg-text-primary)', fontFamily: "'JetBrains Mono', 'Fira Code', 'Cascadia Code', 'Consolas', monospace", }, '.cm-cursor': { borderLeftColor: 'var(--dg-text-primary)', }, '&.cm-focused .cm-selectionBackground, .cm-selectionBackground, .cm-content ::selection': { backgroundColor: 'rgba(66, 133, 244, 0.3) !important', }, '.cm-gutters': { backgroundColor: 'var(--dg-bg-tertiary)', color: 'var(--dg-text-muted)', border: 'none', borderRight: '1px solid var(--dg-border)', }, '.cm-activeLineGutter': { backgroundColor: 'var(--dg-bg-tertiary)', }, '.cm-activeLine': { backgroundColor: 'rgba(255, 255, 255, 0.04)', }, '.cm-placeholder': { color: 'var(--dg-text-muted)', fontStyle: 'italic', }, '.cm-scroller': { overflow: 'auto', }, }); // --------------------------------------------------------------------------- // formatSQL helper // --------------------------------------------------------------------------- const SQL_BREAK_KEYWORDS = [ 'SELECT', 'FROM', 'WHERE', 'ORDER BY', 'GROUP BY', 'HAVING', 'LIMIT', 'OFFSET', 'JOIN', 'INNER JOIN', 'LEFT JOIN', 'RIGHT JOIN', 'FULL JOIN', 'CROSS JOIN', 'LEFT OUTER JOIN', 'RIGHT OUTER JOIN', 'FULL OUTER JOIN', 'ON', 'UNION', 'UNION ALL', 'INTERSECT', 'EXCEPT', 'INSERT INTO', 'VALUES', 'UPDATE', 'SET', 'DELETE FROM', 'CREATE TABLE', 'ALTER TABLE', 'DROP TABLE', ]; const SQL_KEYWORDS_UPPER = new Set([ 'SELECT', 'FROM', 'WHERE', 'ORDER', 'BY', 'GROUP', 'HAVING', 'LIMIT', 'OFFSET', 'JOIN', 'INNER', 'LEFT', 'RIGHT', 'FULL', 'OUTER', 'CROSS', 'ON', 'AND', 'OR', 'NOT', 'IN', 'IS', 'NULL', 'LIKE', 'BETWEEN', 'AS', 'ASC', 'DESC', 'DISTINCT', 'ALL', 'CASE', 'WHEN', 'THEN', 'ELSE', 'END', 'EXISTS', 'UNION', 'INTERSECT', 'EXCEPT', 'INSERT', 'INTO', 'VALUES', 'UPDATE', 'SET', 'DELETE', 'CREATE', 'ALTER', 'DROP', 'TABLE', 'INDEX', 'VIEW', 'TRIGGER', 'PROCEDURE', 'FUNCTION', 'PRIMARY', 'KEY', 'FOREIGN', 'REFERENCES', 'UNIQUE', 'CHECK', 'DEFAULT', 'GRANT', 'REVOKE', 'COMMIT', 'ROLLBACK', 'BEGIN', 'TRANSACTION', 'IF', 'ELSE', 'WHILE', 'FOR', 'RETURN', 'COUNT', 'SUM', 'AVG', 'MIN', 'MAX', 'TRUE', 'FALSE', 'BOOLEAN', 'INTEGER', 'BIGINT', 'FLOAT', 'DOUBLE', 'VARCHAR', 'TEXT', 'DATE', 'TIMESTAMP', 'BLOB', 'JSON', 'TRUNCATE', 'EXPLAIN', 'ANALYZE', 'SHOW', 'DESCRIBE', 'USE', 'DATABASE', ]); export function formatSQL(input: string): string { // Normalise whitespace let formatted = input.replace(/\s+/g, ' ').trim(); // Uppercase SQL keywords (word-boundary aware) formatted = formatted.replace(/\b([A-Za-z_]+)\b/g, (match) => { const upper = match.toUpperCase(); return SQL_KEYWORDS_UPPER.has(upper) ? upper : match; }); // Insert newlines before major clauses // We iterate in the order they might appear; use a single pass regex approach. for (const kw of SQL_BREAK_KEYWORDS) { // Only break on keyword that is not already at start of a line const escaped = kw.replace(/\s+/g, '\\s+'); const re = new RegExp(`(? = ({ value, onChange, placeholder = 'Enter SQL query…', language = 'sql', height = '200px', readOnly = false, onExecute, }) => { const containerRef = useRef(null); const viewRef = useRef(null); const langCompartmentRef = useRef(new Compartment()); const readOnlyCompartmentRef = useRef(new Compartment()); const placeholderCompartmentRef = useRef(new Compartment()); const isExternalUpdateRef = useRef(false); // Keep onChange in a stable ref so callbacks never go stale const onChangeRef = useRef(onChange); onChangeRef.current = onChange; // Ctrl+Enter handler const runKeymap = useCallback( (): Extension[] => [ keymap.of([ { key: 'Mod-Enter', run: () => { if (onExecute) { onExecute(); } else { window.dispatchEvent(new CustomEvent('sql-editor:execute')); } return true; }, }, { key: 'Mod-Shift-f', run: (view: EditorView) => { const current = view.state.doc.toString(); const formatted = formatSQL(current); if (formatted !== current) { view.dispatch({ changes: { from: 0, to: view.state.doc.length, insert: formatted, }, }); } return true; }, }, ]), ], [onExecute], ); // Create editor once useEffect(() => { if (!containerRef.current) return; const updateListener = EditorView.updateListener.of((update) => { if (update.docChanged && !isExternalUpdateRef.current) { onChangeRef.current(update.state.doc.toString()); } }); const state = EditorState.create({ doc: value, extensions: [ lineNumbers(), history(), bracketMatching(), closeBrackets(), highlightSelectionMatches(), syntaxHighlighting(defaultHighlightStyle, { fallback: true }), appTheme, oneDark, langCompartmentRef.current.of(getSQLExtension(language)), readOnlyCompartmentRef.current.of( EditorState.readOnly.of(readOnly), ), placeholderCompartmentRef.current.of(cmPlaceholder(placeholder)), updateListener, ...runKeymap(), keymap.of([ ...closeBracketsKeymap, ...defaultKeymap, ...searchKeymap, ...historyKeymap, ]), EditorView.lineWrapping, ], }); const view = new EditorView({ state, parent: containerRef.current, }); viewRef.current = view; return () => { view.destroy(); viewRef.current = null; }; // We intentionally only run this once on mount. // eslint-disable-next-line react-hooks/exhaustive-deps }, []); // Sync external value changes into the editor (avoid loops) useEffect(() => { const view = viewRef.current; if (!view) return; const currentContent = view.state.doc.toString(); if (currentContent !== value) { isExternalUpdateRef.current = true; view.dispatch({ changes: { from: 0, to: view.state.doc.length, insert: value }, }); isExternalUpdateRef.current = false; } }, [value]); // Update language when it changes useEffect(() => { const view = viewRef.current; if (!view) return; view.dispatch({ effects: langCompartmentRef.current.reconfigure(getSQLExtension(language)), }); }, [language]); // Update readOnly when it changes useEffect(() => { const view = viewRef.current; if (!view) return; view.dispatch({ effects: readOnlyCompartmentRef.current.reconfigure( EditorState.readOnly.of(readOnly), ), }); }, [readOnly]); // Update placeholder when it changes useEffect(() => { const view = viewRef.current; if (!view) return; view.dispatch({ effects: placeholderCompartmentRef.current.reconfigure( cmPlaceholder(placeholder ?? ''), ), }); }, [placeholder]); return (
); }; export default SqlEditor;