feat(query): sql workspace conveniences per opsxdb patterns

- generic ContextMenu component (viewport flip, esc/outside close)
- SchemaSidebar table right-click menu: preview data, generate
  SELECT/INSERT/UPDATE/DELETE templates with pk-aware WHERE, copy table
  name; double-click table runs preview SELECT; column right-click
  copy/insert; dialect-aware quoting; preview SELECT carries no LIMIT
- SqlEditor selection-first execution (Mod-Enter runs selection else
  statement at cursor via quote/comment-aware splitter); pagination
  replays last executed statement
- query history persisted server-side: drawer fetches query-histories
  with keyword/mine/favorites filters, star toggle (new PUT favorite +
  mine filter endpoints), insert and insert-and-run
- custom templates in localStorage alongside built-ins
- unit tests: genSql + statement splitter (28 tests green)
This commit is contained in:
Logan.Li committed 2026-10-26 18:53:02 +08:00
1 parent ff539b47e0
commit f6d636fa44
16 files changed
+1392 -457

No files matched your search

+512 -225
View File
@@ -6,11 +6,7 @@ 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 { syntaxHighlighting, defaultHighlightStyle, bracketMatching } from '@codemirror/language';
import { closeBrackets, closeBracketsKeymap, autocompletion } from '@codemirror/autocomplete';
import { searchKeymap, highlightSelectionMatches } from '@codemirror/search';
@@ -35,45 +31,206 @@ export interface SqlEditorProps {
// ---------------------------------------------------------------------------
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',
'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',
'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',
'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',
'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',
'createIndex',
'dropIndex',
'getIndexes',
'explain',
'hint',
// Collection / DB
'use', 'show', 'db', 'collection', 'collections', 'databases',
'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',
'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'], schema?: SqlEditorProps['schema']): Extension {
@@ -102,9 +259,7 @@ function getSQLExtension(language: SqlEditorProps['language'], schema?: SqlEdito
if (Array.isArray(sampleValue)) {
// Direct: { table: [columns] }
tables = Object.fromEntries(
Object.entries(schema).map(([k, v]) => [k, v as string[]])
);
tables = Object.fromEntries(Object.entries(schema).map(([k, v]) => [k, v as string[]]));
} else {
// Nested: { db: { table: [columns] } }
// Register: db → table names (so "db." shows tables)
@@ -124,7 +279,8 @@ function getSQLExtension(language: SqlEditorProps['language'], schema?: SqlEdito
}
const dialect = SQLDialect.define({
keywords: '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 primary key foreign references unique check default grant revoke commit rollback begin transaction count sum avg min max show describe use database explain analyze',
keywords:
'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 primary key foreign references unique check default grant revoke commit rollback begin transaction count sum avg min max show describe use database explain analyze',
...(tables ? { schema: tables } : {}),
} as any);
return sql({ dialect });
@@ -222,20 +378,103 @@ const SQL_BREAK_KEYWORDS = [
];
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',
'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 {
@@ -270,190 +509,238 @@ export function formatSQL(input: string): string {
export interface SqlEditorHandle {
/** Insert text at cursor position and move cursor after inserted text */
insertText: (text: string) => void;
/** 选区优先,否则光标所在语句;与全文相同时返回 null(调用方直接用全文) */
getSqlToRun: () => string | null;
}
const SqlEditor = forwardRef<SqlEditorHandle, SqlEditorProps>(({
value,
onChange,
placeholder = 'Enter SQL query…',
language = 'sql',
height = '200px',
readOnly = false,
onExecute,
schema,
}, ref) => {
const containerRef = useRef<HTMLDivElement>(null);
const viewRef = useRef<EditorView | null>(null);
const langCompartmentRef = useRef(new Compartment());
const readOnlyCompartmentRef = useRef(new Compartment());
const placeholderCompartmentRef = useRef(new Compartment());
const isExternalUpdateRef = useRef(false);
/** 按分号切分语句(单/双引号与行注释感知),返回每段的 [start, end) 与文本 */
export function splitSqlStatements(text: string): Array<{ start: number; end: number; text: string }> {
const out: Array<{ start: number; end: number; text: string }> = [];
let start = 0;
let quote: string | null = null;
let lineComment = false;
for (let i = 0; i < text.length; i++) {
const ch = text[i];
const next = text[i + 1];
if (lineComment) {
if (ch === '\n') lineComment = false;
continue;
}
if (quote) {
if (ch === '\\') {
i++;
continue;
}
if (ch === quote) quote = null;
continue;
}
if (ch === '-' && next === '-') {
lineComment = true;
i++;
continue;
}
if (ch === "'" || ch === '"' || ch === '`') {
quote = ch;
continue;
}
if (ch === ';') {
out.push({ start, end: i, text: text.slice(start, i) });
start = i + 1;
}
}
if (start < text.length) out.push({ start, end: text.length, text: text.slice(start) });
return out;
}
// Expose insertText to parent via ref
useImperativeHandle(ref, () => ({
insertText: (text: string) => {
const SqlEditor = forwardRef<SqlEditorHandle, SqlEditorProps>(
(
{
value,
onChange,
placeholder = 'Enter SQL query…',
language = 'sql',
height = '200px',
readOnly = false,
onExecute,
schema,
},
ref,
) => {
const containerRef = useRef<HTMLDivElement>(null);
const viewRef = useRef<EditorView | null>(null);
const langCompartmentRef = useRef(new Compartment());
const readOnlyCompartmentRef = useRef(new Compartment());
const placeholderCompartmentRef = useRef(new Compartment());
const isExternalUpdateRef = useRef(false);
// Expose insertText to parent via ref
useImperativeHandle(
ref,
() => ({
insertText: (text: string) => {
const view = viewRef.current;
if (!view) return;
const pos = view.state.selection.main.head;
view.dispatch({
changes: { from: pos, insert: text },
selection: { anchor: pos + text.length },
});
view.focus();
},
getSqlToRun: (): string | null => {
const view = viewRef.current;
if (!view) return null;
const sel = view.state.selection.main;
const selected = view.state.sliceDoc(sel.from, sel.to).trim();
if (selected) return selected;
const doc = view.state.doc.toString();
const statements = splitSqlStatements(doc).filter((st) => st.text.trim());
if (statements.length <= 1) return null; // 单语句/空:用全文
const cursor = sel.head;
const stmt = statements.find((st) => cursor > st.start && cursor <= st.end + 1);
if (!stmt) return null;
const fragment = stmt.text.trim();
return fragment && fragment !== doc.trim() ? fragment : null;
},
}),
[],
);
// 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(),
autocompletion({
activateOnTyping: true,
icons: false,
}),
highlightSelectionMatches(),
syntaxHighlighting(defaultHighlightStyle, { fallback: true }),
appTheme,
oneDark,
langCompartmentRef.current.of(getSQLExtension(language, schema)),
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 pos = view.state.selection.main.head;
view.dispatch({
changes: { from: pos, insert: text },
selection: { anchor: pos + text.length },
});
view.focus();
},
}), []);
// 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 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]);
const state = EditorState.create({
doc: value,
extensions: [
lineNumbers(),
history(),
bracketMatching(),
closeBrackets(),
autocompletion({
activateOnTyping: true,
icons: false,
}),
highlightSelectionMatches(),
syntaxHighlighting(defaultHighlightStyle, { fallback: true }),
appTheme,
oneDark,
langCompartmentRef.current.of(getSQLExtension(language, schema)),
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;
// Update language/schema when they change
useEffect(() => {
const view = viewRef.current;
if (!view) return;
view.dispatch({
changes: { from: 0, to: view.state.doc.length, insert: value },
effects: langCompartmentRef.current.reconfigure(getSQLExtension(language, schema)),
});
isExternalUpdateRef.current = false;
}
}, [value]);
}, [language, schema]);
// Update language/schema when they change
useEffect(() => {
const view = viewRef.current;
if (!view) return;
view.dispatch({
effects: langCompartmentRef.current.reconfigure(getSQLExtension(language, schema)),
});
}, [language, schema]);
// 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 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]);
// Update placeholder when it changes
useEffect(() => {
const view = viewRef.current;
if (!view) return;
view.dispatch({
effects: placeholderCompartmentRef.current.reconfigure(
cmPlaceholder(placeholder ?? ''),
),
});
}, [placeholder]);
return (
<div
ref={containerRef}
style={{ height, minHeight: height }}
className="sql-editor-wrapper"
/>
);
});
return <div ref={containerRef} style={{ height, minHeight: height }} className="sql-editor-wrapper" />;
},
);
export default SqlEditor;