From 13a36ecc528c688c4b4f65fbc5e59fa9aadf2220 Mon Sep 17 00:00:00 2001 From: huyplb Date: Fri, 24 Jul 2026 00:14:47 -0600 Subject: [PATCH 1/3] feat(sql-editor): add global variables with @set, secrets, and per-connection overrides Let users reuse ${{name}} / ${{name.col}} across queries, capture results via -- @set or the grid, mask secrets session-only, override values per destination, and export/import variable JSON. Co-authored-by: Cursor --- .../components/sql-editor/DataGrid.tsx | 217 ++++++- .../components/sql-editor/ResultsPanel.tsx | 29 +- .../components/sql-editor/SqlEditorPane.tsx | 109 +++- .../components/sql-editor/SqlEditorView.tsx | 233 +++++-- .../sql-editor/SqlSidebarSection.tsx | 6 +- .../sql-editor/SqlVariablesPanel.tsx | 551 ++++++++++++++++ .../components/sql-editor/StatementStrip.tsx | 172 ++++- .../components/sql-editor/completion.ts | 91 ++- .../components/sql-editor/sqlEditorBridge.ts | 18 +- .../components/sql-editor/variableHover.ts | 77 +++ .../src/frontend/lib/sql-variables.test.ts | 395 ++++++++++++ apps/web/src/frontend/lib/sql-variables.ts | 593 ++++++++++++++++++ .../frontend/store/sqlEditorTabLogic.test.ts | 19 + .../src/frontend/store/sqlEditorTabLogic.ts | 23 +- .../src/frontend/store/useSqlEditorStore.ts | 396 +++++++++++- apps/web/src/style.css | 6 + docs/USER_GUIDE.md | 46 +- 17 files changed, 2869 insertions(+), 112 deletions(-) create mode 100644 apps/web/src/frontend/components/sql-editor/SqlVariablesPanel.tsx create mode 100644 apps/web/src/frontend/components/sql-editor/variableHover.ts create mode 100644 apps/web/src/frontend/lib/sql-variables.test.ts create mode 100644 apps/web/src/frontend/lib/sql-variables.ts diff --git a/apps/web/src/frontend/components/sql-editor/DataGrid.tsx b/apps/web/src/frontend/components/sql-editor/DataGrid.tsx index 76a7550..67ff1dc 100644 --- a/apps/web/src/frontend/components/sql-editor/DataGrid.tsx +++ b/apps/web/src/frontend/components/sql-editor/DataGrid.tsx @@ -1,6 +1,8 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { AlertTriangle, Download, GripVertical, RefreshCw } from 'lucide-react'; import type { SqlStatementResult } from '../../api/sqlApi'; +import { columnToListValues, rowsForTableVariable } from '../../lib/sql-variables'; +import { useSqlEditorStore } from '../../store/useSqlEditorStore'; import { downloadCsv } from '../../utils/exportCsv'; const CELL_MAX = 200; @@ -141,6 +143,7 @@ function cellDisplay(value: unknown): { text: string; title: string; isNull: boo /** * Result grid — virtualized rows, column-level type colors (no per-cell regex). + * Right-click a cell or column header to save as a SQL Editor variable. */ export const DataGrid: React.FC<{ result: SqlStatementResult; @@ -149,11 +152,27 @@ export const DataGrid: React.FC<{ refreshing?: boolean; onRefresh?: () => void; }> = React.memo(({ result, label, exportName = 'query-result', refreshing, onRefresh }) => { + const upsertVariable = useSqlEditorStore((s) => s.upsertVariable); const sourceColumns = result.ok ? result.columns : []; const sourceRows = result.ok ? result.rows : []; const [colOrder, setColOrder] = useState(() => identityOrder(sourceColumns.length)); const [dragFrom, setDragFrom] = useState(null); const [dragOver, setDragOver] = useState(null); + const [menu, setMenu] = useState< + | { kind: 'cell'; x: number; y: number; colIdx: number; value: unknown } + | { kind: 'column'; x: number; y: number; colIdx: number } + | { kind: 'grid'; x: number; y: number } + | null + >(null); + const [savePrompt, setSavePrompt] = useState< + | { mode: 'scalar'; value: unknown; defaultName: string } + | { mode: 'list'; colIdx: number; defaultName: string } + | { mode: 'table'; defaultName: string } + | null + >(null); + const [saveName, setSaveName] = useState(''); + const [saveError, setSaveError] = useState(null); + const saveInputRef = useRef(null); const scrollRef = useRef(null); const [scrollTop, setScrollTop] = useState(0); @@ -199,6 +218,52 @@ export const DataGrid: React.FC<{ useEffect(() => () => cancelAnimationFrame(rafRef.current), []); + useEffect(() => { + if (!menu) return; + const close = () => setMenu(null); + window.addEventListener('click', close); + window.addEventListener('scroll', close, true); + return () => { + window.removeEventListener('click', close); + window.removeEventListener('scroll', close, true); + }; + }, [menu]); + + useEffect(() => { + if (savePrompt) { + setSaveName(savePrompt.defaultName); + setSaveError(null); + queueMicrotask(() => saveInputRef.current?.select()); + } + }, [savePrompt]); + + const commitSave = () => { + if (!savePrompt) return; + const existing = useSqlEditorStore.getState().variables.find((v) => v.name === saveName.trim()); + if (existing && !window.confirm(`Overwrite variable "${saveName.trim()}"?`)) return; + + let err: string | null; + if (savePrompt.mode === 'scalar') { + err = upsertVariable({ name: saveName, kind: 'scalar', value: savePrompt.value }); + } else if (savePrompt.mode === 'list') { + const values = columnToListValues(sourceRows, savePrompt.colIdx); + err = upsertVariable({ name: saveName, kind: 'list', values }); + } else { + err = upsertVariable({ + name: saveName, + kind: 'table', + columns: [...sourceColumns], + rows: rowsForTableVariable(sourceRows), + }); + } + if (err) { + setSaveError(err); + return; + } + setSavePrompt(null); + setMenu(null); + }; + if (!result.ok) { return (
@@ -285,6 +350,12 @@ export const DataGrid: React.FC<{ className="fox-sql-grid flex-1 min-h-0 border border-[#cbd5e1] rounded-lg shadow-sm bg-white" style={{ overflowX: 'auto', overflowY: 'auto' }} onScroll={onScroll} + onContextMenu={(e) => { + // Empty area / row-number context: save whole result as table. + if ((e.target as HTMLElement).closest('td, th')) return; + e.preventDefault(); + setMenu({ kind: 'grid', x: e.clientX, y: e.clientY }); + }} > {sourceColumns.length === 0 ? (
@@ -312,8 +383,13 @@ export const DataGrid: React.FC<{ { + e.preventDefault(); + e.stopPropagation(); + setMenu({ kind: 'grid', x: e.clientX, y: e.clientY }); + }} > # @@ -327,7 +403,11 @@ export const DataGrid: React.FC<{ key={`${colIdx}-${name}`} draggable data-testid="sql-col-header" - title={`${name} (${KIND_LABEL[kind]}) — drag to reorder`} + title={`${name} (${KIND_LABEL[kind]}) — drag to reorder; right-click to save as list variable`} + onContextMenu={(e) => { + e.preventDefault(); + setMenu({ kind: 'column', x: e.clientX, y: e.clientY, colIdx }); + }} onDragStart={(e) => { setDragFrom(visualIdx); e.dataTransfer.effectAllowed = 'move'; @@ -419,6 +499,16 @@ export const DataGrid: React.FC<{ className={`px-3 overflow-hidden text-ellipsis ${KIND_CELL_CLASS[kind]}`} style={{ width: w, minWidth: COL_MIN_PX, maxWidth: w }} title={title} + onContextMenu={(e) => { + e.preventDefault(); + setMenu({ + kind: 'cell', + x: e.clientX, + y: e.clientY, + colIdx, + value: cell, + }); + }} > {text} @@ -444,6 +534,129 @@ export const DataGrid: React.FC<{ truncated — add a LIMIT for the full picture )}
+ + {menu && ( +
e.stopPropagation()} + > + {menu.kind === 'cell' ? ( + <> + + + + ) : menu.kind === 'column' ? ( + + ) : ( + + )} +
+ )} + + {savePrompt && ( +
setSavePrompt(null)} + > +
e.stopPropagation()} + > +
+ {savePrompt.mode === 'scalar' + ? 'Save cell as variable' + : savePrompt.mode === 'list' + ? 'Save column as list' + : 'Save result as table'} +
+ setSaveName(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Enter') commitSave(); + if (e.key === 'Escape') setSavePrompt(null); + }} + placeholder="variable_name" + className="w-full bg-slate-950 border border-slate-700 rounded px-2 py-1.5 text-xs text-slate-100 outline-none focus:border-cyan-600/50 mb-2" + /> + {saveError && ( +

+ {saveError} +

+ )} +
+ + +
+
+
+ )}
); }); diff --git a/apps/web/src/frontend/components/sql-editor/ResultsPanel.tsx b/apps/web/src/frontend/components/sql-editor/ResultsPanel.tsx index 53a28f3..ed20dd4 100644 --- a/apps/web/src/frontend/components/sql-editor/ResultsPanel.tsx +++ b/apps/web/src/frontend/components/sql-editor/ResultsPanel.tsx @@ -12,6 +12,8 @@ interface Props { layout: ResultsLayout; /** True while any execute is in flight for this tab. */ refreshing?: boolean; + /** Non-fatal run messages (e.g. `@set` failures). */ + warnings?: string[]; /** Re-run for one credential, or all when omitted. */ onRefresh?: (connectionId?: string) => void; } @@ -204,6 +206,7 @@ export const ResultsPanel: React.FC = ({ statements, layout, refreshing, + warnings, onRefresh, }) => { if (runs.length === 0) { @@ -214,10 +217,28 @@ export const ResultsPanel: React.FC = ({ ); } + const warningBanner = + warnings && warnings.length > 0 ? ( +
+ {warnings.map((w, i) => ( +
+ + {w} +
+ ))} +
+ ) : null; + if (layout === 'sideBySide') { const stmtCount = Math.max(statements.length, ...runs.map((r) => r.results?.length ?? 0), 0); return ( -
+
+ {warningBanner} +
{Array.from({ length: stmtCount }, (_, i) => { const items: PaneItem[] = []; for (const run of runs) { @@ -265,12 +286,15 @@ export const ResultsPanel: React.FC = ({ ); })} +
); } return ( -
+
+ {warningBanner} +
{runs.map((run) => { const items: PaneItem[] = run.status === 'done' && run.results @@ -323,6 +347,7 @@ export const ResultsPanel: React.FC = ({ ); })} +
); }; diff --git a/apps/web/src/frontend/components/sql-editor/SqlEditorPane.tsx b/apps/web/src/frontend/components/sql-editor/SqlEditorPane.tsx index dd0d8bf..f546cc3 100644 --- a/apps/web/src/frontend/components/sql-editor/SqlEditorPane.tsx +++ b/apps/web/src/frontend/components/sql-editor/SqlEditorPane.tsx @@ -2,9 +2,14 @@ import React, { useEffect, useRef } from 'react'; import Editor from '@monaco-editor/react'; import { MONACO_THEME, MONACO_THEME_LIGHT, monacoLanguage } from '../../monaco-setup'; import { useUiStore } from '../../store/uiStore'; +import { useSqlEditorStore } from '../../store/useSqlEditorStore'; import { splitSqlStatements, checkStatement } from '../../lib/sql-splitter'; import { ensureSqlCompletions } from './completion'; -import { setSqlInsertHandler } from './sqlEditorBridge'; +import { setSqlInsertHandler, setSqlSelectionGetter } from './sqlEditorBridge'; +import { + buildVariableHoverDecorations, + disposeLegacyVariableHovers, +} from './variableHover'; // Mirrors SqlEditor.tsx's BASE_OPTIONS (that component stays read-only-oriented; // this one is the editable editor with a glyph margin for statement status icons). @@ -38,6 +43,8 @@ interface Props { onChange: (value: string) => void; /** Ctrl/Cmd+Enter shortcut → run. */ onRun?: () => void; + /** Fired when Monaco selection becomes empty / non-empty (for Run label). */ + onSelectionChange?: (hasSelection: boolean) => void; /** Statement strip click → scroll/select that range. */ reveal?: RevealRequest | null; } @@ -48,67 +55,98 @@ interface Props { * complete, amber ⚠ = incomplete (unclosed quote/parens, missing final `;`, * unknown leading keyword). Heuristic only — not validation. */ -export const SqlEditorPane: React.FC = ({ value, dialect, onChange, onRun, reveal }) => { +export const SqlEditorPane: React.FC = ({ + value, + dialect, + onChange, + onRun, + onSelectionChange, + reveal, +}) => { const editorRef = useRef(null); const monacoRef = useRef(null); const decoRef = useRef(null); + const varDecoRef = useRef(null); const debounceRef = useRef | null>(null); const onRunRef = useRef(onRun); onRunRef.current = onRun; + const onSelectionChangeRef = useRef(onSelectionChange); + onSelectionChangeRef.current = onSelectionChange; const monacoTheme = useUiStore((s) => s.resolvedMode) === 'light' ? MONACO_THEME_LIGHT : MONACO_THEME; + const variables = useSqlEditorStore((s) => s.variables); const decorate = (text: string) => { const editor = editorRef.current; + const monaco = monacoRef.current; if (!editor) return; const statements = splitSqlStatements(text); decoRef.current?.clear?.(); if (!statements.length) { decoRef.current = null; - return; - } - decoRef.current = editor.createDecorationsCollection( - statements.map((stmt) => { - const status = checkStatement(stmt); - const ok = status.level === 'ok'; - return { - range: { startLineNumber: stmt.startLine, startColumn: 1, endLineNumber: stmt.startLine, endColumn: 1 }, - options: { - glyphMarginClassName: ok ? 'fox-stmt-glyph-ok' : 'fox-stmt-glyph-warn', - glyphMarginHoverMessage: { - value: ok ? 'Statement looks complete' : status.reasons.join(' · '), + } else { + decoRef.current = editor.createDecorationsCollection( + statements.map((stmt) => { + const status = checkStatement(stmt); + const ok = status.level === 'ok'; + return { + range: { + startLineNumber: stmt.startLine, + startColumn: 1, + endLineNumber: stmt.startLine, + endColumn: 1, + }, + options: { + glyphMarginClassName: ok ? 'fox-stmt-glyph-ok' : 'fox-stmt-glyph-warn', + glyphMarginHoverMessage: { + value: ok ? 'Statement looks complete' : status.reasons.join(' · '), + }, }, - }, - }; - }) - ); + }; + }) + ); + } + + varDecoRef.current?.clear?.(); + if (monaco) { + const vars = useSqlEditorStore.getState().variables; + const varDecos = buildVariableHoverDecorations(monaco, text, vars); + varDecoRef.current = + varDecos.length > 0 ? editor.createDecorationsCollection(varDecos) : null; + } }; - // Re-decorate (debounced) whenever the buffer changes — including external - // resets like loading a persisted buffer after mount. + // Re-decorate (debounced) whenever the buffer or variables change. useEffect(() => { if (debounceRef.current) clearTimeout(debounceRef.current); debounceRef.current = setTimeout(() => decorate(value), 200); return () => { if (debounceRef.current) clearTimeout(debounceRef.current); }; - }, [value]); + }, [value, variables]); useEffect(() => { const editor = editorRef.current; if (!editor || !reveal) return; + const model = editor.getModel(); + const endColumn = model ? model.getLineMaxColumn(reveal.endLine) : 1; const range = { startLineNumber: reveal.startLine, startColumn: 1, endLineNumber: reveal.endLine, - endColumn: 1, + endColumn, }; editor.revealRangeInCenter(range); editor.setSelection(range); editor.focus(); + onSelectionChangeRef.current?.(true); }, [reveal]); useEffect(() => { - return () => setSqlInsertHandler(null); + disposeLegacyVariableHovers(); + return () => { + setSqlInsertHandler(null); + setSqlSelectionGetter(null); + }; }, []); return ( @@ -121,19 +159,34 @@ export const SqlEditorPane: React.FC = ({ value, dialect, onChange, onRun onMount={(editor, monaco) => { editorRef.current = editor; monacoRef.current = monaco; + disposeLegacyVariableHovers(); ensureSqlCompletions(monaco); editor.addCommand(monaco.KeyMod.CtrlCmd | monaco.KeyCode.Enter, () => onRunRef.current?.()); + setSqlSelectionGetter(() => { + const ed = editorRef.current; + const model = ed?.getModel?.(); + const sel = ed?.getSelection?.(); + if (!ed || !model || !sel || sel.isEmpty()) return null; + const text = model.getValueInRange(sel); + const trimmed = text.trim(); + return trimmed.length > 0 ? trimmed : null; + }); + editor.onDidChangeCursorSelection(() => { + const sel = editor.getSelection(); + onSelectionChangeRef.current?.(Boolean(sel && !sel.isEmpty())); + }); setSqlInsertHandler((text) => { const ed = editorRef.current; const m = monacoRef.current; if (!ed || !m) return; const sel = ed.getSelection(); const pos = ed.getPosition(); - const range = sel && !sel.isEmpty() - ? sel - : pos - ? new m.Range(pos.lineNumber, pos.column, pos.lineNumber, pos.column) - : null; + const range = + sel && !sel.isEmpty() + ? sel + : pos + ? new m.Range(pos.lineNumber, pos.column, pos.lineNumber, pos.column) + : null; if (!range) return; ed.executeEdits('schema-insert', [{ range, text, forceMoveMarkers: true }]); ed.focus(); diff --git a/apps/web/src/frontend/components/sql-editor/SqlEditorView.tsx b/apps/web/src/frontend/components/sql-editor/SqlEditorView.tsx index 7228d0e..d182f11 100644 --- a/apps/web/src/frontend/components/sql-editor/SqlEditorView.tsx +++ b/apps/web/src/frontend/components/sql-editor/SqlEditorView.tsx @@ -1,5 +1,21 @@ import React, { Suspense, lazy, useCallback, useEffect, useMemo, useRef, useState } from 'react'; -import { Loader2, Play, Eraser, AlignLeft, Columns2, Rows3, RefreshCw, BookmarkPlus, Database, Bookmark, Network, Shield } from 'lucide-react'; +import { + Loader2, + Play, + Eraser, + AlignLeft, + Columns2, + Rows3, + RefreshCw, + BookmarkPlus, + Database, + Bookmark, + Network, + Shield, + Braces, + PanelLeftClose, + PanelLeftOpen, +} from 'lucide-react'; import { useSyncStore } from '../../store/useSyncStore'; import { useSqlEditorStore } from '../../store/useSqlEditorStore'; import { splitSqlStatements, type SplitStatement } from '../../lib/sql-splitter'; @@ -11,6 +27,7 @@ import { EditorTabBar } from './EditorTabBar'; import { ResultsPanel } from './ResultsPanel'; import { StatementStrip } from './StatementStrip'; import { SqlBookmarksPanel } from './SqlBookmarksPanel'; +import { SqlVariablesPanel } from './SqlVariablesPanel'; import { SqlSchemaExplorer } from './SqlSchemaExplorer'; import { SqlSidebarSection, useSidebarSectionsOpen } from './SqlSidebarSection'; import { WriteConfirmDialog } from './WriteConfirmDialog'; @@ -28,6 +45,30 @@ const EDITOR_PCT_MIN = 15; const EDITOR_PCT_MAX = 70; const EDITOR_PCT_DEFAULT = 26; +const SIDEBAR_WIDTH_KEY = 'foxschema-sql-sidebar-width'; +const SIDEBAR_COLLAPSED_KEY = 'foxschema-sql-sidebar-collapsed'; +const SIDEBAR_MIN = 200; +const SIDEBAR_MAX = 480; +const SIDEBAR_DEFAULT = 288; + +function loadSidebarWidth(): number { + try { + const n = Number(localStorage.getItem(SIDEBAR_WIDTH_KEY)); + if (Number.isFinite(n)) return Math.min(SIDEBAR_MAX, Math.max(SIDEBAR_MIN, n)); + } catch { + /* ignore */ + } + return SIDEBAR_DEFAULT; +} + +function loadSidebarCollapsed(): boolean { + try { + return localStorage.getItem(SIDEBAR_COLLAPSED_KEY) === '1'; + } catch { + return false; + } +} + /** * SQL Editor workspace: multi-tab buffers, destination servers, schema explorer, * statement strip, layout toggle, Format/CSV. Results are per-tab and not persisted. @@ -73,10 +114,29 @@ export const SqlEditorView: React.FC = () => { const [reveal, setReveal] = useState(null); const [editorPct, setEditorPct] = useState(EDITOR_PCT_DEFAULT); + const [hasSelection, setHasSelection] = useState(false); + const [sidebarWidth, setSidebarWidth] = useState(loadSidebarWidth); + const [sidebarCollapsed, setSidebarCollapsed] = useState(loadSidebarCollapsed); const splitRef = useRef(null); const [sidebarOpen, toggleSidebar] = useSidebarSectionsOpen(); - // Completion provider reads active SQL + checked schemas via this getter. + useEffect(() => { + try { + localStorage.setItem(SIDEBAR_WIDTH_KEY, String(sidebarWidth)); + } catch { + /* ignore */ + } + }, [sidebarWidth]); + + useEffect(() => { + try { + localStorage.setItem(SIDEBAR_COLLAPSED_KEY, sidebarCollapsed ? '1' : '0'); + } catch { + /* ignore */ + } + }, [sidebarCollapsed]); + + // Completion provider reads active SQL + checked schemas + variables via this getter. useEffect(() => { setCompletionContextGetter(() => { const state = useSqlEditorStore.getState(); @@ -89,7 +149,7 @@ export const SqlEditorView: React.FC = () => { return { connectionId: id, tables: entry.tables }; }) .filter((x): x is NonNullable => x != null); - return { sql: active.sql, schemas }; + return { sql: active.sql, schemas, variables: state.variables }; }); }, []); @@ -123,6 +183,31 @@ export const SqlEditorView: React.FC = () => { window.addEventListener('mouseup', onUp); }, []); + const startSidebarResize = useCallback( + (e: React.MouseEvent) => { + e.preventDefault(); + if (sidebarCollapsed) return; + const startX = e.clientX; + const startW = sidebarWidth; + const onMove = (ev: MouseEvent) => { + setSidebarWidth( + Math.min(SIDEBAR_MAX, Math.max(SIDEBAR_MIN, startW + ev.clientX - startX)) + ); + }; + const onUp = () => { + window.removeEventListener('mousemove', onMove); + window.removeEventListener('mouseup', onUp); + document.body.style.cursor = ''; + document.body.style.userSelect = ''; + }; + document.body.style.cursor = 'col-resize'; + document.body.style.userSelect = 'none'; + window.addEventListener('mousemove', onMove); + window.addEventListener('mouseup', onUp); + }, + [sidebarCollapsed, sidebarWidth] + ); + const firstSelected = connections.find((c) => liveSelectedIds.includes(c.id)); const dialect = firstSelected?.dialect ?? 'sql'; @@ -134,12 +219,14 @@ export const SqlEditorView: React.FC = () => { : tab.checkedStatements.filter((i) => i >= 0 && i < statements.length).length || (statements.length > 0 ? 1 : 0); - const canRun = !runningTabId && runCount > 0 && liveSelectedIds.length > 0; + const canRun = !runningTabId && liveSelectedIds.length > 0 && (hasSelection || runCount > 0); const runTitle = !liveSelectedIds.length ? 'Check at least one destination server to run against' - : !runCount - ? 'Write a SQL statement first' - : `Run ${runCount} statement(s) against ${liveSelectedIds.length} server(s) (⌘/Ctrl+Enter)`; + : hasSelection + ? 'Run the selected SQL (⌘/Ctrl+Enter)' + : !runCount + ? 'Write a SQL statement first' + : `Run ${runCount} statement(s) against ${liveSelectedIds.length} server(s) (⌘/Ctrl+Enter)`; const onReveal = (stmt: SplitStatement) => { setReveal({ startLine: stmt.startLine, endLine: stmt.endLine, nonce: Date.now() }); @@ -152,48 +239,102 @@ export const SqlEditorView: React.FC = () => { return (
-
+
+ } + open={sidebarOpen.destinations} + onToggle={() => toggleSidebar('destinations')} + > + + + } + open={sidebarOpen.bookmarks} + onToggle={() => toggleSidebar('bookmarks')} + actions={ + + } + > + + + } + open={sidebarOpen.variables} + onToggle={() => toggleSidebar('variables')} + > + + + } + open={sidebarOpen.schema} + onToggle={() => toggleSidebar('schema')} + grow + > + + +
+
+ + )}
{ }`} > {running ? : } - Run + {hasSelection ? 'Run selection' : 'Run'} + + { + void onImportFile(e.target.files?.[0] ?? null); + e.target.value = ''; + }} + /> +
+ + {adding ? ( +
+ setNameDraft(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Enter') commitAdd(); + if (e.key === 'Escape') { + setAdding(false); + setError(null); + } + }} + className="w-full bg-slate-950 border border-slate-700 rounded px-1.5 py-0.5 text-[11px] text-slate-100 outline-none focus:border-cyan-600/50" + /> + setValueDraft(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Enter') commitAdd(); + if (e.key === 'Escape') { + setAdding(false); + setError(null); + } + }} + className="w-full bg-slate-950 border border-slate-700 rounded px-1.5 py-0.5 text-[11px] text-slate-100 outline-none focus:border-cyan-600/50" + /> + +
+ + +
+
+ ) : null} + + {variables.length === 0 && !adding ? ( +

+ Add a scalar, use -- @set, or save from + results. Refs: ${'{{name}}'} /{' '} + ${'{{name.col}}'}. +

+ ) : ( +
    + {variables.map((v) => { + const hasOverrides = Boolean(v.overrides && Object.keys(v.overrides).length > 0); + return ( +
  • +
    +
    +
    + + {v.name} + + + {v.kind} + + {v.secret && ( + + secret + + )} + {hasOverrides && ( + + · override + + )} +
    + {v.kind === 'table' ? ( + + ) : editingId === v.id ? ( + setEditValue(e.target.value)} + onBlur={() => + commitEdit(v.id, v.kind === 'list' ? 'list' : 'scalar') + } + onKeyDown={(e) => { + if (e.key === 'Enter') { + commitEdit(v.id, v.kind === 'list' ? 'list' : 'scalar'); + } + if (e.key === 'Escape') setEditingId(null); + }} + className="mt-0.5 w-full bg-slate-950 border border-cyan-600/50 rounded px-1.5 py-0.5 text-[10px] font-mono text-slate-100 outline-none" + /> + ) : ( + + )} +
    + + {(v.kind === 'scalar' || v.kind === 'list') && connections.length > 0 && ( + + )} +
    +
    + +
    + + {expandedOverrides === v.id && (v.kind === 'scalar' || v.kind === 'list') && ( +
    + {connections.map((c) => { + const o = v.overrides?.[c.id]; + const label = c.name || c.dialect; + return ( +
    + + {label} + +
    + { + const raw = e.target.value.trim(); + if (!raw) { + setVariableOverride(v.id, c.id, null); + return; + } + if (v.kind === 'list') { + const values = raw + .split(',') + .map((s) => s.trim()) + .filter(Boolean); + setVariableOverride(v.id, c.id, { values }); + } else { + let value: unknown = raw; + if (raw === 'NULL') value = null; + else if (raw === 'true') value = true; + else if (raw === 'false') value = false; + else if (/^-?\d+(\.\d+)?$/.test(raw)) value = Number(raw); + setVariableOverride(v.id, c.id, { value }); + } + }} + className="flex-1 min-w-0 bg-slate-950 border border-slate-700 rounded px-1 py-0.5 text-[10px] font-mono text-slate-200 outline-none focus:border-cyan-600/50" + /> + {o && ( + + )} +
    +
    + ); + })} +
    + )} + + {previewTableId === v.id && previewVar?.kind === 'table' && ( +
    + {previewVar.secret ? ( +

    (secret table — values hidden)

    + ) : ( + + + + {(previewVar.columns ?? []).map((col) => ( + + ))} + + + + {(previewVar.rows ?? []).slice(0, TABLE_PREVIEW_ROWS).map((row, ri) => ( + + {(previewVar.columns ?? []).map((_, ci) => ( + + ))} + + ))} + +
    + {col} +
    + {row[ci] === null || row[ci] === undefined + ? 'NULL' + : String(row[ci])} +
    + )} + {(previewVar.rows?.length ?? 0) > TABLE_PREVIEW_ROWS && ( +

    + … {(previewVar.rows?.length ?? 0) - TABLE_PREVIEW_ROWS} more rows +

    + )} +
    + )} +
  • + ); + })} +
+ )} + + {!adding && ( + + )} +
+ ); +}; diff --git a/apps/web/src/frontend/components/sql-editor/StatementStrip.tsx b/apps/web/src/frontend/components/sql-editor/StatementStrip.tsx index 4531d2a..d171f7f 100644 --- a/apps/web/src/frontend/components/sql-editor/StatementStrip.tsx +++ b/apps/web/src/frontend/components/sql-editor/StatementStrip.tsx @@ -1,4 +1,6 @@ -import React, { useCallback, useEffect, useState } from 'react'; +import React, { useCallback, useEffect, useRef, useState } from 'react'; +import { createPortal } from 'react-dom'; +import { Check, Copy } from 'lucide-react'; import { checkStatement, dmlLacksWhere, @@ -6,6 +8,7 @@ import { statementVerb, type SplitStatement, } from '../../lib/sql-splitter'; +import { findVariableRefs, substituteVariables } from '../../lib/sql-variables'; import { useSqlEditorStore } from '../../store/useSqlEditorStore'; interface Props { @@ -49,13 +52,38 @@ const DML_BADGE: Record = { merge: 'MRG', }; +function resolveSql( + stmtText: string, + variables: ReturnType['variables'] +): { sql: string; error?: string; hasVars: boolean } { + const hasVars = findVariableRefs(stmtText).length > 0; + if (!hasVars) return { sql: stmtText, hasVars: false }; + const expanded = substituteVariables(stmtText, variables, { maskSecrets: true }); + if (!expanded.ok) return { sql: stmtText, error: expanded.error, hasVars: true }; + return { sql: expanded.sql, hasVars: true }; +} + +type PopoverState = { + index: number; + top: number; + left: number; + width: number; + sql: string; + error?: string; + hasVars: boolean; +}; + /** - * Per-statement run strip between the editor and results. Resizable; defaults - * to ~2 statement rows. Safe mode badges UPDATE / DELETE / MERGE. + * Per-statement run strip. Hover opens a pinned-style preview of query-with-values + * (overlaps the row so the pointer can reach Copy). Each row also has its own Copy. */ export const StatementStrip: React.FC = ({ statements, checked, onToggle, onReveal }) => { const [height, setHeight] = useState(loadHeight); const safeMode = useSqlEditorStore((s) => s.safeMode); + const variables = useSqlEditorStore((s) => s.variables); + const [popover, setPopover] = useState(null); + const [copiedIndex, setCopiedIndex] = useState(null); + const hideTimer = useRef | null>(null); useEffect(() => { try { @@ -65,6 +93,64 @@ export const StatementStrip: React.FC = ({ statements, checked, onToggle, } }, [height]); + useEffect(() => { + return () => { + if (hideTimer.current) clearTimeout(hideTimer.current); + }; + }, []); + + const clearHide = () => { + if (hideTimer.current) { + clearTimeout(hideTimer.current); + hideTimer.current = null; + } + }; + + const scheduleHide = () => { + clearHide(); + // Long delay so the pointer can move into the overlapping popover / click Copy. + hideTimer.current = setTimeout(() => { + setPopover(null); + }, 500); + }; + + const openPopover = (index: number, el: HTMLElement, stmtText: string) => { + clearHide(); + const rect = el.getBoundingClientRect(); + const resolved = resolveSql(stmtText, variables); + const width = Math.min(520, Math.max(280, rect.width)); + let left = rect.left; + if (left + width > window.innerWidth - 8) { + left = Math.max(8, window.innerWidth - width - 8); + } + // Overlap the row by a few px so there is no gap that dismisses the popover. + let top = rect.bottom - 4; + const approxH = 140; + if (top + approxH > window.innerHeight - 8) { + top = Math.max(8, rect.top - approxH + 4); + } + setPopover({ + index, + top, + left, + width, + sql: resolved.sql, + error: resolved.error, + hasVars: resolved.hasVars, + }); + }; + + const copyText = async (index: number, text: string, hasError?: boolean) => { + if (hasError || !text) return; + try { + await navigator.clipboard.writeText(text); + setCopiedIndex(index); + window.setTimeout(() => setCopiedIndex((cur) => (cur === index ? null : cur)), 1500); + } catch { + /* ignore */ + } + }; + const startResize = useCallback( (e: React.MouseEvent) => { e.preventDefault(); @@ -101,6 +187,8 @@ export const StatementStrip: React.FC = ({ statements, checked, onToggle, const dmlBadge = safeMode && verb && isMutatingDmlStatement(stmt.text) ? DML_BADGE[verb] : null; const noWhere = dmlBadge ? dmlLacksWhere(stmt.text) : false; + const resolved = resolveSql(stmt.text, variables); + const isCopied = copiedIndex === i; return (
= ({ statements, checked, onToggle, +
); })} @@ -165,6 +284,51 @@ export const StatementStrip: React.FC = ({ statements, checked, onToggle, onMouseDown={startResize} className="h-1.5 shrink-0 cursor-row-resize bg-slate-900/80 hover:bg-cyan-500/40 active:bg-cyan-500/60 transition-colors" /> + + {popover && + createPortal( +
+
+ + {popover.hasVars ? 'Query with values' : 'Query'} + + +
+ {popover.error ? ( +

{popover.error}

+ ) : ( +
+                {popover.sql}
+              
+ )} +
, + document.body + )}
); }; diff --git a/apps/web/src/frontend/components/sql-editor/completion.ts b/apps/web/src/frontend/components/sql-editor/completion.ts index 13dc092..bc7915a 100644 --- a/apps/web/src/frontend/components/sql-editor/completion.ts +++ b/apps/web/src/frontend/components/sql-editor/completion.ts @@ -28,7 +28,7 @@ export function ensureSqlCompletions(monaco: typeof Monaco): void { registered = true; const provider: Monaco.languages.CompletionItemProvider = { - triggerCharacters: ['.'], + triggerCharacters: ['.', '{'], provideCompletionItems(model, position) { const word = model.getWordUntilPosition(position); const range: Monaco.IRange = { @@ -45,12 +45,99 @@ export function ensureSqlCompletions(monaco: typeof Monaco): void { endColumn: position.column, }); - const { sql, schemas } = getCompletionContext(); + const { sql, schemas, variables } = getCompletionContext(); // Prefer the live model text — context sql can lag one keystroke behind. const aliases = extractTableAliases(model.getValue() || sql); const tableIndex = buildTableIndex(schemas); const prefix = (word.word || '').toLowerCase(); + // `${{name.` — suggest columns of a table variable. + const varColMatch = /\$\{\{([A-Za-z_][A-Za-z0-9_]*)\.([A-Za-z_][A-Za-z0-9_]*)?$/.exec( + linePrefix + ); + if (varColMatch) { + const varName = varColMatch[1]!; + const partial = (varColMatch[2] ?? '').toLowerCase(); + const startCol = position.column - (varColMatch[2]?.length ?? 0); + const colRange: Monaco.IRange = { + startLineNumber: position.lineNumber, + endLineNumber: position.lineNumber, + startColumn: startCol, + endColumn: position.column, + }; + const tableVar = variables.find( + (v) => v.name === varName && v.kind === 'table' + ); + if (!tableVar) { + return { + suggestions: [ + { + label: `(${varName} is not a table variable)`, + kind: monaco.languages.CompletionItemKind.Text, + insertText: '', + detail: 'Save a result as table, or use -- @set name = table', + range: colRange, + }, + ], + }; + } + const cols = (tableVar.columns ?? []).filter( + (c) => !partial || c.toLowerCase().startsWith(partial) + ); + return { + suggestions: cols.map((c) => ({ + label: c, + kind: monaco.languages.CompletionItemKind.Field, + insertText: `${c}}}`, + detail: `column · ${varName}`, + sortText: `0_${c}`, + range: colRange, + })), + }; + } + + // `${{` or `${{partial` — suggest global SQL Editor variables. + const varMatch = /\$\{\{([A-Za-z_][A-Za-z0-9_]*)?$/.exec(linePrefix); + if (varMatch) { + const partial = (varMatch[1] ?? '').toLowerCase(); + const startCol = position.column - (varMatch[1]?.length ?? 0); + const varRange: Monaco.IRange = { + startLineNumber: position.lineNumber, + endLineNumber: position.lineNumber, + startColumn: startCol, + endColumn: position.column, + }; + const suggestions = variables + .filter((v) => !partial || v.name.toLowerCase().startsWith(partial)) + .map((v) => ({ + label: v.name, + kind: monaco.languages.CompletionItemKind.Variable, + insertText: `${v.name}}}`, + detail: + v.kind === 'list' + ? `list · ${v.values?.length ?? 0} values` + : v.kind === 'table' + ? `table · ${(v.rows?.length ?? 0)}×${(v.columns?.length ?? 0)}` + : 'scalar', + sortText: `0_${v.name}`, + range: varRange, + })); + if (suggestions.length === 0) { + return { + suggestions: [ + { + label: '(no variables)', + kind: monaco.languages.CompletionItemKind.Text, + insertText: '', + detail: 'Add one in the Variables sidebar or save a result cell', + range: varRange, + }, + ], + }; + } + return { suggestions }; + } + // `alias.` or `alias.partial` — keep matching after the user types past the dot. const dot = /([A-Za-z_][\w$]*)\.([A-Za-z_\d$]*)$/.exec(linePrefix); if (dot) { diff --git a/apps/web/src/frontend/components/sql-editor/sqlEditorBridge.ts b/apps/web/src/frontend/components/sql-editor/sqlEditorBridge.ts index 097a8b0..9109633 100644 --- a/apps/web/src/frontend/components/sql-editor/sqlEditorBridge.ts +++ b/apps/web/src/frontend/components/sql-editor/sqlEditorBridge.ts @@ -1,4 +1,5 @@ import type { TableSchema } from '../../lib/types'; +import type { SqlVariable } from '../../lib/sql-variables'; /** One connection's cached tables for autocomplete / explorer. */ export interface SchemaCacheEntry { @@ -16,12 +17,14 @@ export interface CompletionContext { sql: string; /** Schemas for checked credentials that have been loaded. */ schemas: CompletionSchemaSource[]; + /** Global SQL Editor variables for `${{` autocomplete. */ + variables: SqlVariable[]; } type ContextGetter = () => CompletionContext; type InsertHandler = (text: string) => void; -let contextGetter: ContextGetter = () => ({ sql: '', schemas: [] }); +let contextGetter: ContextGetter = () => ({ sql: '', schemas: [], variables: [] }); let insertHandler: InsertHandler | null = null; /** Wired once from SqlEditorView so the completion provider stays leak-free. */ @@ -41,3 +44,16 @@ export function setSqlInsertHandler(fn: InsertHandler | null): void { export function insertAtCursor(text: string): void { insertHandler?.(text); } + +type SelectionGetter = () => string | null; +let selectionGetter: SelectionGetter = () => null; + +/** Wired from SqlEditorPane — non-empty Monaco selection text, else null. */ +export function setSqlSelectionGetter(fn: SelectionGetter | null): void { + selectionGetter = fn ?? (() => null); +} + +/** Current editor selection used by Run (selection overrides statement strip). */ +export function getSelectedSql(): string | null { + return selectionGetter(); +} diff --git a/apps/web/src/frontend/components/sql-editor/variableHover.ts b/apps/web/src/frontend/components/sql-editor/variableHover.ts new file mode 100644 index 0000000..4330caa --- /dev/null +++ b/apps/web/src/frontend/components/sql-editor/variableHover.ts @@ -0,0 +1,77 @@ +import type * as Monaco from 'monaco-editor/esm/vs/editor/editor.api'; +import { expandVariableRef, type SqlVariable } from '../../lib/sql-variables'; + +const VAR_AT = /\$\{\{([A-Za-z_][A-Za-z0-9_]*)(?:\.([A-Za-z_][A-Za-z0-9_]*))?\}\}/g; + +/** Leftover language HoverProviders from older builds (HMR) — dispose once. */ +const DISPOSE_KEY = '__foxschemaSqlVariableHoverDisposables'; + +type Disposable = { dispose: () => void }; + +/** Clear any stacked language hover providers from prior registrations. */ +export function disposeLegacyVariableHovers(): void { + const g = globalThis as typeof globalThis & { [DISPOSE_KEY]?: Disposable[] }; + const prev = g[DISPOSE_KEY]; + if (!prev?.length) return; + for (const d of prev) { + try { + d.dispose(); + } catch { + /* ignore */ + } + } + g[DISPOSE_KEY] = []; +} + +export type VarHoverDecoration = { + range: Monaco.IRange; + options: { + hoverMessage: Monaco.IMarkdownString; + inlineClassName?: string; + }; +}; + +function hoverText(variable: SqlVariable | undefined, name: string, column?: string): string { + if (!variable) return `undefined: ${name}`; + if (variable.secret) return '(secret)'; + if (variable.kind === 'table' && !column) { + const r = variable.rows?.length ?? 0; + const c = variable.columns?.length ?? 0; + return `${r}×${c} table`; + } + const lit = expandVariableRef(variable, column); + return lit.ok ? lit.sql : lit.error; +} + +/** + * Decorations for `${{name}}` / `${{name.col}}` — hover shows value / table size. + */ +export function buildVariableHoverDecorations( + monaco: typeof Monaco, + text: string, + variables: SqlVariable[] +): VarHoverDecoration[] { + const byName = new Map(variables.map((v) => [v.name, v])); + const out: VarHoverDecoration[] = []; + const lines = text.split(/\n/); + + for (let lineNo = 0; lineNo < lines.length; lineNo++) { + const line = lines[lineNo]!; + VAR_AT.lastIndex = 0; + let m: RegExpExecArray | null; + while ((m = VAR_AT.exec(line)) !== null) { + const name = m[1]!; + const column = m[2]; + const startColumn = m.index + 1; + const endColumn = m.index + m[0].length + 1; + out.push({ + range: new monaco.Range(lineNo + 1, startColumn, lineNo + 1, endColumn), + options: { + hoverMessage: { value: hoverText(byName.get(name), name, column) }, + inlineClassName: 'fox-sql-var-ref', + }, + }); + } + } + return out; +} diff --git a/apps/web/src/frontend/lib/sql-variables.test.ts b/apps/web/src/frontend/lib/sql-variables.test.ts new file mode 100644 index 0000000..e26d72f --- /dev/null +++ b/apps/web/src/frontend/lib/sql-variables.test.ts @@ -0,0 +1,395 @@ +import { describe, expect, it } from 'vitest'; +import { + applySetDirectives, + columnToListValues, + expandSqlLiteral, + exportVariables, + findVariableRefs, + isSecretUnset, + isValidVariableName, + parseImportedVariables, + parseSetDirectives, + prepareStatement, + reattachSetComments, + resolveVariablesForConnection, + stripSecretsForPersist, + substituteStatements, + substituteVariables, + type SqlVariable, + SQL_VARIABLE_LIST_MAX, +} from './sql-variables'; + +function scalar(name: string, value: unknown, extra?: Partial): SqlVariable { + return { id: name, name, kind: 'scalar', value, updatedAt: 1, ...extra }; +} + +function list(name: string, values: unknown[], extra?: Partial): SqlVariable { + return { id: name, name, kind: 'list', values, updatedAt: 1, ...extra }; +} + +function table(name: string, columns: string[], rows: unknown[][]): SqlVariable { + return { id: name, name, kind: 'table', columns, rows, updatedAt: 1 }; +} + +describe('sql-variables', () => { + it('validates names', () => { + expect(isValidVariableName('user_id')).toBe(true); + expect(isValidVariableName('_x')).toBe(true); + expect(isValidVariableName('A1')).toBe(true); + expect(isValidVariableName('1bad')).toBe(false); + expect(isValidVariableName('bad-name')).toBe(false); + expect(isValidVariableName('')).toBe(false); + }); + + it('finds ${{name}} and ${{name.col}} refs; ignores $var / ${x}', () => { + expect(findVariableRefs('SELECT $var, ${x}, ${{a}}, ${{b.c}}, ${{a}}')).toEqual([ + { name: 'a' }, + { name: 'b', column: 'c' }, + ]); + }); + + it('expands scalars with SQL quoting (strings always quoted)', () => { + expect(expandSqlLiteral(42)).toBe('42'); + expect(expandSqlLiteral(true)).toBe('true'); + expect(expandSqlLiteral(null)).toBe('NULL'); + expect(expandSqlLiteral("O'Brien")).toBe("'O''Brien'"); + expect(expandSqlLiteral('hello')).toBe("'hello'"); + expect(expandSqlLiteral('true')).toBe("'true'"); + expect(expandSqlLiteral('false')).toBe("'false'"); + }); + + it('substitutes scalar and list', () => { + const vars = [scalar('user_id', 7), list('ids', [1, 2, "a'b"])]; + const r = substituteVariables( + 'SELECT * FROM t WHERE id = ${{user_id}} AND x IN (${{ids}})', + vars + ); + expect(r).toEqual({ + ok: true, + sql: "SELECT * FROM t WHERE id = 7 AND x IN (1,2,'a''b')", + }); + }); + + it('substitutes table as VALUES and table.col as list', () => { + const t = table('t', ['id', 'name'], [ + [1, "a'b"], + [2, 'x'], + ]); + expect(substituteVariables('INSERT INTO x SELECT * FROM (${{t}}) v', [t])).toEqual({ + ok: true, + sql: "INSERT INTO x SELECT * FROM (VALUES (1,'a''b'),(2,'x')) v", + }); + expect(substituteVariables('WHERE id IN (${{t.id}})', [t])).toEqual({ + ok: true, + sql: 'WHERE id IN (1,2)', + }); + }); + + it('fails on missing variable / column / empty list', () => { + expect(substituteVariables('SELECT ${{missing}}', [])).toEqual({ + ok: false, + error: 'Undefined variable: missing', + }); + expect(substituteVariables('SELECT ${{ids}}', [list('ids', [])])).toEqual({ + ok: false, + error: 'Variable "ids" is an empty list', + }); + expect( + substituteVariables('SELECT ${{t.nope}}', [table('t', ['id'], [[1]])]) + ).toEqual({ + ok: false, + error: 'Variable "t" has no column "nope"', + }); + }); + + it('leaves non-template dollars alone', () => { + const r = substituteVariables("SELECT '$var', '${x}', 1", [scalar('x', 1)]); + expect(r).toEqual({ ok: true, sql: "SELECT '$var', '${x}', 1" }); + }); + + it('substituteStatements fails fast', () => { + const r = substituteStatements(['SELECT 1', 'SELECT ${{nope}}'], []); + expect(r.ok).toBe(false); + if (!r.ok) expect(r.error).toContain('nope'); + }); + + it('columnToListValues skips nulls and caps length', () => { + const rows = [ + [1, null], + [2, 'a'], + [null, 'b'], + [3, undefined], + ]; + expect(columnToListValues(rows, 0)).toEqual([1, 2, 3]); + expect(columnToListValues(rows, 1)).toEqual(['a', 'b']); + + const many = Array.from({ length: SQL_VARIABLE_LIST_MAX + 20 }, (_, i) => [i]); + expect(columnToListValues(many, 0)).toHaveLength(SQL_VARIABLE_LIST_MAX); + }); + + it('parseSetDirectives strips @set lines and keeps SQL', () => { + const src = `-- @set orderid +-- @set ids = column id +-- @set t = table +SELECT id FROM t WHERE x = 1;`; + const parsed = parseSetDirectives(src); + expect(parsed.directives).toEqual([ + { mode: 'scalar', name: 'orderid' }, + { mode: 'column', name: 'ids', column: 'id' }, + { mode: 'table', name: 't' }, + ]); + expect(parsed.sql.trim()).toBe('SELECT id FROM t WHERE x = 1;'); + }); + + it('prepareStatement strips @set then substitutes', () => { + const r = prepareStatement( + `-- @set x +SELECT * FROM t WHERE id = ${'${{user_id}}'};`, + [scalar('user_id', 9)] + ); + expect(r.ok).toBe(true); + if (r.ok) { + expect(r.directives).toEqual([{ mode: 'scalar', name: 'x' }]); + expect(r.sql).toBe('SELECT * FROM t WHERE id = 9;'); + } + }); + + it('reattachSetComments restores @set lines the splitter drops', () => { + const full = `SELECT 1 AS id; +-- @set ids = column id +SELECT 2 AS id; +-- @set t = table +SELECT 3 AS a, 4 AS b;`; + const stmts = [ + { text: 'SELECT 1 AS id;', start: 0, end: full.indexOf(';') + 1 }, + { + text: 'SELECT 2 AS id;', + start: full.indexOf('SELECT 2'), + end: full.indexOf('SELECT 2') + 'SELECT 2 AS id;'.length, + }, + { + text: 'SELECT 3 AS a, 4 AS b;', + start: full.indexOf('SELECT 3'), + end: full.length, + }, + ]; + const enriched = reattachSetComments(full, stmts); + expect(enriched[0]).toBe('SELECT 1 AS id;'); + expect(enriched[1]).toContain('-- @set ids = column id'); + expect(enriched[1]).toContain('SELECT 2 AS id;'); + expect(parseSetDirectives(enriched[1]!).directives).toEqual([ + { mode: 'column', name: 'ids', column: 'id' }, + ]); + expect(enriched[2]).toContain('-- @set t = table'); + expect(parseSetDirectives(enriched[2]!).directives).toEqual([ + { mode: 'table', name: 't' }, + ]); + }); + + it('applySetDirectives builds scalar / list / table updates', () => { + const result = { + columns: ['ID', 'NAME'], + rows: [ + [10, 'a'], + [20, 'b'], + ], + }; + expect(applySetDirectives([{ mode: 'scalar', name: 'x' }], result)).toEqual({ + ok: true, + updates: [{ name: 'x', kind: 'scalar', value: 10 }], + }); + expect( + applySetDirectives([{ mode: 'column', name: 'ids', column: 'id' }], result) + ).toEqual({ + ok: true, + updates: [{ name: 'ids', kind: 'list', values: [10, 20] }], + }); + expect(applySetDirectives([{ mode: 'table', name: 't' }], result)).toEqual({ + ok: true, + updates: [ + { + name: 't', + kind: 'table', + columns: ['ID', 'NAME'], + rows: [ + [10, 'a'], + [20, 'b'], + ], + }, + ], + }); + }); + + it('resolveVariablesForConnection merges scalar/list overrides', () => { + const vars: SqlVariable[] = [ + scalar('x', 1, { overrides: { c1: { value: 99 }, c2: { value: 2 } } }), + list('ids', [1], { overrides: { c1: { values: [7, 8] } } }), + table('t', ['a'], [[1]]), + ]; + const forC1 = resolveVariablesForConnection(vars, 'c1'); + expect(forC1.find((v) => v.name === 'x')?.value).toBe(99); + expect(forC1.find((v) => v.name === 'ids')?.values).toEqual([7, 8]); + expect(forC1.find((v) => v.name === 't')?.rows).toEqual([[1]]); + expect(resolveVariablesForConnection(vars, 'missing').find((v) => v.name === 'x')?.value).toBe( + 1 + ); + }); + + it('stripSecretsForPersist and export omit secret payloads', () => { + const secret = scalar('tok', 'shh', { secret: true, overrides: { c1: { value: 'x' } } }); + const plain = scalar('n', 1); + const stripped = stripSecretsForPersist([secret, plain]); + expect(stripped[0]!.value).toBeUndefined(); + expect(stripped[0]!.secret).toBe(true); + expect(isSecretUnset(stripped[0]!)).toBe(true); + expect(stripped[1]!.value).toBe(1); + + const exported = exportVariables([secret, plain]); + expect(exported[0]).toEqual({ name: 'tok', kind: 'scalar', secret: true }); + expect(exported[1]).toEqual({ name: 'n', kind: 'scalar', value: 1 }); + }); + + it('maskSecrets substitutes (secret) for display', () => { + const v = scalar('tok', 'shh', { secret: true }); + expect(substituteVariables('SELECT ${{tok}}', [v], { maskSecrets: true })).toEqual({ + ok: true, + sql: 'SELECT (secret)', + }); + expect(substituteVariables('SELECT ${{tok}}', [v])).toEqual({ + ok: true, + sql: "SELECT 'shh'", + }); + }); + + it('parseImportedVariables validates and accepts secret stubs', () => { + const r = parseImportedVariables([ + { name: 'a', kind: 'scalar', value: 1 }, + { name: 'tok', kind: 'scalar', secret: true, value: 'leak' }, + ]); + expect(r.ok).toBe(true); + if (r.ok) { + expect(r.items[0]).toEqual({ name: 'a', kind: 'scalar', value: 1 }); + expect(r.items[1]).toEqual({ name: 'tok', kind: 'scalar', secret: true }); + } + expect(parseImportedVariables({ nope: true }).ok).toBe(false); + }); + + it('unset secret fails substitution', () => { + const v = scalar('tok', undefined, { secret: true }); + expect(substituteVariables('SELECT ${{tok}}', [v])).toEqual({ + ok: false, + error: 'Secret variable "tok" is unset — enter a value for this session', + }); + }); + + it('resolveVariablesForConnection can override scalar to null', () => { + const vars = [scalar('x', 1, { overrides: { c1: { value: null } } })]; + const resolved = resolveVariablesForConnection(vars, 'c1'); + expect(resolved[0]!.value).toBeNull(); + expect(substituteVariables('SELECT ${{x}}', resolved)).toEqual({ + ok: true, + sql: 'SELECT NULL', + }); + }); + + it('prepareStatement uses per-connection resolved values', () => { + const base = scalar('user_id', 1, { overrides: { prod: { value: 42 } } }); + const forProd = resolveVariablesForConnection([base], 'prod'); + const r = prepareStatement('SELECT * FROM t WHERE id = ${{user_id}};', forProd); + expect(r.ok).toBe(true); + if (r.ok) expect(r.sql).toBe('SELECT * FROM t WHERE id = 42;'); + }); + + it('stripSecretsForPersist clears list/table secret payloads but keeps columns', () => { + const secretList = list('ids', [1, 2], { secret: true }); + const secretTable: SqlVariable = { + id: 't', + name: 't', + kind: 'table', + secret: true, + columns: ['id', 'name'], + rows: [[1, 'a']], + updatedAt: 1, + }; + const stripped = stripSecretsForPersist([secretList, secretTable]); + expect(stripped[0]!.values).toBeUndefined(); + expect(stripped[0]!.secret).toBe(true); + expect(stripped[1]!.rows).toBeUndefined(); + expect(stripped[1]!.columns).toEqual(['id', 'name']); + expect(isSecretUnset(stripped[0]!)).toBe(true); + expect(isSecretUnset(stripped[1]!)).toBe(true); + }); + + it('exportVariables includes list/table bodies and omits empty overrides', () => { + const vars: SqlVariable[] = [ + list('ids', [1, 2], { overrides: { c1: { values: [9] } } }), + table('t', ['a'], [[1]]), + scalar('x', 1, { overrides: {} }), + ]; + const exported = exportVariables(vars); + expect(exported[0]).toEqual({ + name: 'ids', + kind: 'list', + values: [1, 2], + overrides: { c1: { values: [9] } }, + }); + expect(exported[1]).toEqual({ name: 't', kind: 'table', columns: ['a'], rows: [[1]] }); + expect(exported[2]).toEqual({ name: 'x', kind: 'scalar', value: 1 }); + }); + + it('parseImportedVariables round-trips list and table; rejects bad list', () => { + const ok = parseImportedVariables([ + { name: 'ids', kind: 'list', values: [1, 2] }, + { name: 't', kind: 'table', columns: ['id'], rows: [[1]] }, + ]); + expect(ok).toEqual({ + ok: true, + items: [ + { name: 'ids', kind: 'list', values: [1, 2] }, + { name: 't', kind: 'table', columns: ['id'], rows: [[1]] }, + ], + }); + expect(parseImportedVariables([{ name: 'bad', kind: 'list' }]).ok).toBe(false); + expect(parseImportedVariables([{ name: '1bad', kind: 'scalar' }]).ok).toBe(false); + }); + + it('maskSecrets only masks secret refs in mixed SQL', () => { + const vars = [scalar('tok', 'shh', { secret: true }), scalar('n', 3)]; + expect( + substituteVariables('SELECT ${{tok}}, ${{n}}', vars, { maskSecrets: true }) + ).toEqual({ ok: true, sql: 'SELECT (secret), 3' }); + }); + + it('applySetDirectives fails with clear errors', () => { + expect(applySetDirectives([{ mode: 'scalar', name: 'x' }], { columns: [], rows: [] })).toEqual( + { + ok: false, + error: '@set x: result has no cells to capture', + } + ); + expect( + applySetDirectives([{ mode: 'column', name: 'ids', column: 'missing' }], { + columns: ['id'], + rows: [[1]], + }) + ).toEqual({ + ok: false, + error: '@set ids: column "missing" not in result', + }); + }); + + it('export → parseImportedVariables keeps secret stubs without leaked values', () => { + const exported = exportVariables([ + scalar('tok', 'should-not-leak', { secret: true }), + list('ids', [1], { secret: true }), + ]); + const parsed = parseImportedVariables(exported); + expect(parsed.ok).toBe(true); + if (parsed.ok) { + expect(parsed.items).toEqual([ + { name: 'tok', kind: 'scalar', secret: true }, + { name: 'ids', kind: 'list', secret: true }, + ]); + } + }); +}); diff --git a/apps/web/src/frontend/lib/sql-variables.ts b/apps/web/src/frontend/lib/sql-variables.ts new file mode 100644 index 0000000..d034c93 --- /dev/null +++ b/apps/web/src/frontend/lib/sql-variables.ts @@ -0,0 +1,593 @@ +/** Max values kept when saving a result column as a list variable. */ +export const SQL_VARIABLE_LIST_MAX = 500; +/** Max rows kept when saving a result as a table variable. */ +export const SQL_VARIABLE_TABLE_MAX = 500; + +const VAR_NAME_RE = /^[A-Za-z_][A-Za-z0-9_]*$/; +/** + * Matches `${{name}}` or `${{name.col}}` — not `$var` or `${x}`. + * Group 1 = variable name, group 2 = optional column. + */ +const VAR_REF_RE = /\$\{\{([A-Za-z_][A-Za-z0-9_]*)(?:\.([A-Za-z_][A-Za-z0-9_]*))?\}\}/g; + +/** `-- @set name` / `-- @set name = column Col` / `-- @set name = table` */ +const SET_LINE_RE = + /^--\s*@set\s+([A-Za-z_][A-Za-z0-9_]*)\s*(?:=\s*(column\s+(\S+)|table))?\s*$/i; + +export type SqlVariableKind = 'scalar' | 'list' | 'table'; + +/** Per-connection override for scalar / list (tables stay global). */ +export type VariableOverride = { + value?: unknown; + values?: unknown[]; +}; + +export interface SqlVariable { + id: string; + name: string; + kind: SqlVariableKind; + /** Present when kind === 'scalar'. */ + value?: unknown; + /** Present when kind === 'list'. */ + values?: unknown[]; + /** Present when kind === 'table'. */ + columns?: string[]; + rows?: unknown[][]; + /** When true, UI masks the value; secret payloads are not persisted. */ + secret?: boolean; + /** Optional scalar/list overrides keyed by connection id. */ + overrides?: Record; + updatedAt: number; +} + +/** JSON shape for export / import (no ids). */ +export type SqlVariableExport = { + name: string; + kind: SqlVariableKind; + secret?: boolean; + value?: unknown; + values?: unknown[]; + columns?: string[]; + rows?: unknown[][]; + overrides?: Record; +}; + +export type VariableRef = { name: string; column?: string }; + +export type SetDirective = + | { mode: 'scalar'; name: string } + | { mode: 'column'; name: string; column: string } + | { mode: 'table'; name: string }; + +export function isValidVariableName(name: string): boolean { + return VAR_NAME_RE.test(name); +} + +export function normalizeVariableName(raw: string): string { + return raw.trim(); +} + +/** Unique refs in order of first appearance (`name` or `name.col`). */ +export function findVariableRefs(sql: string): VariableRef[] { + const seen = new Set(); + const out: VariableRef[] = []; + VAR_REF_RE.lastIndex = 0; + let m: RegExpExecArray | null; + while ((m = VAR_REF_RE.exec(sql)) !== null) { + const name = m[1]!; + const column = m[2]; + const key = column ? `${name}.${column}` : name; + if (seen.has(key)) continue; + seen.add(key); + out.push(column ? { name, column } : { name }); + } + return out; +} + +/** True if SQL contains any `${{…}}` ref (including `.col`). */ +export function hasVariableRefs(sql: string): boolean { + return findVariableRefs(sql).length > 0; +} + +/** Expand one cell value to a SQL literal fragment. */ +export function expandSqlLiteral(value: unknown): string { + if (value === null || value === undefined) return 'NULL'; + if (typeof value === 'number') { + if (!Number.isFinite(value)) return 'NULL'; + return String(value); + } + if (typeof value === 'bigint') return value.toString(); + if (typeof value === 'boolean') return value ? 'true' : 'false'; + if (typeof value === 'string') { + // Always quote strings (including "true"/"false"/numeric text from drivers). + return quoteSqlString(value); + } + if (value instanceof Date) return quoteSqlString(value.toISOString()); + if (typeof value === 'object') { + try { + return quoteSqlString(JSON.stringify(value)); + } catch { + return quoteSqlString(String(value)); + } + } + return quoteSqlString(String(value)); +} + +function quoteSqlString(s: string): string { + return `'${s.replace(/'/g, "''")}'`; +} + +function expandListValues(name: string, values: unknown[]): SubstituteResult { + if (values.length === 0) { + return { ok: false, error: `Variable "${name}" is an empty list` }; + } + return { ok: true, sql: values.map(expandSqlLiteral).join(',') }; +} + +function expandTableValues( + name: string, + columns: string[], + rows: unknown[][] +): SubstituteResult { + if (rows.length === 0) { + return { ok: false, error: `Variable "${name}" is an empty table` }; + } + if (columns.length === 0) { + return { ok: false, error: `Variable "${name}" has no columns` }; + } + const tuples = rows.map((row) => { + const cells = columns.map((_, i) => expandSqlLiteral(row[i])); + return `(${cells.join(',')})`; + }); + return { ok: true, sql: `VALUES ${tuples.join(',')}` }; +} + +function columnIndex(columns: string[], colName: string): number { + const want = colName.toLowerCase(); + return columns.findIndex((c) => c.toLowerCase() === want); +} + +/** True when a secret has no in-memory value (e.g. after reload). */ +export function isSecretUnset(variable: SqlVariable): boolean { + if (!variable.secret) return false; + if (variable.kind === 'scalar') return variable.value === undefined; + if (variable.kind === 'list') return !variable.values || variable.values.length === 0; + return !variable.rows || variable.rows.length === 0; +} + +/** Expand a bare variable (no `.col`). */ +export function expandVariable( + variable: SqlVariable +): { ok: true; sql: string } | { ok: false; error: string } { + if (isSecretUnset(variable)) { + return { + ok: false, + error: `Secret variable "${variable.name}" is unset — enter a value for this session`, + }; + } + if (variable.kind === 'scalar') { + return { ok: true, sql: expandSqlLiteral(variable.value) }; + } + if (variable.kind === 'list') { + return expandListValues(variable.name, variable.values ?? []); + } + return expandTableValues(variable.name, variable.columns ?? [], variable.rows ?? []); +} + +/** Expand `${{name}}` or `${{name.col}}`. */ +export function expandVariableRef( + variable: SqlVariable, + column?: string +): { ok: true; sql: string } | { ok: false; error: string } { + if (!column) return expandVariable(variable); + + if (variable.kind === 'table') { + const cols = variable.columns ?? []; + const idx = columnIndex(cols, column); + if (idx < 0) { + return { + ok: false, + error: `Variable "${variable.name}" has no column "${column}"`, + }; + } + return expandListValues( + `${variable.name}.${column}`, + columnToListValues(variable.rows ?? [], idx) + ); + } + + if (variable.kind === 'list' || variable.kind === 'scalar') { + return { + ok: false, + error: `Variable "${variable.name}" is ${variable.kind}; use \${{${variable.name}}} without .${column}`, + }; + } + + return { ok: false, error: `Variable "${variable.name}" cannot expand .${column}` }; +} + +export type SubstituteOk = { ok: true; sql: string }; +export type SubstituteErr = { ok: false; error: string }; +export type SubstituteResult = SubstituteOk | SubstituteErr; + +export type SubstituteOptions = { + /** Replace secret variable refs with `(secret)` instead of the real literal. */ + maskSecrets?: boolean; +}; + +/** + * Replace `${{name}}` / `${{name.col}}` refs with SQL literals. + * Unknown syntax (`$x`, `${x}`) is left unchanged. + */ +export function substituteVariables( + sql: string, + variables: SqlVariable[], + opts?: SubstituteOptions +): SubstituteResult { + const byName = new Map(variables.map((v) => [v.name, v])); + const refs = findVariableRefs(sql); + const cache = new Map(); + + for (const ref of refs) { + const key = ref.column ? `${ref.name}.${ref.column}` : ref.name; + const v = byName.get(ref.name); + if (!v) return { ok: false, error: `Undefined variable: ${ref.name}` }; + if (opts?.maskSecrets && v.secret) { + cache.set(key, '(secret)'); + continue; + } + const expanded = expandVariableRef(v, ref.column); + if (!expanded.ok) return expanded; + cache.set(key, expanded.sql); + } + + VAR_REF_RE.lastIndex = 0; + const out = sql.replace(VAR_REF_RE, (_full, name: string, column?: string) => { + const key = column ? `${name}.${column}` : name; + return cache.get(key)!; + }); + return { ok: true, sql: out }; +} + +/** + * Merge per-connection scalar/list overrides over the global base. + * Table variables are unchanged. + */ +export function resolveVariablesForConnection( + variables: SqlVariable[], + connectionId: string +): SqlVariable[] { + return variables.map((v) => { + if (v.kind === 'table') return v; + const o = v.overrides?.[connectionId]; + if (!o) return v; + if (v.kind === 'scalar' && Object.prototype.hasOwnProperty.call(o, 'value')) { + return { ...v, value: o.value }; + } + if (v.kind === 'list' && o.values !== undefined) { + return { ...v, values: o.values }; + } + return v; + }); +} + +/** Drop secret payloads before localStorage (keep name/kind/secret/overrides keys). */ +export function stripSecretsForPersist(variables: SqlVariable[]): SqlVariable[] { + return variables.map((v) => { + if (!v.secret) return { ...v }; + const overrides = v.overrides + ? Object.fromEntries( + Object.entries(v.overrides).map(([id]) => [id, {} as VariableOverride]) + ) + : undefined; + if (v.kind === 'scalar') { + return { + id: v.id, + name: v.name, + kind: 'scalar' as const, + secret: true, + overrides, + updatedAt: v.updatedAt, + }; + } + if (v.kind === 'list') { + return { + id: v.id, + name: v.name, + kind: 'list' as const, + secret: true, + overrides, + updatedAt: v.updatedAt, + }; + } + return { + id: v.id, + name: v.name, + kind: 'table' as const, + secret: true, + columns: v.columns ? [...v.columns] : undefined, + overrides: undefined, + updatedAt: v.updatedAt, + }; + }); +} + +/** Serialize variables for download; secret values are omitted. */ +export function exportVariables(variables: SqlVariable[]): SqlVariableExport[] { + return variables.map((v) => { + const base: SqlVariableExport = { + name: v.name, + kind: v.kind, + ...(v.secret ? { secret: true } : {}), + }; + if (v.secret) return base; + if (v.kind === 'scalar') { + return { + ...base, + value: v.value, + ...(v.overrides && Object.keys(v.overrides).length > 0 + ? { overrides: v.overrides } + : {}), + }; + } + if (v.kind === 'list') { + return { + ...base, + values: v.values, + ...(v.overrides && Object.keys(v.overrides).length > 0 + ? { overrides: v.overrides } + : {}), + }; + } + return { + ...base, + columns: v.columns, + rows: v.rows, + }; + }); +} + +/** Validate import JSON; returns normalized export items. */ +export function parseImportedVariables( + raw: unknown +): { ok: true; items: SqlVariableExport[] } | { ok: false; error: string } { + if (!Array.isArray(raw)) { + return { ok: false, error: 'Import must be a JSON array of variables' }; + } + const items: SqlVariableExport[] = []; + for (const row of raw) { + if (!row || typeof row !== 'object') { + return { ok: false, error: 'Each import entry must be an object' }; + } + const r = row as Record; + const name = typeof r.name === 'string' ? normalizeVariableName(r.name) : ''; + if (!isValidVariableName(name)) { + return { ok: false, error: `Invalid variable name: ${String(r.name)}` }; + } + const kind = r.kind; + if (kind !== 'scalar' && kind !== 'list' && kind !== 'table') { + return { ok: false, error: `Invalid kind for "${name}"` }; + } + const secret = r.secret === true; + const item: SqlVariableExport = { name, kind, ...(secret ? { secret: true } : {}) }; + if (!secret) { + if (kind === 'scalar') item.value = r.value; + if (kind === 'list') { + if (!Array.isArray(r.values)) { + return { ok: false, error: `List "${name}" needs a values array` }; + } + item.values = r.values; + } + if (kind === 'table') { + if (!Array.isArray(r.columns)) { + return { ok: false, error: `Table "${name}" needs columns` }; + } + item.columns = r.columns as string[]; + item.rows = Array.isArray(r.rows) ? (r.rows as unknown[][]) : []; + } + if (r.overrides && typeof r.overrides === 'object' && !Array.isArray(r.overrides)) { + item.overrides = r.overrides as Record; + } + } + items.push(item); + } + return { ok: true, items }; +} + +/** Substitute each statement; fail fast with the first error. */ +export function substituteStatements( + statements: string[], + variables: SqlVariable[] +): { ok: true; statements: string[] } | SubstituteErr { + const next: string[] = []; + for (const stmt of statements) { + const r = substituteVariables(stmt, variables); + if (!r.ok) return r; + next.push(r.sql); + } + return { ok: true, statements: next }; +} + +/** + * Parse leading `-- @set …` comments and return them plus SQL with those lines removed. + * Other leading comments / blank lines are kept in `sql`. + */ +export function parseSetDirectives(statement: string): { + directives: SetDirective[]; + sql: string; +} { + const lines = statement.split(/\r?\n/); + const directives: SetDirective[] = []; + let i = 0; + while (i < lines.length) { + const raw = lines[i]!; + const trimmed = raw.trim(); + if (trimmed === '') { + i += 1; + continue; + } + const m = SET_LINE_RE.exec(trimmed); + if (!m) break; + const name = m[1]!; + const rhs = m[2]; + const col = m[3]; + if (!rhs) { + directives.push({ mode: 'scalar', name }); + } else if (/^table$/i.test(rhs.trim())) { + directives.push({ mode: 'table', name }); + } else if (col) { + directives.push({ mode: 'column', name, column: col }); + } + i += 1; + } + + // Drop consumed leading blank/@set lines; keep the rest (including blanks after). + const sql = lines.slice(i).join('\n').replace(/^\n+/, ''); + return { directives, sql: sql.length ? sql : statement.trim() === '' ? '' : sql }; +} + +/** True when a line is a `-- @set …` directive. */ +export function isSetCommentLine(line: string): boolean { + return SET_LINE_RE.test(line.trim()); +} + +/** + * Extract `-- @set …` lines from text between statements (splitter drops them + * as separators). Other comments/blank lines are ignored. + */ +export function extractSetCommentLines(gap: string): string[] { + const out: string[] = []; + for (const line of gap.split(/\r?\n/)) { + const t = line.trim(); + if (!t) continue; + if (isSetCommentLine(t)) out.push(t); + } + return out; +} + +/** + * The statement splitter treats comments between statements as separators, so + * `-- @set` lines never appear in statement text. Re-attach each inter-statement + * `@set` block to the **following** statement (put `-- @set` above the SELECT). + * Trailing `@set` lines after the last statement attach to that last statement. + */ +export function reattachSetComments( + fullSql: string, + stmts: Array<{ text: string; start: number; end: number }> +): string[] { + if (stmts.length === 0) return []; + return stmts.map((stmt, i) => { + const prevEnd = i === 0 ? 0 : stmts[i - 1]!.end; + const leading = extractSetCommentLines(fullSql.slice(prevEnd, stmt.start)); + const trailing = + i === stmts.length - 1 + ? extractSetCommentLines(fullSql.slice(stmt.end)) + : []; + const sets = [...leading, ...trailing]; + if (sets.length === 0) return stmt.text; + return `${sets.join('\n')}\n${stmt.text}`; + }); +} + +/** Prepare one statement: strip @set lines, then substitute variables into the SQL. */ +export function prepareStatement( + statement: string, + variables: SqlVariable[] +): { ok: true; sql: string; directives: SetDirective[] } | SubstituteErr { + const { directives, sql: stripped } = parseSetDirectives(statement); + if (!stripped.trim()) { + return { ok: false, error: 'Statement is empty after removing @set directives' }; + } + const sub = substituteVariables(stripped, variables); + if (!sub.ok) return sub; + return { ok: true, sql: sub.sql, directives }; +} + +export type SetUpdate = { + name: string; + kind: SqlVariableKind; + value?: unknown; + values?: unknown[]; + columns?: string[]; + rows?: unknown[][]; +}; + +export type ApplySetResult = + | { ok: true; updates: SetUpdate[] } + | { ok: false; error: string }; + +/** + * Build variable upserts from @set directives + a successful statement result. + */ +export function applySetDirectives( + directives: SetDirective[], + result: { columns: string[]; rows: unknown[][] } +): ApplySetResult { + if (directives.length === 0) return { ok: true, updates: [] }; + const updates: SetUpdate[] = []; + + for (const d of directives) { + if (d.mode === 'scalar') { + if (result.rows.length === 0 || result.columns.length === 0) { + return { + ok: false, + error: `@set ${d.name}: result has no cells to capture`, + }; + } + updates.push({ + name: d.name, + kind: 'scalar', + value: result.rows[0]![0], + }); + continue; + } + if (d.mode === 'column') { + const idx = columnIndex(result.columns, d.column); + if (idx < 0) { + return { + ok: false, + error: `@set ${d.name}: column "${d.column}" not in result`, + }; + } + const values = columnToListValues(result.rows, idx); + if (values.length === 0) { + return { + ok: false, + error: `@set ${d.name}: column "${d.column}" has no non-null values`, + }; + } + updates.push({ name: d.name, kind: 'list', values }); + continue; + } + // table + if (result.columns.length === 0) { + return { ok: false, error: `@set ${d.name}: result has no columns` }; + } + const rows = result.rows.slice(0, SQL_VARIABLE_TABLE_MAX); + updates.push({ + name: d.name, + kind: 'table', + columns: [...result.columns], + rows, + }); + } + + return { ok: true, updates }; +} + +/** Values from a result column suitable for a list variable (skip null/undefined). */ +export function columnToListValues(rows: unknown[][], colIndex: number): unknown[] { + const out: unknown[] = []; + for (const row of rows) { + if (out.length >= SQL_VARIABLE_LIST_MAX) break; + const cell = row[colIndex]; + if (cell === null || cell === undefined) continue; + out.push(cell); + } + return out; +} + +/** Cap rows for a table variable. */ +export function rowsForTableVariable(rows: unknown[][]): unknown[][] { + return rows.slice(0, SQL_VARIABLE_TABLE_MAX); +} diff --git a/apps/web/src/frontend/store/sqlEditorTabLogic.test.ts b/apps/web/src/frontend/store/sqlEditorTabLogic.test.ts index b3c5492..a8bb6e8 100644 --- a/apps/web/src/frontend/store/sqlEditorTabLogic.test.ts +++ b/apps/web/src/frontend/store/sqlEditorTabLogic.test.ts @@ -9,6 +9,7 @@ import { nextTabTitle, persistableTabs, statementsToRun, + statementsFromSelection, toggleStatementCheck, } from './sqlEditorTabLogic'; @@ -57,6 +58,24 @@ describe('sqlEditorTabLogic', () => { expect(statementsToRun('', [])).toEqual([]); }); + it('statementsToRun keeps inter-statement -- @set on the following statement', () => { + const sql = `SELECT 1 AS id; +-- @set ids = column id +SELECT 2 AS id;`; + const out = statementsToRun(sql, [0, 1]); + expect(out[0]).toBe('SELECT 1 AS id;'); + expect(out[1]).toContain('-- @set ids = column id'); + expect(out[1]).toContain('SELECT 2 AS id;'); + }); + + it('statementsFromSelection runs all statements in the selection', () => { + expect(statementsFromSelection('SELECT 1; SELECT 2;')).toEqual(['SELECT 1;', 'SELECT 2;']); + expect(statementsFromSelection(' SELECT * FROM t WHERE id = 1; ')).toEqual([ + 'SELECT * FROM t WHERE id = 1;', + ]); + expect(statementsFromSelection('')).toEqual([]); + }); + it('toggleStatementCheck adds/removes sorted', () => { expect(toggleStatementCheck([0], 2)).toEqual([0, 2]); expect(toggleStatementCheck([0, 2], 0)).toEqual([2]); diff --git a/apps/web/src/frontend/store/sqlEditorTabLogic.ts b/apps/web/src/frontend/store/sqlEditorTabLogic.ts index e22f9b7..b405d10 100644 --- a/apps/web/src/frontend/store/sqlEditorTabLogic.ts +++ b/apps/web/src/frontend/store/sqlEditorTabLogic.ts @@ -1,4 +1,5 @@ import { splitSqlStatements } from '../lib/sql-splitter'; +import { reattachSetComments } from '../lib/sql-variables'; export type ResultsLayout = 'byCredential' | 'sideBySide'; @@ -89,16 +90,30 @@ export function checkedAfterSqlChange( /** * Statements to send on Run. Empty check set → first statement only. * Checked indices are sorted and de-duplicated. + * Re-attaches inter-statement `-- @set` comments the splitter would drop. */ export function statementsToRun(sql: string, checkedStatements: number[]): string[] { const all = splitSqlStatements(sql); if (all.length === 0) return []; - if (checkedStatements.length === 0) return [all[0]!.text]; + const enriched = reattachSetComments(sql, all); + if (checkedStatements.length === 0) return [enriched[0]!]; const uniq = [...new Set(checkedStatements)] - .filter((i) => i >= 0 && i < all.length) + .filter((i) => i >= 0 && i < enriched.length) .sort((a, b) => a - b); - if (uniq.length === 0) return [all[0]!.text]; - return uniq.map((i) => all[i]!.text); + if (uniq.length === 0) return [enriched[0]!]; + return uniq.map((i) => enriched[i]!); +} + +/** + * All statements inside an editor selection (selection wins over the strip). + * Falls back to the trimmed selection text when the splitter finds nothing. + */ +export function statementsFromSelection(selectedSql: string): string[] { + const trimmed = selectedSql.trim(); + if (!trimmed) return []; + const all = splitSqlStatements(trimmed); + if (all.length === 0) return [trimmed]; + return reattachSetComments(trimmed, all); } export function toggleStatementCheck(checked: number[], index: number): number[] { diff --git a/apps/web/src/frontend/store/useSqlEditorStore.ts b/apps/web/src/frontend/store/useSqlEditorStore.ts index 5f1d5ed..4cc645c 100644 --- a/apps/web/src/frontend/store/useSqlEditorStore.ts +++ b/apps/web/src/frontend/store/useSqlEditorStore.ts @@ -3,8 +3,24 @@ import { persist } from 'zustand/middleware'; import { executeSql, type SqlStatementResult } from '../api/sqlApi'; import { loadSchema } from '../api/schemaApi'; import { isMutatingDmlStatement, isWriteStatement } from '../lib/sql-splitter'; +import { + applySetDirectives, + exportVariables, + isValidVariableName, + normalizeVariableName, + parseImportedVariables, + parseSetDirectives, + prepareStatement, + resolveVariablesForConnection, + stripSecretsForPersist, + type SqlVariable, + type SqlVariableExport, + type SqlVariableKind, + type VariableOverride, +} from '../lib/sql-variables'; import { useSyncStore } from './useSyncStore'; import type { SchemaCacheEntry } from '../components/sql-editor/sqlEditorBridge'; +import { getSelectedSql } from '../components/sql-editor/sqlEditorBridge'; import { addTab as addTabLogic, checkedAfterSqlChange, @@ -14,12 +30,15 @@ import { hydrateTabs, newTabId, persistableTabs, + statementsFromSelection, statementsToRun, toggleStatementCheck, type ResultsLayout, type SqlTab, } from './sqlEditorTabLogic'; +export type { SqlVariable, SqlVariableKind, SqlVariableExport, VariableOverride }; + /** Dialects whose adapters are SELECT-only — writes fail with a friendly error. */ const READONLY_DIALECTS = new Set(['sqlite', 'clickhouse']); @@ -47,6 +66,8 @@ export interface CredentialRun { export interface TabResults { ranStatements: string[]; runs: CredentialRun[]; + /** Non-fatal messages (e.g. `@set` failures) for this run. */ + warnings?: string[]; } /** Password prompt for a connection saved without one (session-only). */ @@ -99,6 +120,8 @@ interface SqlEditorState { sharedConnectionIds: string[]; /** Named saved scripts — persisted. */ bookmarks: SqlBookmark[]; + /** Global SQL Editor variables (`${{name}}`) — persisted. */ + variables: SqlVariable[]; activeTab: () => SqlTab; /** Destination server ids for the active tab (respects shareDestinations). */ @@ -125,6 +148,29 @@ interface SqlEditorState { openBookmark: (id: string) => void; renameBookmark: (id: string, title: string) => void; deleteBookmark: (id: string) => void; + /** Create or overwrite a variable by name. Returns error string or null. */ + upsertVariable: (input: { + name: string; + kind: SqlVariableKind; + value?: unknown; + values?: unknown[]; + columns?: string[]; + rows?: unknown[][]; + secret?: boolean; + overrides?: Record; + /** When set, rename/update that id instead of matching by name. */ + id?: string; + }) => string | null; + deleteVariable: (id: string) => void; + setVariableSecret: (id: string, secret: boolean) => void; + setVariableOverride: ( + id: string, + connectionId: string, + override: VariableOverride | null + ) => void; + /** Merge imported variables by name. Returns error or null. */ + importVariables: (raw: unknown, opts?: { overwrite?: boolean }) => string | null; + exportVariablesJson: () => string; } const firstTab = createTab({ title: 'Query 1' }); @@ -147,6 +193,7 @@ export const useSqlEditorStore = create()( shareDestinations: true, sharedConnectionIds: [], bookmarks: [], + variables: [], activeTab: () => { const { tabs, activeTabId } = get(); @@ -455,11 +502,15 @@ export const useSqlEditorStore = create()( return; } - const statements = statementsToRun(tab.sql, tab.checkedStatements); - if (statements.length === 0) return; + const selectedSql = getSelectedSql(); + const rawStatements = selectedSql + ? statementsFromSelection(selectedSql) + : statementsToRun(tab.sql, tab.checkedStatements); + if (rawStatements.length === 0) return; - // Safe mode: confirm UPDATE / DELETE / MERGE (and other writes) before run. - const writeStatements = statements.filter((s) => isWriteStatement(s)); + // Safe mode: confirm on stripped SQL (ignore @set lines; vars may resolve mid-run). + const strippedForConfirm = rawStatements.map((s) => parseSetDirectives(s).sql); + const writeStatements = strippedForConfirm.filter((s) => isWriteStatement(s)); const mutatingDml = writeStatements.filter((s) => isMutatingDmlStatement(s)); const needsConfirm = safeMode && @@ -514,14 +565,19 @@ export const useSqlEditorStore = create()( nextRuns = connections.map(runningStub); } + // Accumulate per-connection results as we run statements sequentially. + const resultsByConn = new Map(); + for (const c of connections) resultsByConn.set(c.id, []); + set({ pendingWriteConfirm: null, runningTabId: tabId, resultsByTab: { ...get().resultsByTab, [tabId]: { - ranStatements: statements, + ranStatements: [], runs: nextRuns, + warnings: [], }, }, }); @@ -541,24 +597,161 @@ export const useSqlEditorStore = create()( }; }); - await Promise.allSettled( - connections.map(async (c) => { - try { - const { results } = await executeSql( - { connectionId: c.id, password: sessionPasswords[c.id] || undefined }, - statements, - maxRows - ); - patchRun(c.id, { status: 'done', results, error: undefined }); - } catch (error: unknown) { + const setRanStatements = (stmts: string[]) => + set((state) => { + const current = state.resultsByTab[tabId]; + if (!current) return state; + return { + resultsByTab: { + ...state.resultsByTab, + [tabId]: { ...current, ranStatements: stmts }, + }, + }; + }); + + const appendWarning = (msg: string) => + set((state) => { + const current = state.resultsByTab[tabId]; + if (!current) return state; + return { + resultsByTab: { + ...state.resultsByTab, + [tabId]: { + ...current, + warnings: [...(current.warnings ?? []), msg], + }, + }, + }; + }); + + const ranDisplay: string[] = []; + let aborted: string | null = null; + + for (let si = 0; si < rawStatements.length; si++) { + const raw = rawStatements[si]!; + const { directives } = parseSetDirectives(raw); + + type Prep = + | { ok: true; sql: string } + | { ok: false; error: string }; + const preparedByConn = new Map(); + for (const c of connections) { + const vars = resolveVariablesForConnection(get().variables, c.id); + const prepared = prepareStatement(raw, vars); + preparedByConn.set( + c.id, + prepared.ok + ? { ok: true, sql: prepared.sql } + : { ok: false, error: prepared.error } + ); + } + + const firstOk = [...preparedByConn.values()].find((p) => p.ok); + const firstErr = [...preparedByConn.values()].find((p) => !p.ok); + if (!firstOk) { + aborted = firstErr && !firstErr.ok ? firstErr.error : 'Variable substitution failed'; + for (const c of connections) { + const prep = preparedByConn.get(c.id)!; + const prev = resultsByConn.get(c.id) ?? []; + const filler: SqlStatementResult = { + ok: false, + error: prep.ok ? aborted : prep.error, + durationMs: 0, + }; + while (prev.length < si) { + prev.push({ ok: false, error: 'Skipped', durationMs: 0 }); + } + prev.push(filler); + resultsByConn.set(c.id, prev); patchRun(c.id, { status: 'error', - error: error instanceof Error ? error.message : String(error), - results: undefined, + error: prep.ok ? aborted : prep.error, + results: [...prev], }); } - }) - ); + break; + } + + ranDisplay.push(firstOk.sql); + setRanStatements([...ranDisplay]); + + await Promise.allSettled( + connections.map(async (c) => { + const prev = resultsByConn.get(c.id) ?? []; + const prep = preparedByConn.get(c.id)!; + if (!prep.ok) { + prev.push({ ok: false, error: prep.error, durationMs: 0 }); + resultsByConn.set(c.id, prev); + patchRun(c.id, { + status: 'error', + error: prep.error, + results: [...prev], + }); + return; + } + try { + const { results } = await executeSql( + { connectionId: c.id, password: sessionPasswords[c.id] || undefined }, + [prep.sql], + maxRows + ); + const one = results[0] ?? { + ok: false as const, + error: 'No result returned', + durationMs: 0, + }; + prev.push(one); + resultsByConn.set(c.id, prev); + const last = si === rawStatements.length - 1; + patchRun(c.id, { + status: last ? 'done' : 'running', + results: [...prev], + error: undefined, + }); + } catch (error: unknown) { + const msg = error instanceof Error ? error.message : String(error); + prev.push({ ok: false, error: msg, durationMs: 0 }); + resultsByConn.set(c.id, prev); + patchRun(c.id, { + status: 'error', + error: msg, + results: [...prev], + }); + } + }) + ); + + // @set from the first successful credential result (connection list order). + // Writes the global base value (not a per-connection override). + if (directives.length > 0) { + for (const c of connections) { + const res = resultsByConn.get(c.id)?.[si]; + if (res && res.ok) { + const sets = applySetDirectives(directives, res); + if (sets.ok) { + for (const u of sets.updates) { + get().upsertVariable(u); + } + } else { + appendWarning(sets.error); + } + break; + } + } + } + } + + // Mark any still-running connections as done. + for (const c of connections) { + const run = get().resultsByTab[tabId]?.runs.find((r) => r.connectionId === c.id); + if (run?.status === 'running') { + patchRun(c.id, { + status: aborted ? 'error' : 'done', + error: aborted ?? run.error, + results: resultsByConn.get(c.id), + }); + } + } set({ runningTabId: null }); }, @@ -642,11 +835,146 @@ export const useSqlEditorStore = create()( ), }); }, + + upsertVariable: (input) => { + const name = normalizeVariableName(input.name); + if (!isValidVariableName(name)) { + return 'Name must match [A-Za-z_][A-Za-z0-9_]*'; + } + const allowEmptyList = input.secret === true; + if ( + input.kind === 'list' && + (!input.values || input.values.length === 0) && + !allowEmptyList + ) { + return 'List variable needs at least one value'; + } + if (input.kind === 'table' && (!input.columns || input.columns.length === 0)) { + return 'Table variable needs columns'; + } + const { variables } = get(); + const byId = input.id ? variables.find((v) => v.id === input.id) : undefined; + const nameClash = variables.find( + (v) => v.name === name && (!byId || v.id !== byId.id) + ); + const existing = byId ?? (!input.id ? nameClash : undefined); + const makeEntry = (id: string): SqlVariable => ({ + id, + name, + kind: input.kind, + value: input.kind === 'scalar' ? input.value : undefined, + values: input.kind === 'list' ? [...(input.values ?? [])] : undefined, + columns: input.kind === 'table' ? [...(input.columns ?? [])] : undefined, + rows: + input.kind === 'table' + ? (input.rows ?? []).map((r) => [...r]) + : undefined, + secret: input.secret !== undefined ? input.secret : existing?.secret, + overrides: + input.overrides !== undefined + ? input.overrides + : existing?.overrides + ? { ...existing.overrides } + : undefined, + updatedAt: Date.now(), + }); + if (nameClash && !byId) { + const entry = makeEntry(nameClash.id); + set({ + variables: variables.map((v) => (v.id === nameClash.id ? entry : v)), + }); + return null; + } + if (nameClash && byId) { + return `Variable "${name}" already exists`; + } + if (byId) { + const entry = makeEntry(byId.id); + set({ + variables: variables.map((v) => (v.id === byId.id ? entry : v)), + }); + return null; + } + set({ variables: [makeEntry(newTabId()), ...variables] }); + return null; + }, + + deleteVariable: (id) => { + set({ variables: get().variables.filter((v) => v.id !== id) }); + }, + + setVariableSecret: (id, secret) => { + set({ + variables: get().variables.map((v) => + v.id === id ? { ...v, secret, updatedAt: Date.now() } : v + ), + }); + }, + + setVariableOverride: (id, connectionId, override) => { + set({ + variables: get().variables.map((v) => { + if (v.id !== id || v.kind === 'table') return v; + const next = { ...(v.overrides ?? {}) }; + if (override === null) { + delete next[connectionId]; + } else { + next[connectionId] = override; + } + return { + ...v, + overrides: Object.keys(next).length > 0 ? next : undefined, + updatedAt: Date.now(), + }; + }), + }); + }, + + exportVariablesJson: () => + JSON.stringify(exportVariables(get().variables), null, 2), + + importVariables: (raw, opts) => { + const parsed = parseImportedVariables(raw); + if (!parsed.ok) return parsed.error; + const overwrite = opts?.overwrite !== false; + for (const item of parsed.items) { + const existing = get().variables.find((v) => v.name === item.name); + if (existing && !overwrite) continue; + if (item.secret) { + const err = get().upsertVariable({ + id: existing?.id, + name: item.name, + kind: item.kind, + secret: true, + value: item.kind === 'scalar' ? undefined : undefined, + values: item.kind === 'list' ? [] : undefined, + columns: item.kind === 'table' ? item.columns ?? existing?.columns ?? ['col'] : undefined, + rows: item.kind === 'table' ? [] : undefined, + }); + if (err) return err; + continue; + } + const err = get().upsertVariable({ + id: existing?.id, + name: item.name, + kind: item.kind, + secret: false, + value: item.value, + values: item.values, + columns: item.columns, + rows: item.rows, + overrides: item.overrides, + }); + if (err) return err; + } + return null; + }, }), { name: 'foxschema-sql-editor', - version: 3, - // Persist tabs + destinations mode + bookmarks. Never passwords/results. + version: 5, + // Persist tabs + destinations mode + bookmarks + variables. Never passwords/results. + // Secret variable payloads are stripped (session-only values). partialize: (state) => ({ tabs: persistableTabs(state.tabs), activeTabId: state.activeTabId, @@ -655,6 +983,7 @@ export const useSqlEditorStore = create()( shareDestinations: state.shareDestinations, sharedConnectionIds: state.sharedConnectionIds, bookmarks: state.bookmarks, + variables: stripSecretsForPersist(state.variables), }), migrate: (persisted, fromVersion) => { const p = (persisted ?? {}) as Record; @@ -677,6 +1006,7 @@ export const useSqlEditorStore = create()( ? (p.selectedConnectionIds as string[]) : [], bookmarks: [], + variables: [], }; } if (fromVersion < 3) { @@ -690,8 +1020,17 @@ export const useSqlEditorStore = create()( ? first.selectedConnectionIds : [], bookmarks: [], + variables: [], }; } + if (fromVersion < 4) { + return { ...p, variables: [] }; + } + // v5: secret/overrides fields are optional; strip any leaked secret payloads. + if (fromVersion < 5) { + const vars = Array.isArray(p.variables) ? (p.variables as SqlVariable[]) : []; + return { ...p, variables: stripSecretsForPersist(vars) }; + } return p; }, // Always rehydrate checkedStatements (not persisted) and drop malformed tabs. @@ -705,6 +1044,7 @@ export const useSqlEditorStore = create()( shareDestinations?: boolean; sharedConnectionIds?: string[]; bookmarks?: SqlBookmark[]; + variables?: SqlVariable[]; }; const tabs = hydrateTabs(Array.isArray(p.tabs) ? p.tabs : []); const activeTabId = @@ -734,6 +1074,19 @@ export const useSqlEditorStore = create()( return { ...b, title: tab.title, updatedAt: Date.now() }; }); + const variables = stripSecretsForPersist( + Array.isArray(p.variables) + ? p.variables.filter( + (v) => + v && + typeof v.id === 'string' && + typeof v.name === 'string' && + isValidVariableName(v.name) && + (v.kind === 'scalar' || v.kind === 'list' || v.kind === 'table') + ) + : [] + ); + return { ...currentState, tabs: healedTabs, @@ -746,6 +1099,7 @@ export const useSqlEditorStore = create()( ? p.sharedConnectionIds.filter((id) => typeof id === 'string') : [], bookmarks: healedBookmarks, + variables, }; }, } diff --git a/apps/web/src/style.css b/apps/web/src/style.css index 9662aa4..0016c23 100644 --- a/apps/web/src/style.css +++ b/apps/web/src/style.css @@ -342,3 +342,9 @@ mark.fox-search-hl { content: '⚠'; color: #fbbf24; /* amber-400 */ } + +/* SQL Editor `${{variable}}` refs ? hover shows the value once via decoration. */ +.fox-sql-var-ref { + color: #34d399; /* emerald-400 */ + font-weight: 600; +} diff --git a/docs/USER_GUIDE.md b/docs/USER_GUIDE.md index 83c14c6..2d4f065 100644 --- a/docs/USER_GUIDE.md +++ b/docs/USER_GUIDE.md @@ -120,7 +120,9 @@ compare / migrate). It lives in the same local web UI you open with `foxschema`. 3. Under **Destinations**, check one or more saved connections — the same SQL runs against every checked server (handy for comparing data across environments). 4. Type SQL in the editor. Multiple statements are fine; use the **statement strip** - under the editor to enable/disable individual statements before Run. + under the editor to enable/disable individual statements before Run. Or **select** + a statement (or any SQL) in the editor — Run becomes **Run selection** and only + that text is executed (variables still expand). 5. Click **Run**. Results appear below, grouped by connection (stack or side-by-side). Tips: @@ -131,6 +133,48 @@ Tips: cursor. Autocomplete uses the checked connections’ schemas when available. - **Format** — pretty-print the buffer. **Clear** removes results for the active tab. - **Bookmarks** — save reusable snippets from the sidebar. +- **Variables** — named values reused as `${{name}}` or `${{name.col}}` (table + column → list). Add them in the **Variables** sidebar; right-click a result + **cell** (scalar), **column header** (list), or **# / empty grid** (table); or + use leading comments so Run captures automatically — put `-- @set` + **immediately above** the SELECT it applies to (not below the previous query): + + ```sql + -- @set orderid + SELECT id FROM "ORDER" ORDER BY id DESC FETCH FIRST 1 ROW ONLY; + + -- @set ids = column id + SELECT id FROM ORDER_TIME WHERE orderId = ${{orderid}}; + + -- @set t = table + SELECT id, name FROM users; + + SELECT * FROM ORDER_ANSWER WHERE ORDERID IN (${{ids}}); + -- table column: ${{t.id}} whole table: ${{t}} → VALUES (…) + ``` + + Typing `${{` / `${{name.` autocompletes names and table columns. Hover a ref for + its value (or `N×M table`). Hover a statement in the strip (when it uses vars) to + preview **query with values** and **Copy**. Statements in one Run are sequential + so later SQL can use vars set by earlier `@set`. Multi-destination: `@set` uses + the first successful server’s result. Substitution is local (values are pasted + into the SQL text before send — not database bind parameters). Missing/empty + vars fail the run with a clear error; failed `@set` shows an amber warning above + the results. + + **Secrets** — mark a variable as secret to mask it in the sidebar, hover, and + statement preview. Secret **values are session-only** (not written to + localStorage); after reload, re-enter them or capture again with `@set` / the + grid. Note: substitution still embeds the value in the SQL sent to the server. + + **Per connection** — scalars and lists can override the global value per saved + destination (expand **Per connection…** in the sidebar). Multi-destination runs + substitute each server with its override. `@set` and grid capture still update + the **global** base value. + + **Export / import** — download or upload JSON from the Variables panel. Secret + entries export as stubs (name + flag only, no values). Click a table variable’s + size line to preview columns and rows. - **Safe mode** — when on, write/DDL statements need an extra confirmation before run. - **Max rows** — caps how many rows each statement returns (avoids huge result sets). From 605d35675a557e78f2c3e1193c8ea5e4a5a8b9d0 Mon Sep 17 00:00:00 2001 From: huyplb Date: Fri, 24 Jul 2026 00:18:59 -0600 Subject: [PATCH 2/3] fix(sql-editor): silence eslint security false positives for variable regexes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add detect-unsafe-regex exemptions for bounded ${{…}} / @set patterns, remove invalid react-hooks disable comments, and parse typed tokens without regex. Co-authored-by: Cursor --- .../components/sql-editor/DataGrid.tsx | 5 +- .../components/sql-editor/SqlEditorView.tsx | 2 +- .../sql-editor/SqlVariablesPanel.tsx | 57 ++++++++++--------- .../components/sql-editor/completion.ts | 2 + .../components/sql-editor/variableHover.ts | 5 +- apps/web/src/frontend/lib/sql-variables.ts | 2 + packages/core/src/modules/sql-splitter.ts | 1 + 7 files changed, 43 insertions(+), 31 deletions(-) diff --git a/apps/web/src/frontend/components/sql-editor/DataGrid.tsx b/apps/web/src/frontend/components/sql-editor/DataGrid.tsx index 67ff1dc..3467980 100644 --- a/apps/web/src/frontend/components/sql-editor/DataGrid.tsx +++ b/apps/web/src/frontend/components/sql-editor/DataGrid.tsx @@ -45,8 +45,11 @@ const KIND_LABEL: Record, string> = { string: 'text', }; +// Anchored digit/date forms — no nested quantifiers that can ReDoS. +// eslint-disable-next-line security/detect-unsafe-regex -- false positive: fully anchored, bounded optional groups const ISO_DATE_RE = /^\d{4}-\d{2}-\d{2}([ T]\d{2}:\d{2}(:\d{2}(\.\d{1,9})?)?(Z|[+-]\d{2}:?\d{2})?)?$/; +// eslint-disable-next-line security/detect-unsafe-regex -- false positive: simple digit classes, fully anchored const NUMERIC_STRING_RE = /^-?\d+(\.\d+)?([eE][+-]?\d+)?$/; const BINARY_RE = /^0x[0-9a-fA-F…]+$/; @@ -182,12 +185,10 @@ export const DataGrid: React.FC<{ const colKey = sourceColumns.join('\0'); const colWidths = useMemo( () => computeColWidths(sourceColumns, sourceRows), - // eslint-disable-next-line react-hooks/exhaustive-deps -- colKey + rowCount capture result identity cheaply [colKey, sourceRows.length, result.ok && result.ok ? result.rowCount : 0] ); const colKinds = useMemo( () => computeColKinds(sourceColumns, sourceRows), - // eslint-disable-next-line react-hooks/exhaustive-deps [colKey, sourceRows.length, result.ok && result.ok ? result.rowCount : 0] ); diff --git a/apps/web/src/frontend/components/sql-editor/SqlEditorView.tsx b/apps/web/src/frontend/components/sql-editor/SqlEditorView.tsx index d182f11..7665554 100644 --- a/apps/web/src/frontend/components/sql-editor/SqlEditorView.tsx +++ b/apps/web/src/frontend/components/sql-editor/SqlEditorView.tsx @@ -160,7 +160,7 @@ export const SqlEditorView: React.FC = () => { void ensureSchema(id); } } - }, [liveSelectedIds.join(','), ensureSchema]); // eslint-disable-line react-hooks/exhaustive-deps + }, [liveSelectedIds.join(','), ensureSchema]); const startEditorResize = useCallback((e: React.MouseEvent) => { e.preventDefault(); diff --git a/apps/web/src/frontend/components/sql-editor/SqlVariablesPanel.tsx b/apps/web/src/frontend/components/sql-editor/SqlVariablesPanel.tsx index 83f0972..dc0d77b 100644 --- a/apps/web/src/frontend/components/sql-editor/SqlVariablesPanel.tsx +++ b/apps/web/src/frontend/components/sql-editor/SqlVariablesPanel.tsx @@ -10,6 +10,29 @@ import { useSyncStore } from '../../store/useSyncStore'; const TABLE_PREVIEW_ROWS = 20; +/** Parse a typed scalar/list token without regex (avoids eslint unsafe-regex noise). */ +function parseTypedToken(s: string): unknown { + if (s === 'NULL') return null; + if (s === 'true') return true; + if (s === 'false') return false; + const asNum = Number(s); + if ( + s.length > 0 && + Number.isFinite(asNum) && + !s.includes(' ') && + (s[0] === '-' || (s[0]! >= '0' && s[0]! <= '9')) + ) { + return asNum; + } + if ( + (s.startsWith("'") && s.endsWith("'")) || + (s.startsWith('"') && s.endsWith('"')) + ) { + return s.slice(1, -1).replace(/''/g, "'"); + } + return s; +} + function previewVariable(v: SqlVariable): string { if (v.secret) { if (isSecretUnset(v)) return '(secret · unset)'; @@ -99,19 +122,7 @@ export const SqlVariablesPanel: React.FC = () => { .split(',') .map((s) => s.trim()) .filter((s) => s.length > 0) - .map((s) => { - if (s === 'NULL') return null; - if (s === 'true') return true; - if (s === 'false') return false; - if (/^-?\d+(\.\d+)?$/.test(s)) return Number(s); - if ( - (s.startsWith("'") && s.endsWith("'")) || - (s.startsWith('"') && s.endsWith('"')) - ) { - return s.slice(1, -1).replace(/''/g, "'"); - } - return s; - }); + .map(parseTypedToken); const err = upsertVariable({ id, name: v.name, @@ -125,16 +136,12 @@ export const SqlVariablesPanel: React.FC = () => { return; } } else { - let value: unknown = editValue; - if (editValue === 'NULL') value = null; - else if (editValue === 'true') value = true; - else if (editValue === 'false') value = false; - else if (/^-?\d+(\.\d+)?$/.test(editValue.trim())) value = Number(editValue.trim()); + const value = parseTypedToken(editValue.trim() === '' ? editValue : editValue.trim()); const err = upsertVariable({ id, name: v.name, kind: 'scalar', - value, + value: editValue.trim() === '' ? editValue : value, secret: v.secret, overrides: v.overrides, }); @@ -454,15 +461,13 @@ export const SqlVariablesPanel: React.FC = () => { const values = raw .split(',') .map((s) => s.trim()) - .filter(Boolean); + .filter(Boolean) + .map(parseTypedToken); setVariableOverride(v.id, c.id, { values }); } else { - let value: unknown = raw; - if (raw === 'NULL') value = null; - else if (raw === 'true') value = true; - else if (raw === 'false') value = false; - else if (/^-?\d+(\.\d+)?$/.test(raw)) value = Number(raw); - setVariableOverride(v.id, c.id, { value }); + setVariableOverride(v.id, c.id, { + value: parseTypedToken(raw), + }); } }} className="flex-1 min-w-0 bg-slate-950 border border-slate-700 rounded px-1 py-0.5 text-[10px] font-mono text-slate-200 outline-none focus:border-cyan-600/50" diff --git a/apps/web/src/frontend/components/sql-editor/completion.ts b/apps/web/src/frontend/components/sql-editor/completion.ts index bc7915a..c2b94ce 100644 --- a/apps/web/src/frontend/components/sql-editor/completion.ts +++ b/apps/web/src/frontend/components/sql-editor/completion.ts @@ -52,6 +52,7 @@ export function ensureSqlCompletions(monaco: typeof Monaco): void { const prefix = (word.word || '').toLowerCase(); // `${{name.` — suggest columns of a table variable. + // eslint-disable-next-line security/detect-unsafe-regex -- false positive: fixed `${{` prefix; bounded identifiers const varColMatch = /\$\{\{([A-Za-z_][A-Za-z0-9_]*)\.([A-Za-z_][A-Za-z0-9_]*)?$/.exec( linePrefix ); @@ -97,6 +98,7 @@ export function ensureSqlCompletions(monaco: typeof Monaco): void { } // `${{` or `${{partial` — suggest global SQL Editor variables. + // eslint-disable-next-line security/detect-unsafe-regex -- false positive: fixed `${{` prefix; bounded identifier const varMatch = /\$\{\{([A-Za-z_][A-Za-z0-9_]*)?$/.exec(linePrefix); if (varMatch) { const partial = (varMatch[1] ?? '').toLowerCase(); diff --git a/apps/web/src/frontend/components/sql-editor/variableHover.ts b/apps/web/src/frontend/components/sql-editor/variableHover.ts index 4330caa..e8fa98a 100644 --- a/apps/web/src/frontend/components/sql-editor/variableHover.ts +++ b/apps/web/src/frontend/components/sql-editor/variableHover.ts @@ -1,11 +1,12 @@ import type * as Monaco from 'monaco-editor/esm/vs/editor/editor.api'; import { expandVariableRef, type SqlVariable } from '../../lib/sql-variables'; -const VAR_AT = /\$\{\{([A-Za-z_][A-Za-z0-9_]*)(?:\.([A-Za-z_][A-Za-z0-9_]*))?\}\}/g; - /** Leftover language HoverProviders from older builds (HMR) — dispose once. */ const DISPOSE_KEY = '__foxschemaSqlVariableHoverDisposables'; +// eslint-disable-next-line security/detect-unsafe-regex -- false positive: fixed `${{` prefix; bounded identifier classes +const VAR_AT = /\$\{\{([A-Za-z_][A-Za-z0-9_]*)(?:\.([A-Za-z_][A-Za-z0-9_]*))?\}\}/g; + type Disposable = { dispose: () => void }; /** Clear any stacked language hover providers from prior registrations. */ diff --git a/apps/web/src/frontend/lib/sql-variables.ts b/apps/web/src/frontend/lib/sql-variables.ts index d034c93..bcb1402 100644 --- a/apps/web/src/frontend/lib/sql-variables.ts +++ b/apps/web/src/frontend/lib/sql-variables.ts @@ -8,9 +8,11 @@ const VAR_NAME_RE = /^[A-Za-z_][A-Za-z0-9_]*$/; * Matches `${{name}}` or `${{name.col}}` — not `$var` or `${x}`. * Group 1 = variable name, group 2 = optional column. */ +// eslint-disable-next-line security/detect-unsafe-regex -- false positive: fixed `${{` prefix; identifier classes are bounded const VAR_REF_RE = /\$\{\{([A-Za-z_][A-Za-z0-9_]*)(?:\.([A-Za-z_][A-Za-z0-9_]*))?\}\}/g; /** `-- @set name` / `-- @set name = column Col` / `-- @set name = table` */ +// eslint-disable-next-line security/detect-unsafe-regex -- false positive: anchored line; bounded identifier + simple alternatives const SET_LINE_RE = /^--\s*@set\s+([A-Za-z_][A-Za-z0-9_]*)\s*(?:=\s*(column\s+(\S+)|table))?\s*$/i; diff --git a/packages/core/src/modules/sql-splitter.ts b/packages/core/src/modules/sql-splitter.ts index be6de5d..25e06a5 100644 --- a/packages/core/src/modules/sql-splitter.ts +++ b/packages/core/src/modules/sql-splitter.ts @@ -49,6 +49,7 @@ const WRITE_KEYWORDS = new Set([ 'grant', 'revoke', 'replace', 'rename', ]); +// eslint-disable-next-line security/detect-unsafe-regex -- false positive: anchored at ^; optional bounded identifier const DOLLAR_TAG_RE = /^\$([A-Za-z_][A-Za-z0-9_]*)?\$/; /** Split a SQL buffer into `;`-terminated statements, skipping comment-only segments. */ From 6a60d387916d68070e1a7e12e6cc100997650b62 Mon Sep 17 00:00:00 2001 From: huyplb Date: Fri, 24 Jul 2026 00:21:02 -0600 Subject: [PATCH 3/3] fix(sql-editor): place eslint-disable on the regex literal lines Co-authored-by: Cursor --- apps/web/src/frontend/components/sql-editor/DataGrid.tsx | 2 +- apps/web/src/frontend/lib/sql-variables.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/web/src/frontend/components/sql-editor/DataGrid.tsx b/apps/web/src/frontend/components/sql-editor/DataGrid.tsx index 3467980..0ead1ea 100644 --- a/apps/web/src/frontend/components/sql-editor/DataGrid.tsx +++ b/apps/web/src/frontend/components/sql-editor/DataGrid.tsx @@ -46,8 +46,8 @@ const KIND_LABEL: Record, string> = { }; // Anchored digit/date forms — no nested quantifiers that can ReDoS. -// eslint-disable-next-line security/detect-unsafe-regex -- false positive: fully anchored, bounded optional groups const ISO_DATE_RE = + // eslint-disable-next-line security/detect-unsafe-regex -- false positive: fully anchored, bounded optional groups /^\d{4}-\d{2}-\d{2}([ T]\d{2}:\d{2}(:\d{2}(\.\d{1,9})?)?(Z|[+-]\d{2}:?\d{2})?)?$/; // eslint-disable-next-line security/detect-unsafe-regex -- false positive: simple digit classes, fully anchored const NUMERIC_STRING_RE = /^-?\d+(\.\d+)?([eE][+-]?\d+)?$/; diff --git a/apps/web/src/frontend/lib/sql-variables.ts b/apps/web/src/frontend/lib/sql-variables.ts index bcb1402..8258d7b 100644 --- a/apps/web/src/frontend/lib/sql-variables.ts +++ b/apps/web/src/frontend/lib/sql-variables.ts @@ -12,8 +12,8 @@ const VAR_NAME_RE = /^[A-Za-z_][A-Za-z0-9_]*$/; const VAR_REF_RE = /\$\{\{([A-Za-z_][A-Za-z0-9_]*)(?:\.([A-Za-z_][A-Za-z0-9_]*))?\}\}/g; /** `-- @set name` / `-- @set name = column Col` / `-- @set name = table` */ -// eslint-disable-next-line security/detect-unsafe-regex -- false positive: anchored line; bounded identifier + simple alternatives const SET_LINE_RE = + // eslint-disable-next-line security/detect-unsafe-regex -- false positive: anchored line; bounded identifier + simple alternatives /^--\s*@set\s+([A-Za-z_][A-Za-z0-9_]*)\s*(?:=\s*(column\s+(\S+)|table))?\s*$/i; export type SqlVariableKind = 'scalar' | 'list' | 'table';