From adaaec5a3ca348448aaae15d0046a2ac530b03c2 Mon Sep 17 00:00:00 2001 From: t8y2 Date: Mon, 28 Sep 2026 07:24:21 +0800 Subject: [PATCH] fix(editor): move large SQL analysis off the input path --- .../src/components/editor/QueryEditor.vue | 134 ++++++++++-------- ...ryEditor.largeDocument.integration.spec.ts | 88 +++++++++++- .../useQueryEditorCompletion.spec.ts | 38 ++++- .../useQueryEditorStatementBoundaries.spec.ts | 46 +++++- .../editor/queryEditorSqlExtensions.ts | 25 ++-- .../editor/useQueryEditorCompletion.ts | 120 +++++++--------- .../useQueryEditorCompletionMetadata.ts | 66 +++++++++ .../editor/useQueryEditorDiagnostics.ts | 64 +++++---- .../useQueryEditorStatementBoundaries.ts | 100 +++++++++++-- .../editor/queryEditorLargeDocument.spec.ts | 18 ++- .../__tests__/sql/sqlAnalysisWorker.spec.ts | 124 ++++++++++++++++ .../__tests__/sql/sqlEditorAnalysis.spec.ts | 67 +++++++++ .../codemirrorSqlBlockFolding.spec.ts | 18 ++- .../lib/editor/codemirrorSqlBlockFolding.ts | 14 +- .../lib/editor/queryEditorLargeDocument.ts | 13 +- .../lib/sql/executableStatementRangeCache.ts | 40 ++++-- apps/desktop/src/lib/sql/sqlAnalysisWorker.ts | 98 +++++++++++++ apps/desktop/src/lib/sql/sqlCompletion.ts | 4 +- .../src/lib/sql/sqlCompletionAnalysis.ts | 96 +++++++++++++ .../lib/sql/sqlCompletionAnalysis.worker.ts | 16 +++ .../lib/sql/sqlCompletionAnalysisWorker.ts | 11 ++ .../src/lib/sql/sqlDiagnosticAnalysis.ts | 48 +++++++ .../lib/sql/sqlDiagnosticAnalysis.worker.ts | 9 ++ .../lib/sql/sqlDiagnosticAnalysisWorker.ts | 6 + .../src/lib/sql/sqlStatementAnalysis.ts | 30 ++++ .../lib/sql/sqlStatementAnalysis.worker.ts | 9 ++ .../src/lib/sql/sqlStatementAnalysisWorker.ts | 6 + scripts/bench/README.md | 12 ++ scripts/bench/editor-typing.mjs | 26 ++++ scripts/bench/editor-typing.ts | 106 ++++++++++++++ 30 files changed, 1241 insertions(+), 211 deletions(-) create mode 100644 apps/desktop/src/lib/__tests__/sql/sqlAnalysisWorker.spec.ts create mode 100644 apps/desktop/src/lib/__tests__/sql/sqlEditorAnalysis.spec.ts create mode 100644 apps/desktop/src/lib/sql/sqlAnalysisWorker.ts create mode 100644 apps/desktop/src/lib/sql/sqlCompletionAnalysis.ts create mode 100644 apps/desktop/src/lib/sql/sqlCompletionAnalysis.worker.ts create mode 100644 apps/desktop/src/lib/sql/sqlCompletionAnalysisWorker.ts create mode 100644 apps/desktop/src/lib/sql/sqlDiagnosticAnalysis.ts create mode 100644 apps/desktop/src/lib/sql/sqlDiagnosticAnalysis.worker.ts create mode 100644 apps/desktop/src/lib/sql/sqlDiagnosticAnalysisWorker.ts create mode 100644 apps/desktop/src/lib/sql/sqlStatementAnalysis.ts create mode 100644 apps/desktop/src/lib/sql/sqlStatementAnalysis.worker.ts create mode 100644 apps/desktop/src/lib/sql/sqlStatementAnalysisWorker.ts create mode 100644 scripts/bench/editor-typing.mjs create mode 100644 scripts/bench/editor-typing.ts diff --git a/apps/desktop/src/components/editor/QueryEditor.vue b/apps/desktop/src/components/editor/QueryEditor.vue index f8444c7305..e46effcd2b 100644 --- a/apps/desktop/src/components/editor/QueryEditor.vue +++ b/apps/desktop/src/components/editor/QueryEditor.vue @@ -110,10 +110,10 @@ import { editorClipboardLineEndingsExtension } from "@/lib/editor/editorClipboar import { selectionMatchOccurrences } from "@/lib/editor/codemirrorSelectionMatches"; import { createInsertValueHintsExtension, requestInsertValueHintsRefresh, supportsInsertValueHints } from "@/lib/editor/codemirrorInsertValueHints"; -import { sqlBlockFoldService } from "@/lib/editor/codemirrorSqlBlockFolding"; +import { createSqlBlockFoldService } from "@/lib/editor/codemirrorSqlBlockFolding"; import { focusEditorView } from "@/lib/editor/queryEditorFocus"; import { createSqlUnknownObjectHighlights, refreshSqlUnknownObjectHighlights } from "@/lib/editor/codemirrorSqlUnknownObjectHighlights"; -import { shouldUseQueryEditorLargeDocumentMode } from "@/lib/editor/queryEditorLargeDocument"; +import { shouldUseQueryEditorLargeDocumentMode, shouldUseQueryEditorLargeDocumentModeForSize } from "@/lib/editor/queryEditorLargeDocument"; import { startsQueryEditorRectangularSelection } from "@/lib/editor/queryEditorPointerSelection"; import { LARGE_PASTE_HISTORY_USER_EVENT, normalizeQueryEditorPasteText, recoverableNativePasteSuffix, shouldRecoverLargeTauriPaste } from "@/lib/editor/queryEditorLargePaste"; @@ -470,8 +470,8 @@ interface EditorGestureEvent extends Event { } const codeMirrorRuntime = createQueryEditorCodeMirrorRuntime(); -const largeDocumentMode = shouldUseQueryEditorLargeDocumentMode(props.modelValue); -const fullEditorFeaturesEnabled = () => !largeDocumentMode; +const largeDocumentMode = ref(shouldUseQueryEditorLargeDocumentMode(props.modelValue)); +const boundedEditorAnalysisEnabled = () => largeDocumentMode.value; let previewContextRefreshTimer: ReturnType | null = null; let editorIsActive = true; @@ -482,7 +482,6 @@ const postCompositionKeyGuard = createQueryEditorPostCompositionKeyGuard(); let postCompositionKeyGuardCleanup: (() => void) | null = null; function runStatementGutterExtension(): import("@codemirror/state").Extension { - if (!fullEditorFeaturesEnabled()) return []; const showRunButtons = !props.hideExecutionControls && settingsStore.editorSettings.showStatementRunButtons; return shouldShowStatementGutter(showRunButtons) ? (codeMirrorRuntime.buildRunStatementGutterExtension?.() ?? []) : []; } @@ -494,6 +493,7 @@ const statementBoundaries = useQueryEditorStatementBoundaries({ view, sqlStatementParameterOptions, runtime: codeMirrorRuntime, + driverProfile: () => sqlDriverProfile.value, cache: { get value() { return executableStatementRangeCache; @@ -802,17 +802,21 @@ function schedulePreviewContextRefresh(currentView: EditorViewType) { clearScheduledPreviewContextRefresh(); const expectedDoc = currentView.state.doc; const expectedSelection = currentView.state.selection.main; - previewContextRefreshTimer = setTimeout(() => { + previewContextRefreshTimer = setTimeout(async () => { previewContextRefreshTimer = null; const currentSelection = currentView.state.selection.main; if (view.value !== currentView || currentView.state.doc !== expectedDoc || currentSelection.from !== expectedSelection.from || currentSelection.to !== expectedSelection.to || !editorIsActive) return; + if (boundedEditorAnalysisEnabled()) { + const result = await statementBoundaries.ensureStatementCache(currentView.state); + if (!result || !editorIsActive || view.value !== currentView || currentView.state.doc !== expectedDoc || currentView.state.selection.main !== currentSelection) return; + } previewContextSql.value = resolvePreviewDmlCandidate(); emit("previewChangesAvailable", !!previewContextSql.value); }, 120); } function selectStarExpansionTargetForView(currentView: EditorViewType, position?: number): SelectStarExpansionTarget | null { - if (!fullEditorFeaturesEnabled() || !props.connectionId || props.database == null || props.readOnly || !SEMANTIC_SQL_COMPLETION_ENABLED) return null; + if (!props.connectionId || props.database == null || props.readOnly || !SEMANTIC_SQL_COMPLETION_ENABLED) return null; const sql = currentEditorDocText(currentView); const selection = currentView.state.selection.main; @@ -899,11 +903,6 @@ function syncContextMenuStateAtEvent(currentView: EditorViewType, event: MouseEv return; } - if (!fullEditorFeaturesEnabled()) { - contextObjectTarget.value = null; - return; - } - const sql = currentEditorDocText(currentView); if (!props.connectionId || props.database == null) { const candidate = queryTableCandidateAtSqlPosition({ @@ -969,7 +968,6 @@ function exportQueryFromContextMenu(format: "csv" | "xlsx" | "txt") { // 与「执行」使用同一套候选解析:选区优先,否则取 position(右键点击处)/ 光标处的单条语句。 // 注意:不跟随 executeAllOnBlankLine 回退到“整篇文档”(那会包含多条语句)。 function resolvePreviewDmlCandidate(position?: number): string { - if (!fullEditorFeaturesEnabled()) return ""; const currentView = view.value; if (!currentView) return ""; const selection = currentView.state.selection.main; @@ -978,8 +976,7 @@ function resolvePreviewDmlCandidate(position?: number): string { return looksLikeDmlStatement(text) ? text : ""; } const cursorPos = position ?? selection.head; - executableStatementRangeCache = executableStatementRangeCacheForDoc(executableStatementRangeCache, currentView.state.doc, props.databaseType, sqlStatementParameterOptions()); - const cursorRange = executableStatementRangeAtCursor(executableStatementRangeCache, cursorPos); + const cursorRange = executableStatementRangeAtPosition(currentView, cursorPos); return cursorRange && looksLikeDmlStatement(cursorRange.sql) ? cursorRange.sql : ""; } @@ -1112,10 +1109,15 @@ function executableStatementRangeStartingAt(currentView: EditorViewType, lineFro return executableStatementRangeStartingAtLine(executableStatementRangeCache, lineFrom); } +function executableStatementRangeAtPosition(currentView: EditorViewType, position: number): SqlTextRange | null { + executableStatementRangeCache = executableStatementRangeCacheForDoc(executableStatementRangeCache, currentView.state.doc, props.databaseType, sqlStatementParameterOptions()); + return executableStatementRangeAtCursor(executableStatementRangeCache, position); +} + function currentExecutableStatementRange(currentView: EditorViewType): SqlTextRange | null { if (!supportsExecutionTargetPicker(props.databaseType) && props.databaseType !== "mongodb") return null; - executableStatementRangeCache = executableStatementRangeCacheForDoc(executableStatementRangeCache, currentView.state.doc, props.databaseType, sqlStatementParameterOptions()); - return executableStatementRangeAtCursor(executableStatementRangeCache, currentView.state.selection.main.head); + if (boundedEditorAnalysisEnabled() && executableStatementRangeCache?.doc !== currentView.state.doc) return null; + return executableStatementRangeAtPosition(currentView, currentView.state.selection.main.head); } function executeSqlStatementFromGutter(currentView: EditorViewType, line: { from: number; to: number }, event: Event): boolean { @@ -1552,7 +1554,7 @@ const { sqlErrorDecorationRange, sqlSemanticDecorationRanges, reconfigureDiagnos semanticCompletionEnabled: SEMANTIC_SQL_COMPLETION_ENABLED, maxCompletionTables: MAX_COMPLETION_TABLES, unknownObjectHighlightEnabled: SQL_UNKNOWN_OBJECT_HIGHLIGHT_ENABLED, - fullFeaturesEnabled: fullEditorFeaturesEnabled, + boundedAnalysisEnabled: boundedEditorAnalysisEnabled, runtime: { get setSqlDiagnosticsEffect() { return codeMirrorRuntime.setSqlDiagnosticsEffect; @@ -1753,7 +1755,7 @@ function isEditorComposing(currentView: EditorViewType): boolean { return imeCompositionActive || currentView.compositionStarted || currentView.composing; } -function flushImeComposition() { +async function flushImeComposition() { const currentView = view.value; if (!currentView || !pendingImeModelEmit) return; pendingImeModelEmit = false; @@ -1767,7 +1769,8 @@ function flushImeComposition() { documentState.recordSelection(currentView); const fullDoc = currentView.state.doc.toString(); const position = currentView.state.selection.main.head; - if (shouldTriggerSqlCompletionForPosition(fullDoc, position)) { + if (await shouldTriggerSqlCompletionForPosition(fullDoc, position)) { + if (view.value !== currentView || currentView.state.selection.main.head !== position || isEditorComposing(currentView)) return; scheduleSqlCompletionStart(currentView); } } @@ -1782,7 +1785,7 @@ const codeMirrorLifecycle = useQueryEditorCodeMirror({ view, runtime: codeMirrorRuntime, beforeLoad() { - if (fullEditorFeaturesEnabled()) hoverContent.initializeHighlighter(); + hoverContent.initializeHighlighter(); }, async prepare(modules) { const initializedRuntime = modules.runtime; @@ -1839,7 +1842,7 @@ const codeMirrorLifecycle = useQueryEditorCodeMirror({ currentExecutableStatementRange, executeSqlStatementFromGutter, signatureHelpWindowChars: SQL_SIGNATURE_HELP_WINDOW_CHARS, - fullFeaturesEnabled: fullEditorFeaturesEnabled, + boundedAnalysisEnabled: boundedEditorAnalysisEnabled, }); const initialSettings = settingsStore.editorSettings; const theme = await loadEditorTheme(initialSettings.theme, editorThemeAppearance(), getCurrentCustomThemeColors(), themePalette.value); @@ -1847,6 +1850,9 @@ const codeMirrorLifecycle = useQueryEditorCodeMirror({ await ensureCodeMirrorVim(); } const { currentStatementFrameExtension, activeLineHighlighter } = sqlExtensions.createViewDecorations(); + function updateLargeDocumentMode(currentView: EditorViewType) { + largeDocumentMode.value = shouldUseQueryEditorLargeDocumentModeForSize(currentView.state.doc.length, currentView.state.doc.lines); + } const editorElement = editorRef.value; if (!editorElement) return; const tooltipParent = editorElement.closest("#root")?.querySelector("#dbx-query-editor-tooltip-root") ?? editorElement; @@ -1873,23 +1879,22 @@ const codeMirrorLifecycle = useQueryEditorCodeMirror({ initializedRuntime.runGutterComp.of(runStatementGutterExtension()), initializedRuntime.lineNumbersComp.of(lineNumbersExtension(initialSettings.showLineNumbers)), createQueryEditorLineNumberAlignmentExtension(ViewPlugin), - fullEditorFeaturesEnabled() ? currentStatementFrameExtension : [], + currentStatementFrameExtension, highlightActiveLineGutter(), highlightSpecialChars(), initializedRuntime.historyResetComp.of(history()), - fullEditorFeaturesEnabled() - ? foldGutter({ - markerDOM(open: boolean) { - const span = document.createElement("span"); - span.className = "cm-foldMarker-svg"; - span.innerHTML = open - ? '' - : ''; - return span; - }, - }) - : [], - fullEditorFeaturesEnabled() ? sqlBlockFoldService : [], + foldGutter({ + foldingChanged: (update) => update.transactions.some((transaction) => transaction.effects.some((effect) => !!codeMirrorRuntime.statementBoundariesRefreshEffect && effect.is(codeMirrorRuntime.statementBoundariesRefreshEffect))), + markerDOM(open: boolean) { + const span = document.createElement("span"); + span.className = "cm-foldMarker-svg"; + span.innerHTML = open + ? '' + : ''; + return span; + }, + }), + createSqlBlockFoldService(() => props.databaseType, statementBoundaries.foldRangeForState), drawSelection(), editorClipboardLineEndingsExtension(EditorView), trimmedSelectionLayer(), @@ -1908,8 +1913,8 @@ const codeMirrorLifecycle = useQueryEditorCodeMirror({ ), ), EditorState.allowMultipleSelections.of(true), - fullEditorFeaturesEnabled() ? indentOnInput() : [], - fullEditorFeaturesEnabled() ? syntaxHighlighting(defaultHighlightStyle, { fallback: true }) : [], + indentOnInput(), + syntaxHighlighting(defaultHighlightStyle, { fallback: true }), crosshairCursor(), activeLineHighlighter, // Vim must be mounted before DBX/default keymaps so normal-mode keys are handled first. @@ -1919,14 +1924,12 @@ const codeMirrorLifecycle = useQueryEditorCodeMirror({ Prec.highest(keymap.of([{ key: "Space", run: acceptSqlServerCompletionOnSpace }])), initializedRuntime.sqlLanguageComp.of(sqlExtensions.buildSqlLanguageExtension()), initializedRuntime.sqlSemanticHighlightComp.of(sqlExtensions.buildSqlSemanticHighlightExtension()), - fullEditorFeaturesEnabled() - ? createSqlUnknownObjectHighlights({ - enabled: SQL_UNKNOWN_OBJECT_HIGHLIGHT_ENABLED, - load: loadSqlUnknownObjectSpans, - initialDelayMs: SQL_UNKNOWN_OBJECT_INITIAL_DELAY_MS, - debounceMs: SQL_UNKNOWN_OBJECT_DEBOUNCE_MS, - }) - : [], + createSqlUnknownObjectHighlights({ + enabled: SQL_UNKNOWN_OBJECT_HIGHLIGHT_ENABLED, + load: loadSqlUnknownObjectSpans, + initialDelayMs: SQL_UNKNOWN_OBJECT_INITIAL_DELAY_MS, + debounceMs: SQL_UNKNOWN_OBJECT_DEBOUNCE_MS, + }), tooltips({ parent: tooltipParent }), initializedRuntime.completionComp.of(sqlExtensions.buildSqlCompletionExtension()), sqlCompletionTheme(EditorView), @@ -1953,17 +1956,15 @@ const codeMirrorLifecycle = useQueryEditorCodeMirror({ return true; }), ), - fullEditorFeaturesEnabled() ? hoverTooltip((currentView, pos) => resolveSqlHoverTooltip(currentView, pos)) : [], + hoverTooltip((currentView, pos) => resolveSqlHoverTooltip(currentView, pos)), initializedRuntime.sqlSignatureComp.of(sqlExtensions.buildSqlSignatureExtension()), initializedRuntime.diagnosticComp.of(sqlExtensions.buildSqlDiagnosticExtension()), - fullEditorFeaturesEnabled() - ? createInsertValueHintsExtension({ - isEnabled: () => settingsStore.editorSettings.showInsertValueHints && supportsInsertValueHints(props.databaseType), - getTableColumns: getInsertValueHintTableColumns, - requestTableColumns: requestInsertValueHintTableColumns, - getDialectId: () => resolveSqlDialectId({ databaseType: props.databaseType, dialect: sqlBehaviorDialect() }), - }) - : [], + createInsertValueHintsExtension({ + isEnabled: () => settingsStore.editorSettings.showInsertValueHints && supportsInsertValueHints(props.databaseType), + getTableColumns: getInsertValueHintTableColumns, + requestTableColumns: requestInsertValueHintTableColumns, + getDialectId: () => resolveSqlDialectId({ databaseType: props.databaseType, dialect: sqlBehaviorDialect() }), + }), initializedRuntime.previewRangeComp.of(sqlExtensions.buildPreviewRangeExtension()), sqlExtensions.buildResultSourceRangeExtension(), Prec.highest( @@ -2007,10 +2008,11 @@ const codeMirrorLifecycle = useQueryEditorCodeMirror({ rememberFocusedQueryEditorView(update.view); } if (update.docChanged) { + completion.invalidateRequests(); + updateLargeDocumentMode(update.view); searchPanelRef.value?.scheduleDocumentSearchUpdate(); if (isEditorComposing(update.view)) { pendingImeModelEmit = true; - completion.invalidateRequests(); } else { emitModelValue(update.view); invalidateSemanticDiagnosticsForDocumentChange(); @@ -2138,7 +2140,12 @@ const codeMirrorLifecycle = useQueryEditorCodeMirror({ restoreEditorSelection(props.initialSelection, !props.initialViewport); restoreEditorViewport(); performEditorReveal(); - syncContextMenuState(view.value); + if (boundedEditorAnalysisEnabled()) { + syncEditorSelectionState(view.value); + schedulePreviewContextRefresh(view.value); + } else { + syncContextMenuState(view.value); + } emit("previewChangesAvailable", !!previewContextSql.value); syncEditorFontCssVars(liveFontSize.value, initialSettings.fontFamily); syncEditorDiagnosticCssVars(); @@ -2173,7 +2180,7 @@ onMounted(codeMirrorLifecycle.initialize); // that would no longer be allowed under the new mode. watch( () => settingsStore.editorSettings.completionTriggerMode, - (newMode) => { + async (newMode) => { if (!view.value || !codeMirrorRuntime.codeMirrorCompletionStatus || !codeMirrorRuntime.codeMirrorCloseCompletion) return; const status = codeMirrorRuntime.codeMirrorCompletionStatus(view.value.state); if (!status || completion.activeOrigin !== "typing") return; @@ -2184,10 +2191,11 @@ watch( } // For other mode changes, re-evaluate the policy. // If the current position would not trigger under the new mode, close. - const fullDoc = view.value.state.doc.toString(); - const position = view.value.state.selection.main.head; - if (!shouldTriggerSqlCompletionForPosition(fullDoc, position)) { - codeMirrorRuntime.codeMirrorCloseCompletion(view.value); + const currentView = view.value; + const doc = currentView.state.doc; + const position = currentView.state.selection.main.head; + if (!(await shouldTriggerSqlCompletionForPosition(doc.toString(), position))) { + if (view.value === currentView && currentView.state.doc === doc && currentView.state.selection.main.head === position && settingsStore.editorSettings.completionTriggerMode === newMode) codeMirrorRuntime.codeMirrorCloseCompletion(currentView); } }, ); @@ -2546,6 +2554,8 @@ function pauseQueryEditorBackgroundWork() { clearScheduledSemanticDiagnostics(); completion.invalidateRequests(); unregisterTableReferenceDropListener(); + completionMetadata.disposeCompletionAnalysis(); + statementBoundaries.clear(); } function resumeQueryEditorBackgroundWork() { diff --git a/apps/desktop/src/components/editor/__tests__/QueryEditor.largeDocument.integration.spec.ts b/apps/desktop/src/components/editor/__tests__/QueryEditor.largeDocument.integration.spec.ts index 22f6bc30b8..6ef420dd63 100644 --- a/apps/desktop/src/components/editor/__tests__/QueryEditor.largeDocument.integration.spec.ts +++ b/apps/desktop/src/components/editor/__tests__/QueryEditor.largeDocument.integration.spec.ts @@ -6,12 +6,15 @@ import { createI18n } from "vue-i18n"; import { language } from "@codemirror/language"; import { EditorView } from "@codemirror/view"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { QUERY_EDITOR_FULL_FEATURE_MAX_DOCUMENT_LENGTH } from "@/lib/editor/queryEditorLargeDocument"; +import { QUERY_EDITOR_FULL_FEATURE_MAX_DOCUMENT_LENGTH, QUERY_EDITOR_FULL_FEATURE_MAX_LINE_COUNT } from "@/lib/editor/queryEditorLargeDocument"; import type { QueryEditorProps } from "../queryEditorTypes"; const analysisProbe = vi.hoisted(() => ({ documentLengths: [] as number[], + documentLineCounts: [] as number[], + backgroundDocuments: [] as string[], failAtLength: Number.POSITIVE_INFINITY, + failAtLineCount: Number.POSITIVE_INFINITY, })); vi.mock("@/lib/sql/executableStatementRangeCache", async (importOriginal) => { @@ -19,13 +22,41 @@ vi.mock("@/lib/sql/executableStatementRangeCache", async (importOriginal) => { return { ...actual, executableStatementRangeCacheForDoc: (...args: Parameters) => { + if (args[0]?.doc === args[1]) return actual.executableStatementRangeCacheForDoc(...args); analysisProbe.documentLengths.push(args[1].length); - if (args[1].length >= analysisProbe.failAtLength) throw new Error("unbounded statement analysis"); + analysisProbe.documentLineCounts.push(args[1].lines); + if (args[1].length >= analysisProbe.failAtLength || args[1].lines >= analysisProbe.failAtLineCount) throw new Error("unbounded statement analysis"); return actual.executableStatementRangeCacheForDoc(...args); }, }; }); +vi.mock("@/lib/sql/sqlStatementAnalysisWorker", async () => { + const actual = await vi.importActual("@/lib/sql/executableStatementRangeCache"); + const { Text } = await import("@codemirror/state"); + return { + createSqlStatementAnalysisWorker: () => { + let generation = 0; + return { + async analyze(request: import("@/lib/sql/sqlStatementAnalysis").SqlStatementAnalysisRequest) { + const current = ++generation; + analysisProbe.backgroundDocuments.push(request.sql); + await Promise.resolve(); + if (current !== generation) return null; + const { doc: _doc, ...result } = actual.executableStatementRangeCacheForDoc(null, Text.of(request.sql.split("\n")), request.databaseType, request.parameterOptions); + return result; + }, + cancel() { + generation++; + }, + dispose() { + generation++; + }, + }; + }, + }; +}); + import QueryEditor from "../QueryEditor.vue"; function oraclePackage(procedureCount: number): string { @@ -50,7 +81,10 @@ const cleanups: Array<() => void> = []; beforeEach(() => { analysisProbe.documentLengths = []; + analysisProbe.documentLineCounts = []; + analysisProbe.backgroundDocuments = []; analysisProbe.failAtLength = Number.POSITIVE_INFINITY; + analysisProbe.failAtLineCount = Number.POSITIVE_INFINITY; }); afterEach(() => { @@ -103,6 +137,40 @@ describe("QueryEditor large document mode", () => { expect(view.state.doc.toString()).toBe(source); }); + it("keeps SQL language features while bounding statement analysis above the line budget", async () => { + const lineCount = QUERY_EDITOR_FULL_FEATURE_MAX_LINE_COUNT + 1; + const source = Array.from({ length: lineCount }, (_, index) => `SELECT ${index};`).join("\n"); + expect(source.length).toBeLessThan(QUERY_EDITOR_FULL_FEATURE_MAX_DOCUMENT_LENGTH); + analysisProbe.failAtLineCount = lineCount; + + const { host, view } = await mountEditor(source); + + expect(host.firstElementChild?.getAttribute("data-large-document-mode")).toBe("true"); + expect(analysisProbe.documentLineCounts).not.toContain(lineCount); + expect(view.state.facet(language)).not.toBeNull(); + expect(analysisProbe.backgroundDocuments).toContain(source); + }); + + it("enters bounded mode when typing crosses the line budget", async () => { + const source = Array.from({ length: QUERY_EDITOR_FULL_FEATURE_MAX_LINE_COUNT }, (_, index) => `SELECT ${index};`).join("\n"); + const { host, view } = await mountEditor(source); + analysisProbe.documentLengths = []; + analysisProbe.documentLineCounts = []; + + view.dispatch({ + changes: { from: view.state.doc.length, insert: "\nS" }, + selection: { anchor: view.state.doc.length + 2 }, + userEvent: "input.type", + }); + await nextTick(); + await new Promise((resolve) => setTimeout(resolve, 250)); + + expect(host.firstElementChild?.getAttribute("data-large-document-mode")).toBe("true"); + expect(view.state.doc.lines).toBe(QUERY_EDITOR_FULL_FEATURE_MAX_LINE_COUNT + 1); + expect(view.state.facet(language)).not.toBeNull(); + expect(analysisProbe.documentLineCounts).not.toContain(QUERY_EDITOR_FULL_FEATURE_MAX_LINE_COUNT + 1); + }); + it("keeps a realistically large package editable and searchable without full-document analysis", async () => { const source = oraclePackage(4_000); expect(source.length).toBeGreaterThan(QUERY_EDITOR_FULL_FEATURE_MAX_DOCUMENT_LENGTH); @@ -112,7 +180,7 @@ describe("QueryEditor large document mode", () => { expect(host.firstElementChild?.getAttribute("data-large-document-mode")).toBe("true"); expect(analysisProbe.documentLengths).not.toContain(source.length); - expect(view.state.facet(language)).toBeNull(); + expect(view.state.facet(language)).not.toBeNull(); expect(view.state.doc.toString()).toBe(source); expect(view.state.readOnly).toBe(false); expect(view.state.sliceDoc(0, 24)).toBe(source.slice(0, 24)); @@ -137,4 +205,18 @@ describe("QueryEditor large document mode", () => { expect(view.state.readOnly).toBe(true); expect(view.contentDOM.getAttribute("contenteditable")).toBe("false"); }); + + it("returns to the ordinary path after shrinking and preserves undo", async () => { + const source = Array.from({ length: 2_001 }, (_, index) => `SELECT ${index};`).join("\n"); + const { host, view } = await mountEditor(source); + view.dispatch({ changes: { from: 0, to: view.state.doc.length, insert: "SELECT 1;" }, userEvent: "input" }); + await nextTick(); + expect(host.firstElementChild?.hasAttribute("data-large-document-mode")).toBe(false); + expect(view.state.facet(language)).not.toBeNull(); + const { undo } = await import("@codemirror/commands"); + expect(undo(view)).toBe(true); + await nextTick(); + expect(view.state.doc.toString()).toBe(source); + expect(host.firstElementChild?.getAttribute("data-large-document-mode")).toBe("true"); + }); }); diff --git a/apps/desktop/src/components/editor/__tests__/useQueryEditorCompletion.spec.ts b/apps/desktop/src/components/editor/__tests__/useQueryEditorCompletion.spec.ts index fde3032a77..59f128c0c7 100644 --- a/apps/desktop/src/components/editor/__tests__/useQueryEditorCompletion.spec.ts +++ b/apps/desktop/src/components/editor/__tests__/useQueryEditorCompletion.spec.ts @@ -10,6 +10,7 @@ import { useQueryEditorCompletionMetadata } from "../useQueryEditorCompletionMet import type { QueryEditorProps } from "../queryEditorTypes"; import type { RedisCommandDocumentation } from "@/lib/redis/redisCommandDocs"; import type { SqlCompletionTable } from "@/lib/sql/sqlCompletion"; +import { analyzeSqlCompletion, type SqlCompletionAnalysisResult } from "@/lib/sql/sqlCompletionAnalysis"; vi.mock("@/stores/connectionStore", () => ({ COMPLETION_METADATA_CONCURRENCY: 4 })); vi.mock("@/lib/backend/api", () => ({})); @@ -31,7 +32,7 @@ afterEach(() => { vi.restoreAllMocks(); }); -function createHarness(overrides: Partial = {}) { +function createHarness(overrides: Partial = {}, configureMetadata?: (metadata: ReturnType) => void) { const props = reactive({ modelValue: "SEL", databaseType: "mysql", dialect: "mysql", connectionId: "connection", database: "demo", ...overrides }); const parent = document.createElement("div"); document.body.append(parent); @@ -65,6 +66,7 @@ function createHarness(overrides: Partial = {}) { const connectionStore = store as unknown as Options["connectionStore"]; const settings = reactive({ editorSettings: { completionTriggerMode: "positional", snippets: [], sqlFormatter: { keywordCase: "upper", functionCase: "upper" }, autoAliasTables: false, tableCompletionSchemaQualification: "collision", generateSqlQuoteIdentifiers: false } }); const metadata = useQueryEditorCompletionMetadata({ props, view, connectionStore, sqlBehaviorDialect: () => props.dialect, remoteLatencyBudgetMs: 40, maxCompletionTables: 100, onDemandMinPrefix: 2, semanticCompletionEnabled: false }); + configureMetadata?.(metadata); const startCompletion = vi.fn(() => true); const runtime: Options["runtime"] = { codeMirrorStartCompletion: startCompletion, codeMirrorInsertCompletionText: insertCompletionText, codeMirrorSnippetCompletion: snippetCompletion, codeMirrorCompletionStatus: () => null, imeCompositionActive: false }; const batchSelection = { @@ -102,6 +104,40 @@ function createHarness(overrides: Partial = {}) { } describe("QueryEditor completion provider ownership", () => { + it.each(["cursor", "document", "view", "composition", "mode"])("rejects asynchronous analysis after a %s change", async (change) => { + let resolve!: (result: SqlCompletionAnalysisResult) => void; + const pending = new Promise((done) => { + resolve = done; + }); + const { currentView, view, runtime, settings, completion, startCompletion } = createHarness({}, (metadata) => { + vi.spyOn(metadata, "getEditorSqlCompletionAnalysis").mockReturnValue(pending); + }); + completion.scheduleDeferredCompletionTrigger(currentView, "L", ""); + await vi.advanceTimersByTimeAsync(25); + expect(startCompletion).not.toHaveBeenCalled(); + if (change === "cursor") currentView.dispatch({ selection: { anchor: 0 } }); + if (change === "document") currentView.dispatch({ changes: { from: 0, insert: " " } }); + if (change === "view") view.value = null; + if (change === "composition") runtime.imeCompositionActive = true; + if (change === "mode") settings.editorSettings.completionTriggerMode = "manual"; + resolve(analyzeSqlCompletion({ sql: "SEL", cursor: 3, databaseType: "mysql", semanticCompletionEnabled: false })); + await vi.advanceTimersByTimeAsync(50); + expect(startCompletion).not.toHaveBeenCalled(); + }); + + it("discards a provider result invalidated while background analysis is running", async () => { + let resolve!: (result: SqlCompletionAnalysisResult) => void; + const pending = new Promise((done) => { + resolve = done; + }); + const { provide, completion } = createHarness({}, (metadata) => { + vi.spyOn(metadata, "getEditorSqlCompletionAnalysis").mockReturnValue(pending); + }); + const result = provide(); + completion.invalidateRequests(); + resolve(analyzeSqlCompletion({ sql: "SEL", cursor: 3, databaseType: "mysql", semanticCompletionEnabled: false })); + await expect(result).resolves.toBeNull(); + }); it("keeps manual completion available while automatic completion is disabled", async () => { const { settings, provide, completion, currentView, startCompletion } = createHarness({ database: undefined }); settings.editorSettings.completionTriggerMode = "manual"; diff --git a/apps/desktop/src/components/editor/__tests__/useQueryEditorStatementBoundaries.spec.ts b/apps/desktop/src/components/editor/__tests__/useQueryEditorStatementBoundaries.spec.ts index cf331bf876..8e5e74607d 100644 --- a/apps/desktop/src/components/editor/__tests__/useQueryEditorStatementBoundaries.spec.ts +++ b/apps/desktop/src/components/editor/__tests__/useQueryEditorStatementBoundaries.spec.ts @@ -20,7 +20,7 @@ afterEach(() => { vi.restoreAllMocks(); }); -function createHarness() { +function createHarness(doc = "SELECT 1;\nSELECT 2;") { const view = shallowRef(null); const cache = { value: null as ExecutableStatementRangeCache | null }; const runtime = createQueryEditorCodeMirrorRuntime(); @@ -34,7 +34,7 @@ function createHarness() { }); const parent = document.createElement("div"); document.body.append(parent); - const currentView = new EditorView({ parent, state: EditorState.create({ doc: "SELECT 1;\nSELECT 2;", extensions: [boundaries.createTrackingPlugin(ViewPlugin)] }) }); + const currentView = new EditorView({ parent, state: EditorState.create({ doc, extensions: [boundaries.createTrackingPlugin(ViewPlugin)] }) }); view.value = currentView; cleanups.push(() => { currentView.destroy(); @@ -87,6 +87,19 @@ describe("QueryEditor shared statement boundaries", () => { expect(boundaries.statementBoundariesForState(currentView.state).doc).toBe(currentView.state.doc); }); + it("clears a pending refresh when the editor is paused", async () => { + const { currentView, cache, boundaries } = createHarness(); + boundaries.statementBoundariesForState(currentView.state); + currentView.dispatch({ changes: { from: 0, insert: " " } }); + + boundaries.clear(); + const dispatch = vi.spyOn(currentView, "dispatch"); + await vi.advanceTimersByTimeAsync(200); + + expect(cache.value).toBeNull(); + expect(dispatch).not.toHaveBeenCalled(); + }); + it.each(["destroyed", "replaced", "detached"])("does not refresh an editor that was %s", async (reason) => { const { currentView, view, parent, boundaries, cache } = createHarness(); boundaries.statementBoundariesForState(currentView.state); @@ -98,6 +111,33 @@ describe("QueryEditor shared statement boundaries", () => { const dispatch = vi.spyOn(currentView, "dispatch"); await vi.advanceTimersByTimeAsync(200); expect(dispatch).not.toHaveBeenCalled(); - expect(cache.value).toBe(previousCache); + expect(cache.value).toBe(reason === "destroyed" ? null : previousCache); + }); + + it("maps large-document folds while an exact background rebuild is pending", async () => { + const source = `BEGIN\n${"SELECT 1;\n".repeat(2_001)}END;`; + const { currentView, boundaries } = createHarness(source); + const initial = boundaries.statementBoundariesForState(currentView.state); + expect(initial.fresh).toBe(false); + await boundaries.ensureStatementCache(currentView.state); + const original = boundaries.foldRangeForState(currentView.state, 0); + expect(original).toEqual({ from: 5, to: source.indexOf("END;") }); + currentView.dispatch({ changes: { from: 0, insert: "-- prefix\n" } }); + expect(boundaries.statementBoundariesForState(currentView.state).fresh).toBe(false); + expect(boundaries.foldRangeForState(currentView.state, 10)).toEqual({ from: original!.from + 10, to: original!.to + 10 }); + await vi.advanceTimersByTimeAsync(150); + expect(boundaries.statementBoundariesForState(currentView.state).fresh).toBe(true); + }); + + it("never installs a background result for an edited or paused document", async () => { + const { currentView, boundaries, cache } = createHarness("SELECT 1;\n".repeat(2_001)); + const pending = boundaries.ensureStatementCache(currentView.state); + currentView.dispatch({ changes: { from: 0, insert: " " } }); + await expect(pending).resolves.toBeNull(); + expect(cache.value).toBeNull(); + const paused = boundaries.ensureStatementCache(currentView.state); + boundaries.clear(); + await expect(paused).resolves.toBeNull(); + expect(cache.value).toBeNull(); }); }); diff --git a/apps/desktop/src/components/editor/queryEditorSqlExtensions.ts b/apps/desktop/src/components/editor/queryEditorSqlExtensions.ts index b9ee15efab..ab0bd94c6a 100644 --- a/apps/desktop/src/components/editor/queryEditorSqlExtensions.ts +++ b/apps/desktop/src/components/editor/queryEditorSqlExtensions.ts @@ -1,4 +1,5 @@ import type { CompletionContext } from "@codemirror/autocomplete"; +import { syntaxTreeAvailable } from "@codemirror/language"; import { type QueryCompletionOption } from "./useQueryEditorBatchSelection"; import { type SqlTextRange } from "@/lib/sql/sqlStatementRanges"; import { executableStatementRangeCacheForDoc, statementGutterStartIndexHasStartAt } from "@/lib/sql/executableStatementRangeCache"; @@ -50,11 +51,11 @@ interface QueryEditorSqlExtensionsOptions { currentExecutableStatementRange: (view: EditorViewType) => SqlTextRange | null; executeSqlStatementFromGutter: (view: EditorViewType, line: { from: number; to: number }, event: Event) => boolean; signatureHelpWindowChars: number; - fullFeaturesEnabled: () => boolean; + boundedAnalysisEnabled: () => boolean; } export function configureQueryEditorSqlExtensions(options: QueryEditorSqlExtensionsOptions) { - const { props, runtime: codeMirrorRuntime, settingsStore, t, sqlBehaviorDialect, sqlDriverProfile, sqlStatementParameterOptions, queryEditorSelectionLanguage, cache, batchSelection, currentExecutableStatementRange, executeSqlStatementFromGutter, fullFeaturesEnabled } = options; + const { props, runtime: codeMirrorRuntime, settingsStore, t, sqlBehaviorDialect, sqlDriverProfile, sqlStatementParameterOptions, queryEditorSelectionLanguage, cache, batchSelection, currentExecutableStatementRange, executeSqlStatementFromGutter, boundedAnalysisEnabled } = options; const { EditorView, Decoration, StateField, StateEffect, GutterMarker, RangeSet, lineNumberMarkers, gutter, showTooltip, autocompletion, ViewPlugin, highlightingFor, syntaxTree, langSql, Prec, EditorState, ensureSyntaxTree, layer, RectangleMarker } = options.modules; const { provideSqlCompletions } = options.completion; const { renderBatchColumnSelectionActionMarker, renderBatchColumnSelectionCheckbox } = batchSelection; @@ -74,7 +75,6 @@ export function configureQueryEditorSqlExtensions(options: QueryEditorSqlExtensi }); codeMirrorRuntime.buildSqlDiagnosticExtension = () => { - if (!fullFeaturesEnabled()) return []; const diagnosticEffect = codeMirrorRuntime.setSqlDiagnosticsEffect; const buildDecorations = (state: import("@codemirror/state").EditorState) => { const errorDecorations = sqlErrorDecorationRange(state).map((range) => @@ -243,7 +243,6 @@ export function configureQueryEditorSqlExtensions(options: QueryEditorSqlExtensi codeMirrorRuntime.setStatementExecutionMarkersEffect = StateEffect.define(); codeMirrorRuntime.buildRunStatementGutterExtension = () => { - if (!fullFeaturesEnabled()) return []; const effectType = codeMirrorRuntime.setStatementExecutionMarkersEffect!; const showRunButtons = !props.hideExecutionControls && settingsStore.editorSettings.showStatementRunButtons; const markersForState = (state: import("@codemirror/state").EditorState, markers: readonly StatementExecutionMarker[]) => { @@ -294,7 +293,6 @@ export function configureQueryEditorSqlExtensions(options: QueryEditorSqlExtensi }; codeMirrorRuntime.buildSqlSignatureExtension = () => { - if (!fullFeaturesEnabled()) return []; return showTooltip.compute(["doc", "selection"], (currentState) => { const cursor = currentState.selection.main.head; // Signature detection only scans backward from the cursor, so window the @@ -313,7 +311,6 @@ export function configureQueryEditorSqlExtensions(options: QueryEditorSqlExtensi }; codeMirrorRuntime.buildSqlCompletionExtension = () => { - if (!fullFeaturesEnabled()) return []; return autocompletion({ activateOnTyping: true, defaultKeymap: false, @@ -333,11 +330,9 @@ export function configureQueryEditorSqlExtensions(options: QueryEditorSqlExtensi const shellLineCommentHighlightPlugin = createShellLineCommentHighlight({ ViewPlugin, Decoration, highlightingFor, syntaxTree }); codeMirrorRuntime.buildSqlLanguageExtension = () => [ - fullFeaturesEnabled() - ? langSql.sql({ - dialect: createDbxCodeMirrorSqlDialect(langSql, props.syntaxDialect ?? props.dialect, props.databaseType, sqlDriverProfile.value), - }) - : [], + langSql.sql({ + dialect: createDbxCodeMirrorSqlDialect(langSql, props.syntaxDialect ?? props.dialect, props.databaseType, sqlDriverProfile.value), + }), // Non-SQL editors (MongoDB shell) keep the SQL grammar for highlighting, so override the // comment marker that toggleLineComment reads from language data. Prec.highest(EditorState.languageData.of(() => [{ commentTokens: queryEditorCommentTokens(props.databaseType) }])), @@ -355,7 +350,6 @@ export function configureQueryEditorSqlExtensions(options: QueryEditorSqlExtensi const refreshSqlSemanticHighlightEffect = StateEffect.define(); codeMirrorRuntime.buildSqlSemanticHighlightExtension = () => { - if (!fullFeaturesEnabled()) return []; return [ createSqlAliasHighlights({ databaseType: props.databaseType, dialect: sqlBehaviorDialect(), enabled: queryEditorSelectionLanguage() === "sql" }), ViewPlugin.fromClass( @@ -422,7 +416,7 @@ export function configureQueryEditorSqlExtensions(options: QueryEditorSqlExtensi } const sql = this.cachedSql; - const shouldPrewarmFullDocument = this.cachedWindows.length === 0 && this.prewarmedDoc !== doc && sql.length <= MAX_FULL_DOCUMENT_SQL_SEMANTIC_HIGHLIGHT_LENGTH; + const shouldPrewarmFullDocument = !boundedAnalysisEnabled() && this.cachedWindows.length === 0 && this.prewarmedDoc !== doc && sql.length <= MAX_FULL_DOCUMENT_SQL_SEMANTIC_HIGHLIGHT_LENGTH; const windows: Array<{ from: number; to: number; @@ -451,7 +445,7 @@ export function configureQueryEditorSqlExtensions(options: QueryEditorSqlExtensi if (pendingWindows.length > 0) { const requestedTo = Math.max(...pendingWindows.map((window) => window.to)); - const tree = ensureSyntaxTree(currentView.state, requestedTo, requestedTo === sql.length ? 250 : 25); + const tree = boundedAnalysisEnabled() ? (syntaxTreeAvailable(currentView.state, requestedTo) ? syntaxTree(currentView.state) : null) : ensureSyntaxTree(currentView.state, requestedTo, requestedTo === sql.length ? 250 : 25); if (!tree) { // The Lezer parse has not reached the pending windows yet (long // documents). Keep the decorations that are still valid and let @@ -508,12 +502,13 @@ export function configureQueryEditorSqlExtensions(options: QueryEditorSqlExtensi if (view.state.selection.ranges.some((range) => !range.empty)) return null; const cursorPos = view.state.selection.main.head; const boundaries = statementBoundariesForState(view.state); - if (boundaries && !boundaries.fresh && boundaries.frameRange && cursorPos >= boundaries.frameRange.from && cursorPos <= boundaries.frameRange.to) { + if (!boundaries.fresh && boundaries.frameRange && cursorPos >= boundaries.frameRange.from && cursorPos <= boundaries.frameRange.to) { // Typing in progress: reuse the ChangeSet-shifted range instead of // re-parsing the whole document. The debounced refresh rebuilds and // repaints the exact frame once typing pauses. return { from: boundaries.frameRange.from, to: currentStatementFrameTo(view, { from: boundaries.frameRange.from, to: boundaries.frameRange.to, sql: "" }) }; } + if (boundedAnalysisEnabled() && !boundaries.fresh) return null; let range = currentExecutableStatementRange(view); if (!range) { const cursorLine = view.state.doc.lineAt(cursorPos); diff --git a/apps/desktop/src/components/editor/useQueryEditorCompletion.ts b/apps/desktop/src/components/editor/useQueryEditorCompletion.ts index b8d0f8a2fc..f16de018ac 100644 --- a/apps/desktop/src/components/editor/useQueryEditorCompletion.ts +++ b/apps/desktop/src/components/editor/useQueryEditorCompletion.ts @@ -9,9 +9,7 @@ import { buildSqlCompletionItemsFromContext, buildPostgresSequenceLiteralCompletionItems, getSqlCompletionContext, - getPostgresSequenceLiteralCompletionContext, getSqlCompletionResultValidFor, - isSqlCompletionSuppressedContext, isSqlLikeCompletionStatement, prepareSqlCompletionReplacement, recordCompletionSelection, @@ -21,8 +19,6 @@ import { } from "@/lib/sql/sqlCompletion"; import { originForSqlCompletionProvider, originForTypedSqlCompletionStart, shouldAllowSqlCompletionTrigger, type SqlCompletionTriggerFacts, type SqlCompletionTriggerOrigin } from "@/lib/sql/sqlCompletionTriggerPolicy"; import { driverProfileHasCompletionCandidates } from "@/lib/database/driverProfileExtensions"; -import { sqlCompletionContextFromSemantic } from "@/lib/sql/semantic/completion"; -import { buildSqlSemanticModel } from "@/lib/sql/semantic/model"; import { buildElasticsearchCompletionItemsFromContext, elasticsearchCompletionNeedsFields, getElasticsearchCompletionContext, getElasticsearchCompletionResultValidFor, shouldAutoOpenElasticsearchCompletion, type ElasticsearchCompletionField } from "@/lib/elasticsearch/elasticsearchCompletion"; import { buildMongoCompletionItemsFromContext, getMongoCompletionContext, getMongoCompletionResultValidFor, mongoCompletionNeedsCollections, mongoCompletionNeedsFields, shouldAutoOpenMongoCompletion } from "@/lib/mongo/mongoCompletion"; import { buildSoqlCompletionItems, getSoqlCompletionContext, getSoqlCompletionResultValidFor, resolveSoqlFieldCandidates, resolveSoqlValueField, shouldAutoOpenSoqlCompletion, soqlCompletionNeedsObjects, type SoqlCompletionField, type SoqlCompletionObject } from "@/lib/soql/soqlCompletion"; @@ -33,7 +29,6 @@ import { resolveSqlCompletionSchemaLookupDatabase, resolveSqlCompletionScope, resolveSqlCompletionTableLookupTarget, - resolveSqlServerUseDatabaseCompletion, sqlServerUseCompletionDatabaseNames, sqlServerUseDatabaseBeforeCursor, } from "@/lib/sql/sqlCompletionLookupTarget"; @@ -42,7 +37,7 @@ import { completionReplacementTo, shouldResolveSqlColumnCompletion } from "@/lib import { completionLabelPresentation } from "@/lib/editor/sqlCompletionPresentation"; import { supportsDatabaseNameCompletion } from "@/lib/database/databaseFeatureSupport"; import { sqlSnippetDatabaseTypeForConnection } from "@/lib/database/jdbcDialect"; -import { oracleDatabaseLinkCompletionContext, oracleDatabaseLinkCompletionItems } from "@/lib/sql/oracleDatabaseLinkCompletion"; +import { oracleDatabaseLinkCompletionItems } from "@/lib/sql/oracleDatabaseLinkCompletion"; import { isOracleCompletionDatabase } from "@/lib/sql/oracleCompletionSession"; import { buildRedisCompletionItemsFromContext, getRedisCompletionContext, getRedisCompletionResultValidFor, shouldAutoOpenRedisCompletion, takesKeyArgument } from "@/lib/redis/redisCompletion"; import type { SqlCompletionColumn, SqlCompletionContext, SqlCompletionForeignKey, SqlCompletionItem, SqlCompletionObject } from "@/lib/sql/sqlCompletion"; @@ -87,6 +82,8 @@ export function useQueryEditorCompletion(options: QueryEditorCompletionOptions) supportsDatabaseSchemaQualifierCompletion, sqlCompletionDialectOptions, getEditorSqlCompletionContext, + getEditorSqlCompletionAnalysis, + cancelEditorSqlCompletionAnalysis, supportsDatabaseQualifierCompletion, completionObjectsForScope, usesOracleSessionCompletionColumns, @@ -119,7 +116,7 @@ export function useQueryEditorCompletion(options: QueryEditorCompletionOptions) const COMPLETION_TRIGGER_DEFER_DELAY_MS = options.triggerDeferDelayMs; const MAX_COMPLETION_TABLES = options.maxCompletionTables; const PRESTO_ON_DEMAND_TABLE_COMPLETION_LIMIT = options.onDemandTableLimit; - const SEMANTIC_SQL_COMPLETION_ENABLED = options.semanticCompletionEnabled; + let analyzedCtes: { sql: string; definitions: ReturnType } | null = null; const snippetDatabaseType = computed(() => { const connection = props.connectionId ? connectionStore.getConfig(props.connectionId) : undefined; @@ -679,26 +676,26 @@ export function useQueryEditorCompletion(options: QueryEditorCompletionOptions) return provideSoqlCompletions(currentState, position, explicit); } const hasDatabase = props.database != null; - const sequenceLiteralContext = getPostgresSequenceLiteralCompletionContext(fullDoc, position, props.databaseType); - const databaseLinkContext = oracleDatabaseLinkCompletionContext(fullDoc, position, props.databaseType); - const epoch = ++completionEpoch; + context.addEventListener( + "abort", + () => { + if (epoch !== completionEpoch) return; + completionEpoch++; + cancelEditorSqlCompletionAnalysis(); + }, + { onDocChange: true }, + ); try { - // 1. Suppressed context (comment / string literal) rejects everything, including explicit. - if (isSqlCompletionSuppressedContext(fullDoc, position, { databaseType: props.databaseType, editorState: currentState }) && !sequenceLiteralContext) return null; - - // 2. Determine completion origin (session-level marker). activeCompletionOrigin = originForSqlCompletionProvider(activeCompletionOrigin, context.explicit); const origin = activeCompletionOrigin; - - // 3. Explicit (manual shortcut) -> always proceed. No mode gating. - // 4. For typing sessions, apply mode gating with lazy fact computation. - const useDatabaseCompletion = resolveSqlServerUseDatabaseCompletion({ - sql: fullDoc, - cursor: position, - databaseType: props.databaseType, - }); + if (origin !== "explicit" && settingsStore.editorSettings.completionTriggerMode === "manual") return null; + const analysis = await getEditorSqlCompletionAnalysis(fullDoc, position, currentState); + if (!analysis || context.aborted || epoch !== completionEpoch) return null; + analyzedCtes = { sql: fullDoc, definitions: analysis.cteDefinitions }; + const { sequenceLiteralContext, databaseLinkContext, useDatabaseCompletion } = analysis; + if (analysis.suppressed && !sequenceLiteralContext) return null; const useDatabasePrefix = useDatabaseCompletion?.prefix ?? null; if (origin !== "explicit") { @@ -709,7 +706,8 @@ export function useQueryEditorCompletion(options: QueryEditorCompletionOptions) // require-prefix: only compute local facts (no positionalEligible). if (mode === "require-prefix") { - const ctx = databaseLinkContext ?? sequenceLiteralContext ?? getEditorSqlCompletionContext(fullDoc, position); + const ctx = databaseLinkContext ?? sequenceLiteralContext ?? analysis.completionContext; + if (!ctx || epoch !== completionEpoch) return null; const prevChar = fullDoc[position - 1] ?? ""; const facts: SqlCompletionTriggerFacts = { origin, @@ -722,9 +720,10 @@ export function useQueryEditorCompletion(options: QueryEditorCompletionOptions) // positional: compute positionalEligible (lazy). if (mode === "positional") { - const ctx = databaseLinkContext ?? sequenceLiteralContext ?? getEditorSqlCompletionContext(fullDoc, position); + const ctx = databaseLinkContext ?? sequenceLiteralContext ?? analysis?.completionContext; + if (!ctx || epoch !== completionEpoch) return null; const prevChar = fullDoc[position - 1] ?? ""; - const positionalEligible = shouldAutoOpenSqlCompletion(fullDoc, position, sqlCompletionDialectOptions()); + const positionalEligible = !!databaseLinkContext || !!sequenceLiteralContext || !!analysis?.positionalEligible; const facts: SqlCompletionTriggerFacts = { origin, hasIdentifierPrefix: ctx.prefix.length > 0, @@ -792,9 +791,7 @@ export function useQueryEditorCompletion(options: QueryEditorCompletionOptions) return buildCompletionResult(buildPostgresSequenceLiteralCompletionItems(sequenceLiteralContext, sequences), sequenceLiteralContext.from, undefined, sequenceLiteralContext.prefix); } - const legacyCompletionContext = getEditorSqlCompletionContext(fullDoc, position); - const semanticModel = SEMANTIC_SQL_COMPLETION_ENABLED ? buildSqlSemanticModel(fullDoc, position, sqlCompletionDialectOptions()) : null; - let completionContext = semanticModel ? sqlCompletionContextFromSemantic(semanticModel, legacyCompletionContext) : legacyCompletionContext; + let completionContext = analysis.completionContext; if (!hasDatabase) { const items = buildSqlCompletionItemsFromContext(completionContext, { @@ -960,10 +957,11 @@ export function useQueryEditorCompletion(options: QueryEditorCompletionOptions) clearDeferredCompletionTrigger(); const expectedDoc = currentView.state.doc; const expectedPosition = currentView.state.selection.main.head; - deferredCompletionTriggerTimer = setTimeout(() => { + deferredCompletionTriggerTimer = setTimeout(async () => { deferredCompletionTriggerTimer = null; if (view.value !== currentView || currentView.state.doc !== expectedDoc || currentView.state.selection.main.head !== expectedPosition || isEditorComposing(currentView)) return; - if (shouldStartSqlCompletionAfterInput(insertedText, removedText, currentView)) { + if (await shouldStartSqlCompletionAfterInput(insertedText, removedText, currentView)) { + if (view.value !== currentView || currentView.state.doc !== expectedDoc || currentView.state.selection.main.head !== expectedPosition || isEditorComposing(currentView)) return; scheduleSqlCompletionStart(currentView); } }, COMPLETION_TRIGGER_DEFER_DELAY_MS); @@ -973,47 +971,27 @@ export function useQueryEditorCompletion(options: QueryEditorCompletionOptions) * Returns true when the current SQL position should trigger completion under the active trigger mode. * Used by flushImeComposition and shouldStartSqlCompletionAfterInput. */ - function shouldTriggerSqlCompletionForPosition(fullDoc: string, position: number): boolean { - const sequenceLiteralContext = getPostgresSequenceLiteralCompletionContext(fullDoc, position, props.databaseType); - const databaseLinkContext = oracleDatabaseLinkCompletionContext(fullDoc, position, props.databaseType); - if (isSqlCompletionSuppressedContext(fullDoc, position, { databaseType: props.databaseType, editorState: view.value?.state }) && !sequenceLiteralContext) return false; + async function shouldTriggerSqlCompletionForPosition(fullDoc: string, position: number): Promise { const mode = settingsStore.editorSettings.completionTriggerMode; if (mode === "manual") return false; - - const useDatabaseCompletion = resolveSqlServerUseDatabaseCompletion({ - sql: fullDoc, - cursor: position, - databaseType: props.databaseType, - }); - const useDatabasePrefix = useDatabaseCompletion?.prefix ?? null; - - if (mode === "require-prefix") { - const ctx = databaseLinkContext ?? sequenceLiteralContext ?? getEditorSqlCompletionContext(fullDoc, position); - const prevChar = fullDoc[position - 1] ?? ""; - const facts: SqlCompletionTriggerFacts = { - origin: "typing", - hasIdentifierPrefix: ctx.prefix.length > 0, - qualifierTriggered: !!databaseLinkContext || (prevChar === "." && ("schema" in ctx ? ctx.schema != null : "qualifier" in ctx && ctx.qualifier != null)), - useDatabasePrefix, - }; - return shouldAllowSqlCompletionTrigger(mode, facts); - } - - // positional - const ctx = databaseLinkContext ?? sequenceLiteralContext ?? getEditorSqlCompletionContext(fullDoc, position); + const currentView = view.value; + const expectedDoc = currentView?.state.doc; + const analysis = await getEditorSqlCompletionAnalysis(fullDoc, position, currentView?.state); + if (!analysis || view.value !== currentView || currentView?.state.doc !== expectedDoc || settingsStore.editorSettings.completionTriggerMode !== mode) return false; + const { sequenceLiteralContext, databaseLinkContext, useDatabaseCompletion } = analysis; + if (analysis.suppressed && !sequenceLiteralContext) return false; + const context = databaseLinkContext ?? sequenceLiteralContext ?? analysis.completionContext; const prevChar = fullDoc[position - 1] ?? ""; - const positionalEligible = shouldAutoOpenSqlCompletion(fullDoc, position, sqlCompletionDialectOptions()); - const facts: SqlCompletionTriggerFacts = { + return shouldAllowSqlCompletionTrigger(mode, { origin: "typing", - hasIdentifierPrefix: ctx.prefix.length > 0, - qualifierTriggered: !!databaseLinkContext || (prevChar === "." && ("schema" in ctx ? ctx.schema != null : "qualifier" in ctx && ctx.qualifier != null)), - useDatabasePrefix, - positionalEligible, - }; - return shouldAllowSqlCompletionTrigger(mode, facts); + hasIdentifierPrefix: context.prefix.length > 0, + qualifierTriggered: !!databaseLinkContext || (prevChar === "." && ("schema" in context ? context.schema != null : "qualifier" in context && context.qualifier != null)), + useDatabasePrefix: useDatabaseCompletion?.prefix ?? null, + positionalEligible: analysis.positionalEligible, + }); } - function shouldStartSqlCompletionAfterInput(insertedText: string, removedText: string, currentView: EditorViewType): boolean { + async function shouldStartSqlCompletionAfterInput(insertedText: string, removedText: string, currentView: EditorViewType): Promise { const position = currentView.state.selection.main.head; const fullDoc = currentView.state.doc.toString(); @@ -1122,7 +1100,7 @@ export function useQueryEditorCompletion(options: QueryEditorCompletionOptions) } } - const cteDefs = extractCteDefinitions(fullDoc); + const cteDefs = analyzedCtes?.sql === fullDoc ? analyzedCtes.definitions : extractCteDefinitions(fullDoc); for (const refTable of completionContext.referencedTables) { if (refTable.columns?.length) { columnsByTable.set( @@ -1541,7 +1519,7 @@ export function useQueryEditorCompletion(options: QueryEditorCompletionOptions) } // Populate CTE columns from parsed definitions - const cteDefs = extractCteDefinitions(fullDoc); + const cteDefs = analyzedCtes?.sql === fullDoc ? analyzedCtes.definitions : extractCteDefinitions(fullDoc); for (const refTable of refs) { if (refTable.columns) continue; const cteDef = cteDefs.find((c) => c.name.toLowerCase() === refTable.name.toLowerCase()); @@ -1676,11 +1654,14 @@ export function useQueryEditorCompletion(options: QueryEditorCompletionOptions) return completionContext.suggestTables || completionContext.exclusiveTableSuggestions; } - function refreshActiveSqlCompletion(fullDoc: string, position: number, completionContext: ReturnType) { + async function refreshActiveSqlCompletion(fullDoc: string, position: number, completionContext: ReturnType) { const currentView = view.value; if (!currentView || runtime.codeMirrorCompletionStatus?.(currentView.state) !== "active") return; if (currentView.state.doc.toString() !== fullDoc || currentView.state.selection.main.head !== position) return; - const currentContext = getSqlCompletionContext(fullDoc, position); + const doc = currentView.state.doc; + const analysis = await getEditorSqlCompletionAnalysis(fullDoc, position, currentView.state); + if (!analysis || view.value !== currentView || currentView.state.doc !== doc || currentView.state.selection.main.head !== position) return; + const currentContext = analysis.completionContext; if (currentContext.prefix !== completionContext.prefix || currentContext.contextKind !== completionContext.contextKind) return; scheduleSqlCompletionStart(currentView); } @@ -1696,6 +1677,7 @@ export function useQueryEditorCompletion(options: QueryEditorCompletionOptions) clearDeferredCompletionTrigger, invalidateRequests() { completionEpoch++; + cancelEditorSqlCompletionAnalysis(); }, get activeOrigin() { return activeCompletionOrigin; diff --git a/apps/desktop/src/components/editor/useQueryEditorCompletionMetadata.ts b/apps/desktop/src/components/editor/useQueryEditorCompletionMetadata.ts index 4ddf902f56..d1a4920b13 100644 --- a/apps/desktop/src/components/editor/useQueryEditorCompletionMetadata.ts +++ b/apps/desktop/src/components/editor/useQueryEditorCompletionMetadata.ts @@ -5,6 +5,9 @@ import { COMPLETION_METADATA_CONCURRENCY } from "@/stores/connectionStore"; import { getSqlCompletionContext } from "@/lib/sql/sqlCompletion"; import { buildSqlSemanticModel } from "@/lib/sql/semantic/model"; import type { SqlSemanticModel } from "@/lib/sql/semantic/types"; +import { analyzeSqlCompletion, type SqlCompletionAnalysisResult } from "@/lib/sql/sqlCompletionAnalysis"; +import { createSqlCompletionAnalysisWorker } from "@/lib/sql/sqlCompletionAnalysisWorker"; +import { shouldUseQueryEditorLargeDocumentModeForSize } from "@/lib/editor/queryEditorLargeDocument"; import { usesOracleSessionCompletionColumns as shouldUseOracleSessionCompletionColumns } from "@/lib/sql/oracleCompletionSession"; import { mergeSqlObjectNavigationType } from "@/lib/sql/sqlNavigation"; import { requestInsertValueHintsRefresh, supportsInsertValueHints } from "@/lib/editor/codemirrorInsertValueHints"; @@ -59,6 +62,7 @@ export function useQueryEditorCompletionMetadata(options: QueryEditorCompletionM const cachedForeignKeysByTable = new Map(); const loadedColumnsByTable = new Set(); + const completionAnalysisWorker = createSqlCompletionAnalysisWorker(); function sqlCompletionDialectOptions() { return { @@ -102,6 +106,64 @@ export function useQueryEditorCompletionMetadata(options: QueryEditorCompletionM return context; } + let editorCompletionAnalysisCache: { + doc: Text; + editorState: EditorState; + position: number; + databaseType: DatabaseType | undefined; + dialect: QueryEditorProps["dialect"]; + driverProfile: string | undefined; + syntaxDialect: QueryEditorProps["syntaxDialect"]; + result: Promise; + } | null = null; + + function getEditorSqlCompletionAnalysis(sql: string, position: number, editorState = view.value?.state): Promise { + const dialect = sqlBehaviorDialect(); + const doc = editorState?.doc; + const driverProfile = props.connectionId ? connectionStore.getConfig(props.connectionId)?.driver_profile : undefined; + if ( + doc && + editorCompletionAnalysisCache?.doc === doc && + editorCompletionAnalysisCache.editorState === editorState && + editorCompletionAnalysisCache.position === position && + editorCompletionAnalysisCache.databaseType === props.databaseType && + editorCompletionAnalysisCache.dialect === dialect && + editorCompletionAnalysisCache.driverProfile === driverProfile && + editorCompletionAnalysisCache.syntaxDialect === props.syntaxDialect + ) { + return editorCompletionAnalysisCache.result; + } + + const request = { + sql, + cursor: position, + databaseType: props.databaseType, + dialect, + semanticCompletionEnabled: SEMANTIC_SQL_COMPLETION_ENABLED, + syntaxDialect: props.syntaxDialect, + driverProfile, + }; + const result = editorState && shouldUseQueryEditorLargeDocumentModeForSize(editorState.doc.length, editorState.doc.lines) ? completionAnalysisWorker.analyze(request, editorState) : Promise.resolve(analyzeSqlCompletion(request, editorState)); + if (doc && editorState) { + editorCompletionAnalysisCache = { + doc, + editorState, + position, + databaseType: props.databaseType, + dialect, + driverProfile, + syntaxDialect: props.syntaxDialect, + result, + }; + } + return result; + } + + function cancelEditorSqlCompletionAnalysis() { + editorCompletionAnalysisCache = null; + completionAnalysisWorker.cancel(); + } + // CTE definition metadata is cursor-independent, but hover/ctrl-click models still carry the // position; memoize per (Text node, position, dialect, state) so hover and click at the same // position share one parse. Any edit swaps the Text node, invalidating the cache for free. @@ -541,6 +603,7 @@ export function useQueryEditorCompletionMetadata(options: QueryEditorCompletionM } function refreshCompletionCache() { + cancelEditorSqlCompletionAnalysis(); cachedTables = []; cachedCompletionObjectsByScope.clear(); cachedColumnsByTable.clear(); @@ -553,6 +616,9 @@ export function useQueryEditorCompletionMetadata(options: QueryEditorCompletionM return { sqlCompletionDialectOptions, getEditorSqlCompletionContext, + getEditorSqlCompletionAnalysis, + cancelEditorSqlCompletionAnalysis, + disposeCompletionAnalysis: completionAnalysisWorker.dispose, ensureColumnsForTable, cachedColumnsByTable, completionCacheKey, diff --git a/apps/desktop/src/components/editor/useQueryEditorDiagnostics.ts b/apps/desktop/src/components/editor/useQueryEditorDiagnostics.ts index adab603bb9..e15a26059f 100644 --- a/apps/desktop/src/components/editor/useQueryEditorDiagnostics.ts +++ b/apps/desktop/src/components/editor/useQueryEditorDiagnostics.ts @@ -4,6 +4,7 @@ import type { useConnectionStore } from "@/stores/connectionStore"; import type { QueryEditorProps, CompletionMetadataScope } from "./queryEditorTypes"; import * as api from "@/lib/backend/api"; import type { SqlParameterOptions } from "@/lib/sql/sqlParameters"; +import { createSqlDiagnosticAnalysisWorker } from "@/lib/sql/sqlDiagnosticAnalysisWorker"; import type { EditorView as EditorViewType } from "@codemirror/view"; import { type SqlTextRange } from "@/lib/sql/sqlStatementRanges"; @@ -70,12 +71,13 @@ interface QueryEditorDiagnosticsOptions { semanticCompletionEnabled: boolean; maxCompletionTables: number; unknownObjectHighlightEnabled: boolean; - fullFeaturesEnabled?: () => boolean; + boundedAnalysisEnabled?: () => boolean; runtime: QueryEditorDiagnosticsRuntime; metadata: QueryEditorDiagnosticMetadata; } export function useQueryEditorDiagnostics(options: QueryEditorDiagnosticsOptions) { + const diagnosticAnalysisWorker = createSqlDiagnosticAnalysisWorker(); const { props, view, settingsStore, connectionStore, sqlDriverProfile, sqlStatementParameterOptions, sqlBehaviorDialect, runtime, metadata } = options; const SEMANTIC_SQL_COMPLETION_ENABLED = options.semanticCompletionEnabled; const MAX_COMPLETION_TABLES = options.maxCompletionTables; @@ -181,6 +183,7 @@ export function useQueryEditorDiagnostics(options: QueryEditorDiagnosticsOptions } function clearScheduledSemanticDiagnostics() { + diagnosticAnalysisWorker.dispose(); semanticDiagnosticRunId++; if (semanticDiagnosticTimer) clearTimeout(semanticDiagnosticTimer); semanticDiagnosticTimer = null; @@ -188,12 +191,13 @@ export function useQueryEditorDiagnostics(options: QueryEditorDiagnosticsOptions } function invalidateSemanticDiagnosticsForDocumentChange() { + diagnosticAnalysisWorker.cancel(); semanticDiagnosticRunId++; semanticDiagnostics = []; } function shouldSkipSqlSemanticDiagnostics() { - return options.fullFeaturesEnabled?.() === false || props.databaseType === "victoriametrics" || props.databaseType === "salesforce" || (props.databaseType !== "redis" && props.databaseType !== "mongodb" && !settingsStore.editorSettings.sqlSemanticDiagnosticsEnabled); + return props.databaseType === "victoriametrics" || props.databaseType === "salesforce" || (props.databaseType !== "redis" && props.databaseType !== "mongodb" && !settingsStore.editorSettings.sqlSemanticDiagnosticsEnabled); } function rangesOverlap(left: { from: number; to: number }, right: { from: number; to: number }): boolean { @@ -322,7 +326,7 @@ export function useQueryEditorDiagnostics(options: QueryEditorDiagnosticsOptions return kind === "cte" || kind === "subquery" || kind === "table_function"; } - async function refreshSemanticDiagnostics(options: { preserveOutsideRanges?: boolean } = {}) { + async function refreshSemanticDiagnostics(refreshOptions: { preserveOutsideRanges?: boolean } = {}) { const currentView = view.value; const runId = ++semanticDiagnosticRunId; if (!currentView || !props.connectionId || props.database == null) { @@ -349,7 +353,7 @@ export function useQueryEditorDiagnostics(options: QueryEditorDiagnosticsOptions // Redis has no SQL semantics; run command-name / arity / quote / danger checks instead. if (!shouldRunRedisDiagnostics(sql, currentView.state.selection.main.head)) { scheduleSemanticDiagnostics(900, { - preserveOutsideRanges: options.preserveOutsideRanges, + preserveOutsideRanges: refreshOptions.preserveOutsideRanges, }); return; } @@ -360,35 +364,38 @@ export function useQueryEditorDiagnostics(options: QueryEditorDiagnosticsOptions setSemanticDiagnostics([]); return; } - if (!shouldRunSqlSemanticDiagnostics(sql, currentView.state.selection.main.head, { databaseType: props.databaseType })) { + const visibleRanges = currentView.visibleRanges.length > 0 ? currentView.visibleRanges : [currentView.viewport]; + const backgroundAnalysis = options.boundedAnalysisEnabled?.() === true; + const prepared = backgroundAnalysis ? (await diagnosticAnalysisWorker.analyze({ sql, cursor: currentView.state.selection.main.head, databaseType: props.databaseType, driverProfile: sqlDriverProfile.value, parameterOptions: sqlStatementParameterOptions(), visibleRanges }))?.document : undefined; + if (runId !== semanticDiagnosticRunId || (backgroundAnalysis && !prepared)) return; + if (!(prepared?.shouldRun ?? shouldRunSqlSemanticDiagnostics(sql, currentView.state.selection.main.head, { databaseType: props.databaseType }))) { scheduleSemanticDiagnostics(1200, { - preserveOutsideRanges: options.preserveOutsideRanges, + preserveOutsideRanges: refreshOptions.preserveOutsideRanges, }); return; } - if (runtime.codeMirrorCompletionStatus?.(currentView.state) && isSqlSemanticDiagnosticInputContext(sql, currentView.state.selection.main.head, { databaseType: props.databaseType })) { + if (runtime.codeMirrorCompletionStatus?.(currentView.state) && (prepared?.inputContext ?? isSqlSemanticDiagnosticInputContext(sql, currentView.state.selection.main.head, { databaseType: props.databaseType }))) { scheduleSemanticDiagnostics(900, { - preserveOutsideRanges: options.preserveOutsideRanges, + preserveOutsideRanges: refreshOptions.preserveOutsideRanges, }); return; } - const visibleRanges = currentView.visibleRanges.length > 0 ? currentView.visibleRanges : [currentView.viewport]; - if (props.databaseType !== "sqlserver") { + if (!prepared && props.databaseType !== "sqlserver") { runtime.executableStatementRangeCache = executableStatementRangeCacheForDoc(runtime.executableStatementRangeCache, currentView.state.doc, props.databaseType, sqlStatementParameterOptions()); } - const diagnosticRanges = sqlSemanticDiagnosticRangesForViewport(sql, visibleRanges, props.databaseType, props.databaseType === "sqlserver" ? undefined : runtime.executableStatementRangeCache?.ranges, sqlStatementParameterOptions()); + const diagnosticRanges = prepared?.diagnosticRanges ?? sqlSemanticDiagnosticRangesForViewport(sql, visibleRanges, props.databaseType, props.databaseType === "sqlserver" ? undefined : runtime.executableStatementRangeCache?.ranges, sqlStatementParameterOptions()); // SQL Server routine batches are excluded from `diagnosticRanges` (see // `sqlServerRoutineDefinitionRangesForViewport`), so they are recomputed here // and stay part of the replaced range set below. - const sqlServerRoutineRanges = props.databaseType === "sqlserver" ? sqlServerRoutineDefinitionRangesForViewport(sql, visibleRanges) : []; + const sqlServerRoutineRanges = prepared?.sqlServerRoutineRanges ?? (props.databaseType === "sqlserver" ? sqlServerRoutineDefinitionRangesForViewport(sql, visibleRanges) : []); if (diagnosticRanges.length === 0 && sqlServerRoutineRanges.length === 0) { - if (!options.preserveOutsideRanges) setSemanticDiagnostics([]); + if (!refreshOptions.preserveOutsideRanges) setSemanticDiagnostics([]); return; } const nextDiagnostics: SqlSemanticDiagnostic[] = []; - const oracleSyntaxDiagnostics = buildOracleSyntaxDiagnostics(sql, props.databaseType); + const oracleSyntaxDiagnostics = prepared?.oracleSyntaxDiagnostics ?? buildOracleSyntaxDiagnostics(sql, props.databaseType); nextDiagnostics.push( ...oracleSyntaxDiagnostics.filter((diagnostic) => { const diagnosticRange = sqlTextSpanToRange(sql, diagnostic.span); @@ -398,10 +405,10 @@ export function useQueryEditorDiagnostics(options: QueryEditorDiagnosticsOptions // The analyzer never sees routine batches (the MsSql grammar cannot parse their // parameter list), so run the token-based routine syntax rules instead of leaving // a stored procedure without any check at all (dbx#9315). - for (const range of sqlServerRoutineRanges) { - nextDiagnostics.push(...offsetSqlSemanticDiagnostics(buildSqlServerRoutineSyntaxDiagnostics(range.sql, props.databaseType), range, sql)); + for (const [index, range] of sqlServerRoutineRanges.entries()) { + nextDiagnostics.push(...offsetSqlSemanticDiagnostics(prepared?.sqlServerRoutineDiagnostics[index] ?? buildSqlServerRoutineSyntaxDiagnostics(range.sql, props.databaseType), range, sql)); } - const mysqlRoutineAnalysis = props.databaseType === "mysql" && supportsMysqlRoutineSyntaxDiagnostics(sqlDriverProfile.value) ? analyzeMysqlRoutineSyntax(sql) : null; + const mysqlRoutineAnalysis = prepared ? prepared.mysqlRoutineAnalysis : props.databaseType === "mysql" && supportsMysqlRoutineSyntaxDiagnostics(sqlDriverProfile.value) ? analyzeMysqlRoutineSyntax(sql) : null; if (mysqlRoutineAnalysis) { nextDiagnostics.push( ...mysqlRoutineAnalysis.diagnostics.filter((diagnostic) => { @@ -424,13 +431,20 @@ export function useQueryEditorDiagnostics(options: QueryEditorDiagnosticsOptions if (runId !== semanticDiagnosticRunId) return; const semanticCursor = Math.max(0, Math.min(currentView.state.selection.main.head - range.from, range.sql.length)); - const semanticModel = SEMANTIC_SQL_COMPLETION_ENABLED - ? buildSqlSemanticModel(range.sql, semanticCursor, { - databaseType: props.databaseType, - dialect: sqlBehaviorDialect(), - }) - : null; - const semanticAnalysis = semanticModel ? mergeSqlSemanticReferenceAnalysis(analysis, semanticModel) : analysis; + const semanticModel = + SEMANTIC_SQL_COMPLETION_ENABLED && !backgroundAnalysis + ? buildSqlSemanticModel(range.sql, semanticCursor, { + databaseType: props.databaseType, + dialect: sqlBehaviorDialect(), + }) + : null; + const semanticAnalysis = + SEMANTIC_SQL_COMPLETION_ENABLED && backgroundAnalysis + ? (await diagnosticAnalysisWorker.analyze({ sql: range.sql, cursor: semanticCursor, databaseType: props.databaseType, dialect: sqlBehaviorDialect(), visibleRanges: [], referenceAnalysis: analysis }))?.referenceAnalysis + : semanticModel + ? mergeSqlSemanticReferenceAnalysis(analysis, semanticModel) + : analysis; + if (!semanticAnalysis || runId !== semanticDiagnosticRunId) return; const metadataScope = semanticDiagnosticMetadataScope(sql, range); const scopedAnalysis = { ...semanticAnalysis, @@ -465,7 +479,7 @@ export function useQueryEditorDiagnostics(options: QueryEditorDiagnosticsOptions if (diagnostic) nextDiagnostics.push(...offsetSqlSemanticDiagnostics([diagnostic], range, sql)); } } - if (options.preserveOutsideRanges) { + if (refreshOptions.preserveOutsideRanges) { replaceSemanticDiagnosticsInRanges(nextDiagnostics, [...diagnosticRanges, ...sqlServerRoutineRanges], sql); } else { setSemanticDiagnostics(nextDiagnostics.sort(compareSqlSemanticDiagnostics)); diff --git a/apps/desktop/src/components/editor/useQueryEditorStatementBoundaries.ts b/apps/desktop/src/components/editor/useQueryEditorStatementBoundaries.ts index 9d31213d3e..d88c536a34 100644 --- a/apps/desktop/src/components/editor/useQueryEditorStatementBoundaries.ts +++ b/apps/desktop/src/components/editor/useQueryEditorStatementBoundaries.ts @@ -1,9 +1,20 @@ import type { ShallowRef } from "vue"; +import { RangeSet, RangeValue, type EditorState } from "@codemirror/state"; import type { EditorView as EditorViewType, ViewPlugin as ViewPluginType } from "@codemirror/view"; import { executableStatementRangeCacheForDoc, statementGutterStartIndexForCache, mapStatementGutterStartIndex, type ExecutableStatementRangeCache, type StatementGutterStartIndex } from "@/lib/sql/executableStatementRangeCache"; import type { SqlParameterOptions } from "@/lib/sql/sqlParameters"; import type { QueryEditorProps } from "./queryEditorTypes"; import type { QueryEditorCodeMirrorRuntime } from "./queryEditorCodeMirrorRuntime"; +import { createSqlStatementAnalysisWorker } from "@/lib/sql/sqlStatementAnalysisWorker"; +import { shouldUseQueryEditorLargeDocumentModeForSize } from "@/lib/editor/queryEditorLargeDocument"; +import type { FoldRange } from "@/lib/editor/codemirrorSqlBlockFolding"; + +class FoldRangeMarker extends RangeValue { + startSide = 1; + endSide = 1; +} + +const foldRangeMarker = new FoldRangeMarker(); interface QueryEditorStatementBoundariesOptions { props: Readonly; @@ -11,6 +22,7 @@ interface QueryEditorStatementBoundariesOptions { sqlStatementParameterOptions: () => SqlParameterOptions; cache: { value: ExecutableStatementRangeCache | null }; runtime: QueryEditorCodeMirrorRuntime; + driverProfile?: () => string | undefined; } export function useQueryEditorStatementBoundaries(options: QueryEditorStatementBoundariesOptions) { @@ -18,14 +30,18 @@ export function useQueryEditorStatementBoundaries(options: QueryEditorStatementB // Lenient statement-boundary view shared by the run-statement gutter and the // current-statement frame. Re-parsing the whole document on every keystroke is // the dominant typing cost on large scripts, so while typing we only shift the - // known start positions / frame range through the ChangeSet (O(statements)) and - // rebuild exactly once after typing pauses. Paths that must stay exact (gutter + // known positions through RangeSet mapping and rebuild after typing pauses, + // off-thread for large documents. Paths that must stay exact (gutter // click-to-execute, execution picker) keep using the full on-demand parse. const STATEMENT_BOUNDARIES_REFRESH_MS = 150; + const analysisWorker = createSqlStatementAnalysisWorker(); + let trackedView: EditorViewType | null = null; + let pendingAnalysis: { doc: EditorState["doc"]; generation: number; result: Promise } | null = null; let statementBoundariesView: { doc: import("@codemirror/state").Text; startsIndex: StatementGutterStartIndex; + folds: RangeSet; frameRange: { from: number; to: number } | null; fresh: boolean; generation: number; @@ -35,20 +51,65 @@ export function useQueryEditorStatementBoundaries(options: QueryEditorStatementB let statementBoundariesRefreshTimer: ReturnType | null = null; - function refreshStatementBoundaries(state: import("@codemirror/state").EditorState) { - cache.value = executableStatementRangeCacheForDoc(cache.value, state.doc, props.databaseType, sqlStatementParameterOptions()); + function cancelStatementBoundariesRefresh() { + if (statementBoundariesRefreshTimer !== null) clearTimeout(statementBoundariesRefreshTimer); + statementBoundariesRefreshTimer = null; + } + + function clearStatementBoundaries() { + cancelStatementBoundariesRefresh(); + analysisWorker.dispose(); + pendingAnalysis = null; + statementBoundariesGeneration += 1; + cache.value = null; + statementBoundariesView = null; + } + + function installStatementBoundaries(state: EditorState, result: ExecutableStatementRangeCache, folds?: Map) { + cache.value = result; statementBoundariesView = { doc: state.doc, - startsIndex: statementGutterStartIndexForCache(cache.value), + startsIndex: statementGutterStartIndexForCache(result), + folds: folds + ? RangeSet.of( + [...folds.values()].map((range) => foldRangeMarker.range(range.from, range.to)), + true, + ) + : RangeSet.empty, frameRange: null, fresh: true, generation: statementBoundariesGeneration, }; } + function ensureStatementCache(state: EditorState): Promise { + if (pendingAnalysis?.doc === state.doc && pendingAnalysis.generation === statementBoundariesGeneration) return pendingAnalysis.result; + const generation = statementBoundariesGeneration; + const result = analysisWorker.analyze({ sql: state.doc.toString(), databaseType: props.databaseType, parameterOptions: sqlStatementParameterOptions(), includeFolds: true, syntaxDialect: props.syntaxDialect ?? props.dialect, driverProfile: options.driverProfile?.() }).then((analysis) => { + const currentView = view.value ?? trackedView; + if (!analysis || generation !== statementBoundariesGeneration || currentView?.state.doc !== state.doc) return null; + const resolved = { ...analysis, doc: state.doc }; + installStatementBoundaries(state, resolved, analysis.folds); + const refreshEffect = codeMirrorRuntime.statementBoundariesRefreshEffect; + if (refreshEffect) currentView.dispatch({ effects: refreshEffect.of(null) }); + return resolved; + }); + pendingAnalysis = { doc: state.doc, generation, result }; + return result; + } + + function refreshStatementBoundaries(state: EditorState) { + if (shouldUseQueryEditorLargeDocumentModeForSize(state.doc.length, state.doc.lines)) { + void ensureStatementCache(state); + } else { + installStatementBoundaries(state, executableStatementRangeCacheForDoc(cache.value, state.doc, props.databaseType, sqlStatementParameterOptions())); + } + } + interface StatementBoundariesView { doc: import("@codemirror/state").Text; startsIndex: StatementGutterStartIndex; + folds: RangeSet; frameRange: { from: number; to: number } | null; fresh: boolean; generation: number; @@ -60,6 +121,7 @@ export function useQueryEditorStatementBoundaries(options: QueryEditorStatementB // reference current through ChangeSet mapping, so this stays cheap. function statementBoundariesForState(state: import("@codemirror/state").EditorState): StatementBoundariesView { if (statementBoundariesView && statementBoundariesView.doc === state.doc && statementBoundariesView.generation === statementBoundariesGeneration) return statementBoundariesView; + statementBoundariesView = { doc: state.doc, startsIndex: { starts: RangeSet.empty, executableLineStarts: RangeSet.empty }, folds: RangeSet.empty, frameRange: null, fresh: false, generation: statementBoundariesGeneration }; refreshStatementBoundaries(state); return statementBoundariesView!; } @@ -82,8 +144,14 @@ export function useQueryEditorStatementBoundaries(options: QueryEditorStatementB function createTrackingPlugin(ViewPlugin: typeof ViewPluginType) { const statementBoundariesTrackingPlugin = ViewPlugin.fromClass( class { + constructor(currentView: EditorViewType) { + trackedView = currentView; + } + update(update: import("@codemirror/view").ViewUpdate) { if (!update.docChanged) return; + analysisWorker.cancel(); + pendingAnalysis = null; const boundaries = statementBoundariesView; // No consumer (run gutter off + statement frame off) ever initialized // the view — nothing to maintain and no refresh to schedule. @@ -92,6 +160,7 @@ export function useQueryEditorStatementBoundaries(options: QueryEditorStatementB statementBoundariesView = { doc: update.state.doc, startsIndex: mapStatementGutterStartIndex(boundaries.startsIndex, update.changes), + folds: boundaries.folds.map(update.changes), frameRange: boundaries.frameRange ? { from: update.changes.mapPos(boundaries.frameRange.from, 1), @@ -106,19 +175,32 @@ export function useQueryEditorStatementBoundaries(options: QueryEditorStatementB } destroy() { - if (statementBoundariesRefreshTimer !== null) { - clearTimeout(statementBoundariesRefreshTimer); - statementBoundariesRefreshTimer = null; - } + clearStatementBoundaries(); + trackedView = null; } }, ); return statementBoundariesTrackingPlugin; } return { + foldRangeForState(state: EditorState, lineStart: number): FoldRange | null | undefined { + if (!shouldUseQueryEditorLargeDocumentModeForSize(state.doc.length, state.doc.lines)) return undefined; + const lineEnd = state.doc.lineAt(lineStart).to; + let range: FoldRange | null = null; + statementBoundariesForState(state).folds.between(lineEnd, lineEnd, (from, to) => { + if (from !== lineEnd) return; + range = { from, to }; + return false; + }); + return range; + }, statementBoundariesForState, createTrackingPlugin, + ensureStatementCache, + clear: clearStatementBoundaries, invalidate() { + analysisWorker.cancel(); + pendingAnalysis = null; statementBoundariesGeneration += 1; }, }; diff --git a/apps/desktop/src/lib/__tests__/editor/queryEditorLargeDocument.spec.ts b/apps/desktop/src/lib/__tests__/editor/queryEditorLargeDocument.spec.ts index 8866a7f526..1541afeb1b 100644 --- a/apps/desktop/src/lib/__tests__/editor/queryEditorLargeDocument.spec.ts +++ b/apps/desktop/src/lib/__tests__/editor/queryEditorLargeDocument.spec.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { QUERY_EDITOR_FULL_FEATURE_MAX_DOCUMENT_LENGTH, shouldUseQueryEditorLargeDocumentMode } from "@/lib/editor/queryEditorLargeDocument"; +import { QUERY_EDITOR_FULL_FEATURE_MAX_DOCUMENT_LENGTH, QUERY_EDITOR_FULL_FEATURE_MAX_LINE_COUNT, shouldUseQueryEditorLargeDocumentMode, shouldUseQueryEditorLargeDocumentModeForSize } from "@/lib/editor/queryEditorLargeDocument"; describe("query editor large document policy", () => { it("keeps the default feature set through the configured budget", () => { @@ -9,4 +9,20 @@ describe("query editor large document policy", () => { it("switches to bounded features above the configured budget", () => { expect(shouldUseQueryEditorLargeDocumentMode("x".repeat(QUERY_EDITOR_FULL_FEATURE_MAX_DOCUMENT_LENGTH + 1))).toBe(true); }); + + it("keeps full features through the configured line budget", () => { + const document = Array.from({ length: QUERY_EDITOR_FULL_FEATURE_MAX_LINE_COUNT }, () => "SELECT 1;").join("\n"); + + expect(shouldUseQueryEditorLargeDocumentMode(document)).toBe(false); + expect(shouldUseQueryEditorLargeDocumentModeForSize(document.length, QUERY_EDITOR_FULL_FEATURE_MAX_LINE_COUNT)).toBe(false); + }); + + it("switches to bounded features above the configured line budget", () => { + const lineCount = QUERY_EDITOR_FULL_FEATURE_MAX_LINE_COUNT + 1; + const document = Array.from({ length: lineCount }, () => "SELECT 1;").join("\n"); + + expect(document.length).toBeLessThan(QUERY_EDITOR_FULL_FEATURE_MAX_DOCUMENT_LENGTH); + expect(shouldUseQueryEditorLargeDocumentMode(document)).toBe(true); + expect(shouldUseQueryEditorLargeDocumentModeForSize(document.length, lineCount)).toBe(true); + }); }); diff --git a/apps/desktop/src/lib/__tests__/sql/sqlAnalysisWorker.spec.ts b/apps/desktop/src/lib/__tests__/sql/sqlAnalysisWorker.spec.ts new file mode 100644 index 0000000000..c7da5ecec8 --- /dev/null +++ b/apps/desktop/src/lib/__tests__/sql/sqlAnalysisWorker.spec.ts @@ -0,0 +1,124 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { createSqlAnalysisWorker } from "@/lib/sql/sqlAnalysisWorker"; + +class ControlledWorker { + onmessage: Worker["onmessage"] = null; + onerror: Worker["onerror"] = null; + onmessageerror: Worker["onmessageerror"] = null; + postMessage = vi.fn(); + terminate = vi.fn(); + + reply(id: number, result: string) { + this.onmessage?.call(this as unknown as Worker, { data: { id, result } } as MessageEvent); + } + + fail() { + this.onerror?.call(this as unknown as Worker, {} as ErrorEvent); + } +} + +afterEach(() => vi.unstubAllGlobals()); + +function harness() { + vi.stubGlobal("Worker", ControlledWorker); + const worker = new ControlledWorker(); + const create = vi.fn(() => worker as unknown as Worker); + const fallback = vi.fn((input: string) => `fallback:${input}`); + const service = createSqlAnalysisWorker(create, fallback); + return { worker, create, fallback, service }; +} + +describe("SQL background analysis scheduling", () => { + it("does not run analysis on the UI thread and reuses the worker", async () => { + const { worker, create, fallback, service } = harness(); + const first = service.analyze("first"); + expect(fallback).not.toHaveBeenCalled(); + worker.reply(1, "first result"); + await expect(first).resolves.toBe("first result"); + const second = service.analyze("second"); + worker.reply(2, "second result"); + await expect(second).resolves.toBe("second result"); + expect(create).toHaveBeenCalledTimes(1); + service.dispose(); + expect(worker.terminate).toHaveBeenCalledTimes(1); + }); + + it("keeps at most the running request and the newest queued edit", async () => { + const { worker, fallback, service } = harness(); + const first = service.analyze("first"); + const second = service.analyze("second"); + const third = service.analyze("third"); + await expect(first).resolves.toBeNull(); + await expect(second).resolves.toBeNull(); + expect(worker.postMessage).toHaveBeenCalledTimes(1); + worker.reply(1, "obsolete"); + expect(worker.postMessage).toHaveBeenLastCalledWith({ id: 3, input: "third" }); + worker.reply(2, "out of order"); + worker.reply(3, "current"); + await expect(third).resolves.toBe("current"); + expect(fallback).not.toHaveBeenCalled(); + service.dispose(); + }); + + it("settles both requests when cancelled without losing the warm worker", async () => { + const { worker, create, service } = harness(); + const first = service.analyze("first"); + const second = service.analyze("second"); + service.cancel(); + await expect(first).resolves.toBeNull(); + await expect(second).resolves.toBeNull(); + worker.reply(1, "obsolete"); + expect(worker.postMessage).toHaveBeenCalledTimes(1); + const next = service.analyze("next"); + worker.reply(3, "next result"); + await expect(next).resolves.toBe("next result"); + expect(create).toHaveBeenCalledTimes(1); + service.dispose(); + }); + + it("ignores late events after disposal", async () => { + const { worker, service, fallback } = harness(); + const pending = service.analyze("pending"); + service.dispose(); + worker.reply(1, "late"); + worker.fail(); + await expect(pending).resolves.toBeNull(); + expect(fallback).not.toHaveBeenCalled(); + }); + + it("falls back only for the newest request when a worker fails", async () => { + const { worker, service, fallback } = harness(); + const first = service.analyze("first"); + const latest = service.analyze("latest"); + worker.fail(); + await expect(first).resolves.toBeNull(); + await expect(latest).resolves.toBe("fallback:latest"); + expect(fallback.mock.calls).toEqual([["latest"]]); + await expect(service.analyze("next")).resolves.toBe("fallback:next"); + }); + + it("preserves functionality when worker construction or message cloning fails", async () => { + const { worker, service, fallback } = harness(); + worker.postMessage.mockImplementation(() => { + throw new Error("clone failed"); + }); + await expect(service.analyze("clone")).resolves.toBe("fallback:clone"); + const unavailable = createSqlAnalysisWorker(() => { + throw new Error("blocked by CSP"); + }, fallback); + await expect(unavailable.analyze("CSP")).resolves.toBe("fallback:CSP"); + }); + + it("uses the caller's editor-aware fallback and handles its exceptions", async () => { + vi.stubGlobal("Worker", undefined); + const create = vi.fn(); + const service = createSqlAnalysisWorker(create, () => "default"); + await expect(service.analyze("SQL", () => "editor state")).resolves.toBe("editor state"); + await expect( + service.analyze("invalid", () => { + throw new Error("invalid"); + }), + ).resolves.toBeNull(); + expect(create).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/desktop/src/lib/__tests__/sql/sqlEditorAnalysis.spec.ts b/apps/desktop/src/lib/__tests__/sql/sqlEditorAnalysis.spec.ts new file mode 100644 index 0000000000..9a070129f3 --- /dev/null +++ b/apps/desktop/src/lib/__tests__/sql/sqlEditorAnalysis.spec.ts @@ -0,0 +1,67 @@ +import { EditorState } from "@codemirror/state"; +import * as langSql from "@codemirror/lang-sql"; +import { ensureSyntaxTree } from "@codemirror/language"; +import { describe, expect, it } from "vitest"; +import { createDbxCodeMirrorSqlDialect } from "@/lib/editor/codemirrorSqlDialect"; +import { analyzeSqlCompletion, SqlCompletionAnalysisEngine, type SqlCompletionAnalysisRequest } from "@/lib/sql/sqlCompletionAnalysis"; +import { analyzeSqlStatements } from "@/lib/sql/sqlStatementAnalysis"; +import { executableStatementRangeCacheForDoc } from "@/lib/sql/executableStatementRangeCache"; +import { analyzeSqlDiagnostics } from "@/lib/sql/sqlDiagnosticAnalysis"; +import { sqlSemanticDiagnosticRangesForViewport, sqlServerRoutineDefinitionRangesForViewport } from "@/lib/sql/semantic/diagnostics"; +import type { DatabaseType } from "@/types/database"; +import { computeBlockFoldRanges } from "@/lib/editor/codemirrorSqlBlockFolding"; + +describe("background SQL completion parity", () => { + it.each([ + ["mysql", "WITH recent(id) AS (SELECT id FROM orders) SELECT recent. FROM recent"], + ["postgres", "SELECT nextval('orders_id_seq')"], + ["oracle", "SELECT * FROM orders@remote"], + ["sqlserver", "USE reporting; SELECT * FROM dbo.orders WHERE "], + ["mysql", "SELECT 'literal; still literal' -- comment"], + ] as const)("preserves %s context and trigger policy", (databaseType, sql) => { + const dialect = databaseType === "oracle" ? "mysql" : databaseType; + const request: SqlCompletionAnalysisRequest = { sql, cursor: sql.indexOf("recent.") >= 0 ? sql.indexOf("recent.") + 7 : sql.length, databaseType, dialect, semanticCompletionEnabled: true }; + const state = EditorState.create({ doc: sql, extensions: [langSql.sql({ dialect: createDbxCodeMirrorSqlDialect(langSql, dialect, databaseType) })] }); + ensureSyntaxTree(state, sql.length, 1000); + const expected = analyzeSqlCompletion(request, state); + expect(new SqlCompletionAnalysisEngine().analyze(request)).toEqual(expected); + expect(structuredClone(expected)).toEqual(expected); + }); + + it("preserves distant CTE columns in an oversized statement", () => { + const sql = `WITH recent(id, total) AS (SELECT id, total FROM orders WHERE 1=1\n${"AND 1=1\n".repeat(5_000)}) SELECT recent. FROM recent`; + const engine = new SqlCompletionAnalysisEngine(); + const request: SqlCompletionAnalysisRequest = { sql, cursor: sql.lastIndexOf("recent.") + 7, databaseType: "mysql", dialect: "mysql", semanticCompletionEnabled: true }; + const result = engine.analyze(request); + expect(result.completionContext.qualifier).toBe("recent"); + expect(result.cteDefinitions).toContainEqual({ name: "recent", columns: ["id", "total"] }); + const edited = { ...request, sql: sql.slice(0, request.cursor) + "i" + sql.slice(request.cursor), cursor: request.cursor + 1 }; + expect(engine.analyze(edited)).toEqual(new SqlCompletionAnalysisEngine().analyze(edited)); + const replaced = { ...request, sql: "SELECT o. FROM orders o", cursor: 9 }; + expect(engine.analyze(replaced)).toEqual(new SqlCompletionAnalysisEngine().analyze(replaced)); + }); +}); + +describe("background statement and diagnostic parity", () => { + it.each([ + ["postgres", "DO $body$ BEGIN PERFORM 1; PERFORM 2; END $body$;\nSELECT 3;"], + ["mysql", "DELIMITER $$\nCREATE PROCEDURE p() BEGIN SELECT 1; SELECT 2; END$$\nDELIMITER ;\nSELECT 3;"], + ["oracle", "BEGIN\n NULL;\n NULL;\nEND;\n/\nSELECT 3 FROM dual;"], + ["sqlserver", "CREATE PROCEDURE p AS\nBEGIN\n SELECT 1;\n SELECT 2;\nEND\nGO\nSELECT 3;"], + ["mysql", "/*+ optimizer hint */ SELECT #{value};\nSELECT 'escaped;literal';"], + ] as const)("keeps exact %s execution boundaries and viewport diagnostics", (databaseType, sql) => { + const request = { sql, databaseType: databaseType as DatabaseType, parameterOptions: { enabledSyntaxes: ["mybatis" as const] } }; + const { doc: _doc, ...expected } = executableStatementRangeCacheForDoc(null, EditorState.create({ doc: sql }).doc, databaseType, request.parameterOptions); + const result = structuredClone(analyzeSqlStatements(request)); + expect(result).toEqual(expected); + const dialect = databaseType === "oracle" ? "mysql" : databaseType; + const state = EditorState.create({ doc: sql, extensions: [langSql.sql({ dialect: createDbxCodeMirrorSqlDialect(langSql, dialect, databaseType) })] }); + ensureSyntaxTree(state, sql.length, 1000); + expect(analyzeSqlStatements({ ...request, includeFolds: true, syntaxDialect: dialect }).folds).toEqual(computeBlockFoldRanges(state, databaseType)); + const visibleRanges = [{ from: 0, to: sql.length }]; + const diagnostics = analyzeSqlDiagnostics({ ...request, cursor: 0, visibleRanges }).document!; + expect(diagnostics.shouldRun).toBe(true); + expect(diagnostics.diagnosticRanges).toEqual(sqlSemanticDiagnosticRangesForViewport(sql, visibleRanges, databaseType, undefined, request.parameterOptions)); + expect(diagnostics.sqlServerRoutineRanges).toEqual(databaseType === "sqlserver" ? sqlServerRoutineDefinitionRangesForViewport(sql, visibleRanges) : []); + }); +}); diff --git a/apps/desktop/src/lib/editor/__tests__/codemirrorSqlBlockFolding.spec.ts b/apps/desktop/src/lib/editor/__tests__/codemirrorSqlBlockFolding.spec.ts index 61dcb2f9a0..21e6c93c21 100644 --- a/apps/desktop/src/lib/editor/__tests__/codemirrorSqlBlockFolding.spec.ts +++ b/apps/desktop/src/lib/editor/__tests__/codemirrorSqlBlockFolding.spec.ts @@ -1,7 +1,7 @@ import * as langSql from "@codemirror/lang-sql"; -import { ensureSyntaxTree, foldable } from "@codemirror/language"; +import { ensureSyntaxTree, foldable, foldService } from "@codemirror/language"; import { Compartment, EditorState } from "@codemirror/state"; -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; import { createDbxCodeMirrorSqlDialect } from "@/lib/editor/codemirrorSqlDialect"; import { collectUnionBranchFoldRanges, createSqlBlockFoldService, sqlBlockFoldService } from "@/lib/editor/codemirrorSqlBlockFolding"; @@ -21,6 +21,20 @@ function foldedTextAtLine(state: EditorState, lineNumber: number): string | null } describe("sqlBlockFoldService", () => { + it("does not flatten SQL documents for REST request detection", () => { + const state = stateFor("BEGIN\n SELECT 1;\nEND", "mysql", createSqlBlockFoldService("mysql")); + const flatten = vi.spyOn(state.doc, "toString"); + expect(foldedTextAtLine(state, 1)).toBe("\n SELECT 1;\n"); + expect(flatten).not.toHaveBeenCalled(); + }); + + it("uses background ranges without falling back to a synchronous scan", () => { + const cached = vi.fn(() => null); + const state = stateFor("BEGIN\n SELECT 1;\nEND", "mysql", createSqlBlockFoldService("mysql", cached)); + const service = state.facet(foldService)[0]; + expect(service(state, 0, state.doc.line(1).to)).toBeNull(); + expect(cached).toHaveBeenCalled(); + }); it.each(["elasticsearch", "easysearch", "meilisearch"] as const)("folds one %s REST request without a semicolon", (databaseType) => { const sql = `POST /orders/_search { diff --git a/apps/desktop/src/lib/editor/codemirrorSqlBlockFolding.ts b/apps/desktop/src/lib/editor/codemirrorSqlBlockFolding.ts index 567db8facd..009dde5108 100644 --- a/apps/desktop/src/lib/editor/codemirrorSqlBlockFolding.ts +++ b/apps/desktop/src/lib/editor/codemirrorSqlBlockFolding.ts @@ -1,14 +1,14 @@ import { foldService, syntaxTree } from "@codemirror/language"; import type { EditorState } from "@codemirror/state"; import { elasticsearchRestRequestRanges } from "@/lib/sql/sqlStatementRanges"; -import type { DatabaseType } from "@/types/database"; +import { isElasticsearchCompatibleDatabaseType, isMeilisearchDatabaseType, isSolrDatabaseType, type DatabaseType } from "@/types/database"; // `@lezer/common` is only a transitive dependency here (see sqlSyntaxTreeWindow.ts's comment on // the same pattern), so derive the node types structurally instead of importing them. type SyntaxNode = ReturnType["resolve"]>; type Tree = ReturnType; -interface FoldRange { +export interface FoldRange { from: number; to: number; } @@ -151,6 +151,7 @@ function addQueryStructureFoldRanges(state: EditorState, tree: Tree, ranges: Map } function addRestRequestFoldRanges(state: EditorState, ranges: Map, databaseType?: DatabaseType): boolean { + if (databaseType && !isElasticsearchCompatibleDatabaseType(databaseType) && !isMeilisearchDatabaseType(databaseType) && !isSolrDatabaseType(databaseType)) return false; const requests = elasticsearchRestRequestRanges(state.doc.toString(), databaseType ?? "elasticsearch"); if (requests.length === 0) return false; @@ -160,8 +161,7 @@ function addRestRequestFoldRanges(state: EditorState, ranges: Map { - const tree = syntaxTree(state); +export function computeBlockFoldRanges(state: EditorState, databaseType?: DatabaseType, tree: Tree = syntaxTree(state)): Map { const cacheByDatabaseType = rangeCache.get(tree); const cacheKey = databaseType ?? "auto-detect"; const cached = cacheByDatabaseType?.get(cacheKey); @@ -220,9 +220,11 @@ function computeBlockFoldRanges(state: EditorState, databaseType?: DatabaseType) } /** Creates folding for REST requests, procedural blocks, query parentheses, and UNION branches. */ -export function createSqlBlockFoldService(databaseType?: DatabaseType) { +export function createSqlBlockFoldService(databaseType?: DatabaseType | (() => DatabaseType | undefined), cachedRange?: (state: EditorState, lineStart: number) => FoldRange | null | undefined) { return foldService.of((state, lineStart) => { - const range = computeBlockFoldRanges(state, databaseType).get(state.doc.lineAt(lineStart).number); + const cached = cachedRange?.(state, lineStart); + if (cached !== undefined) return cached; + const range = computeBlockFoldRanges(state, typeof databaseType === "function" ? databaseType() : databaseType).get(state.doc.lineAt(lineStart).number); return range ?? null; }); } diff --git a/apps/desktop/src/lib/editor/queryEditorLargeDocument.ts b/apps/desktop/src/lib/editor/queryEditorLargeDocument.ts index ed8a03a18d..93597e8b1d 100644 --- a/apps/desktop/src/lib/editor/queryEditorLargeDocument.ts +++ b/apps/desktop/src/lib/editor/queryEditorLargeDocument.ts @@ -1,5 +1,16 @@ export const QUERY_EDITOR_FULL_FEATURE_MAX_DOCUMENT_LENGTH = 1024 * 1024; +export const QUERY_EDITOR_FULL_FEATURE_MAX_LINE_COUNT = 2_000; + +export function shouldUseQueryEditorLargeDocumentModeForSize(documentLength: number, lineCount: number): boolean { + return documentLength > QUERY_EDITOR_FULL_FEATURE_MAX_DOCUMENT_LENGTH || lineCount > QUERY_EDITOR_FULL_FEATURE_MAX_LINE_COUNT; +} export function shouldUseQueryEditorLargeDocumentMode(document: string): boolean { - return document.length > QUERY_EDITOR_FULL_FEATURE_MAX_DOCUMENT_LENGTH; + if (document.length > QUERY_EDITOR_FULL_FEATURE_MAX_DOCUMENT_LENGTH) return true; + let lineCount = 1; + for (let offset = document.indexOf("\n"); offset >= 0; offset = document.indexOf("\n", offset + 1)) { + lineCount += 1; + if (lineCount > QUERY_EDITOR_FULL_FEATURE_MAX_LINE_COUNT) return true; + } + return false; } diff --git a/apps/desktop/src/lib/sql/executableStatementRangeCache.ts b/apps/desktop/src/lib/sql/executableStatementRangeCache.ts index e17d1f8168..fe08fe7bc4 100644 --- a/apps/desktop/src/lib/sql/executableStatementRangeCache.ts +++ b/apps/desktop/src/lib/sql/executableStatementRangeCache.ts @@ -1,4 +1,4 @@ -import type { ChangeSet, Text } from "@codemirror/state"; +import { RangeSet, RangeValue, type ChangeSet, type Text } from "@codemirror/state"; import type { DatabaseType } from "@/types/database"; import { readSqlBracedParameterAt, type SqlParameterOptions } from "@/lib/sql/sqlParameters"; import { executableStatementRanges, type SqlTextRange } from "@/lib/sql/sqlStatementRanges"; @@ -22,26 +22,42 @@ export interface ExecutableStatementRangeCache { * the whole document synchronously per keystroke. */ export interface StatementGutterStartIndex { - starts: ReadonlySet; - executableLineStarts: ReadonlySet; + starts: RangeSet; + executableLineStarts: RangeSet; +} + +class StatementGutterStartMarker extends RangeValue { + startSide = 1; + endSide = 1; +} + +const statementGutterStartMarker = new StatementGutterStartMarker(); + +function statementGutterStartRangeSet(positions: Iterable): RangeSet { + return RangeSet.of( + Array.from(new Set(positions), (position) => statementGutterStartMarker.range(position)), + true, + ); } export function statementGutterStartIndexForCache(cache: ExecutableStatementRangeCache): StatementGutterStartIndex { - return { starts: new Set(cache.byStart.keys()), executableLineStarts: new Set(cache.byExecutableLineStart.keys()) }; + return { + starts: statementGutterStartRangeSet(cache.byStart.keys()), + executableLineStarts: statementGutterStartRangeSet(cache.byExecutableLineStart.keys()), + }; } export function mapStatementGutterStartIndex(index: StatementGutterStartIndex, changes: ChangeSet): StatementGutterStartIndex { - return { starts: mapStartPositions(index.starts, changes), executableLineStarts: mapStartPositions(index.executableLineStarts, changes) }; -} - -function mapStartPositions(positions: ReadonlySet, changes: ChangeSet): Set { - const mapped = new Set(); - for (const position of positions) mapped.add(changes.mapPos(position, 1)); - return mapped; + return { starts: index.starts.map(changes), executableLineStarts: index.executableLineStarts.map(changes) }; } export function statementGutterStartIndexHasStartAt(index: StatementGutterStartIndex, lineFrom: number): boolean { - return index.starts.has(lineFrom) || index.executableLineStarts.has(lineFrom); + return rangeSetHasPointAt(index.starts, lineFrom) || rangeSetHasPointAt(index.executableLineStarts, lineFrom); +} + +function rangeSetHasPointAt(ranges: RangeSet, position: number): boolean { + const cursor = ranges.iter(position); + return cursor.value !== null && cursor.from === position; } export type ExecutableStatementRangeParser = (sql: string, databaseType?: DatabaseType, parameterOptions?: SqlParameterOptions) => SqlTextRange[]; diff --git a/apps/desktop/src/lib/sql/sqlAnalysisWorker.ts b/apps/desktop/src/lib/sql/sqlAnalysisWorker.ts new file mode 100644 index 0000000000..6eeec887c4 --- /dev/null +++ b/apps/desktop/src/lib/sql/sqlAnalysisWorker.ts @@ -0,0 +1,98 @@ +export interface SqlAnalysisWorker { + analyze: (input: Input, fallback?: () => Result) => Promise; + cancel: () => void; + dispose: () => void; +} + +export function createSqlAnalysisWorker(createWorker: () => Worker, analyzeLocally: (input: Input) => Result): SqlAnalysisWorker { + interface Request { + id: number; + input: Input; + fallback: () => Result; + resolve: (result: Result | null) => void; + cancelled: boolean; + } + + let worker: Worker | null = null; + let unavailable = typeof Worker === "undefined"; + let nextId = 0; + let active: Request | null = null; + let queued: Request | null = null; + + function fallback(request: Request) { + if (request.cancelled) return; + try { + request.resolve(request.fallback()); + } catch { + request.resolve(null); + } + } + + function finish(result: Result | null) { + active?.resolve(result); + active = null; + const next = queued; + queued = null; + if (next) start(next); + } + + function fail() { + worker?.terminate(); + worker = null; + unavailable = true; + if (active && !queued) fallback(active); + finish(null); + } + + function start(request: Request) { + if (unavailable) { + fallback(request); + return; + } + active = request; + try { + if (!worker) { + const currentWorker = createWorker(); + worker = currentWorker; + worker.onmessage = (event: MessageEvent<{ id: number; result?: Result; error?: string }>) => { + if (worker !== currentWorker || event.data.id !== active?.id) return; + if (event.data.error && !queued) fallback(active); + finish(event.data.result ?? null); + }; + worker.onerror = worker.onmessageerror = () => { + if (worker === currentWorker) fail(); + }; + } + worker.postMessage({ id: request.id, input: request.input }); + } catch { + fail(); + } + } + + function cancel() { + if (active) { + active.cancelled = true; + active.resolve(null); + } + queued?.resolve(null); + queued = null; + } + + return { + analyze(input, localFallback = () => analyzeLocally(input)) { + cancel(); + return new Promise((resolve) => { + const request = { id: ++nextId, input, fallback: localFallback, resolve, cancelled: false }; + if (active) queued = request; + else start(request); + }); + }, + cancel, + dispose() { + cancel(); + worker?.terminate(); + worker = null; + active = null; + }, + }; +} diff --git a/apps/desktop/src/lib/sql/sqlCompletion.ts b/apps/desktop/src/lib/sql/sqlCompletion.ts index 06088cbf05..110013a721 100644 --- a/apps/desktop/src/lib/sql/sqlCompletion.ts +++ b/apps/desktop/src/lib/sql/sqlCompletion.ts @@ -1816,7 +1816,7 @@ class SqlCompletionProvider { } } -export function shouldAutoOpenSqlCompletion(sql: string, cursor: number, options: SqlSemanticBuildOptions = {}): boolean { +export function shouldAutoOpenSqlCompletion(sql: string, cursor: number, options: SqlSemanticBuildOptions = {}, precomputedContext?: SqlCompletionContext): boolean { if (getPostgresSequenceLiteralCompletionContext(sql, cursor, options.databaseType)) return true; if (isSqlCompletionSuppressedContext(sql, cursor, options)) return false; const previousChar = sql[cursor - 1]; @@ -1830,7 +1830,7 @@ export function shouldAutoOpenSqlCompletion(sql: string, cursor: number, options if (/\bon\s+$/i.test(beforeCursor)) return true; if (isAfterJoinModifierContext(beforeCursor, options.databaseType)) return true; if (/\bcall\s+(?:[A-Za-z_][\w$]*\.)?$/i.test(beforeCursor)) return true; - const context = getSqlCompletionContext(sql, cursor, options); + const context = precomputedContext ?? getSqlCompletionContext(sql, cursor, options); if (previousChar === "(" && (context.insertTable || context.preferredValueKeywords?.length)) return true; if (/[,;()[\]]/.test(previousChar)) return false; if (context.exclusiveTableSuggestions || context.exclusiveRoutineSuggestions || context.suggestTables) { diff --git a/apps/desktop/src/lib/sql/sqlCompletionAnalysis.ts b/apps/desktop/src/lib/sql/sqlCompletionAnalysis.ts new file mode 100644 index 0000000000..1377450212 --- /dev/null +++ b/apps/desktop/src/lib/sql/sqlCompletionAnalysis.ts @@ -0,0 +1,96 @@ +import { ensureSyntaxTree, syntaxTree } from "@codemirror/language"; +import { EditorState } from "@codemirror/state"; +import * as langSql from "@codemirror/lang-sql"; +import { createDbxCodeMirrorSqlDialect, type CodeMirrorSqlDialectName } from "@/lib/editor/codemirrorSqlDialect"; +import { extractCteDefinitions, getSqlCompletionContext, getPostgresSequenceLiteralCompletionContext, isSqlCompletionSuppressedContext, shouldAutoOpenSqlCompletion, type SqlCompletionContext } from "@/lib/sql/sqlCompletion"; +import { oracleDatabaseLinkCompletionContext } from "./oracleDatabaseLinkCompletion"; +import { resolveSqlServerUseDatabaseCompletion } from "./sqlCompletionLookupTarget"; +import { sqlCompletionContextFromSemantic } from "@/lib/sql/semantic/completion"; +import { buildSqlSemanticModel } from "@/lib/sql/semantic/model"; +import type { DatabaseType } from "@/types/database"; +import type { SqlSemanticBuildOptions } from "./semantic/types"; + +export interface SqlCompletionAnalysisRequest { + sql: string; + cursor: number; + databaseType?: DatabaseType; + dialect?: SqlSemanticBuildOptions["dialect"]; + semanticCompletionEnabled: boolean; + driverProfile?: string; + syntaxDialect?: CodeMirrorSqlDialectName; +} + +export interface SqlCompletionAnalysisResult { + completionContext: SqlCompletionContext; + positionalEligible: boolean; + suppressed: boolean; + sequenceLiteralContext: ReturnType; + databaseLinkContext: ReturnType; + useDatabaseCompletion: ReturnType; + cteDefinitions: ReturnType; +} + +function minimalDocumentChange(previous: string, next: string): { from: number; to: number; insert: string } | null { + if (previous === next) return null; + const sharedLimit = Math.min(previous.length, next.length); + let from = 0; + while (from < sharedLimit && previous.charCodeAt(from) === next.charCodeAt(from)) from += 1; + + let previousTo = previous.length; + let nextTo = next.length; + while (previousTo > from && nextTo > from && previous.charCodeAt(previousTo - 1) === next.charCodeAt(nextTo - 1)) { + previousTo -= 1; + nextTo -= 1; + } + return { from, to: previousTo, insert: next.slice(from, nextTo) }; +} + +export class SqlCompletionAnalysisEngine { + private state: EditorState | null = null; + private document = ""; + private dialectKey = ""; + + analyze(request: SqlCompletionAnalysisRequest): SqlCompletionAnalysisResult { + return analyzeSqlCompletion(request, this.editorStateFor(request)); + } + + private editorStateFor(request: SqlCompletionAnalysisRequest): EditorState { + const dialectName = request.syntaxDialect ?? (request.dialect === "doris" ? "mysql" : request.dialect) ?? "mysql"; + const dialectKey = `${request.databaseType ?? ""}:${dialectName}:${request.driverProfile ?? ""}`; + if (!this.state || this.dialectKey !== dialectKey) { + this.state = EditorState.create({ + doc: request.sql, + extensions: [langSql.sql({ dialect: createDbxCodeMirrorSqlDialect(langSql, dialectName, request.databaseType, request.driverProfile) })], + }); + this.document = request.sql; + this.dialectKey = dialectKey; + } else { + const change = minimalDocumentChange(this.document, request.sql); + if (change) this.state = this.state.update({ changes: change }).state; + this.document = request.sql; + } + let tree = ensureSyntaxTree(this.state, this.state.doc.length, 250); + while (!tree) tree = ensureSyntaxTree(this.state, this.state.doc.length, 250); + if (tree !== syntaxTree(this.state)) this.state = this.state.update({}).state; + return this.state; + } +} + +export function analyzeSqlCompletion(request: SqlCompletionAnalysisRequest, editorState?: EditorState): SqlCompletionAnalysisResult { + const options = { + databaseType: request.databaseType, + dialect: request.dialect, + editorState, + }; + const legacyContext = getSqlCompletionContext(request.sql, request.cursor, options); + const completionContext = request.semanticCompletionEnabled ? sqlCompletionContextFromSemantic(buildSqlSemanticModel(request.sql, request.cursor, options), legacyContext) : legacyContext; + return { + completionContext, + positionalEligible: shouldAutoOpenSqlCompletion(request.sql, request.cursor, options, legacyContext), + suppressed: isSqlCompletionSuppressedContext(request.sql, request.cursor, options), + sequenceLiteralContext: getPostgresSequenceLiteralCompletionContext(request.sql, request.cursor, request.databaseType), + databaseLinkContext: oracleDatabaseLinkCompletionContext(request.sql, request.cursor, request.databaseType), + useDatabaseCompletion: resolveSqlServerUseDatabaseCompletion({ sql: request.sql, cursor: request.cursor, databaseType: request.databaseType }), + cteDefinitions: extractCteDefinitions(request.sql), + }; +} diff --git a/apps/desktop/src/lib/sql/sqlCompletionAnalysis.worker.ts b/apps/desktop/src/lib/sql/sqlCompletionAnalysis.worker.ts new file mode 100644 index 0000000000..99828e708d --- /dev/null +++ b/apps/desktop/src/lib/sql/sqlCompletionAnalysis.worker.ts @@ -0,0 +1,16 @@ +import { SqlCompletionAnalysisEngine, type SqlCompletionAnalysisRequest } from "./sqlCompletionAnalysis"; + +interface SqlCompletionAnalysisWorkerRequest { + id: number; + input: SqlCompletionAnalysisRequest; +} + +const engine = new SqlCompletionAnalysisEngine(); + +self.onmessage = (event: MessageEvent) => { + try { + self.postMessage({ id: event.data.id, result: engine.analyze(event.data.input) }); + } catch (error) { + self.postMessage({ id: event.data.id, error: error instanceof Error ? error.message : String(error) }); + } +}; diff --git a/apps/desktop/src/lib/sql/sqlCompletionAnalysisWorker.ts b/apps/desktop/src/lib/sql/sqlCompletionAnalysisWorker.ts new file mode 100644 index 0000000000..c155fd3a75 --- /dev/null +++ b/apps/desktop/src/lib/sql/sqlCompletionAnalysisWorker.ts @@ -0,0 +1,11 @@ +import type { EditorState } from "@codemirror/state"; +import { analyzeSqlCompletion, type SqlCompletionAnalysisRequest } from "./sqlCompletionAnalysis"; +import { createSqlAnalysisWorker } from "./sqlAnalysisWorker"; + +export function createSqlCompletionAnalysisWorker() { + const worker = createSqlAnalysisWorker(() => new Worker(new URL("./sqlCompletionAnalysis.worker.ts", import.meta.url), { type: "module" }), analyzeSqlCompletion); + return { + ...worker, + analyze: (input: SqlCompletionAnalysisRequest, editorState?: EditorState) => worker.analyze(input, () => analyzeSqlCompletion(input, editorState)), + }; +} diff --git a/apps/desktop/src/lib/sql/sqlDiagnosticAnalysis.ts b/apps/desktop/src/lib/sql/sqlDiagnosticAnalysis.ts new file mode 100644 index 0000000000..b5d9babb2d --- /dev/null +++ b/apps/desktop/src/lib/sql/sqlDiagnosticAnalysis.ts @@ -0,0 +1,48 @@ +import type { DatabaseType, SqlReferenceAnalysis } from "@/types/database"; +import { buildOracleSyntaxDiagnostics } from "./oracleSyntaxDiagnostics"; +import { analyzeMysqlRoutineSyntax, supportsMysqlRoutineSyntaxDiagnostics } from "./mysqlRoutineSyntaxDiagnostics"; +import { buildSqlServerRoutineSyntaxDiagnostics } from "./sqlServerRoutineSyntaxDiagnostics"; +import { isSqlSemanticDiagnosticInputContext, shouldRunSqlSemanticDiagnostics, sqlSemanticDiagnosticRangesForViewport, sqlServerRoutineDefinitionRangesForViewport } from "./semantic/diagnostics"; +import { buildSqlSemanticModel } from "./semantic/model"; +import { mergeSqlSemanticReferenceAnalysis } from "./semantic/references"; +import type { SqlSemanticBuildOptions } from "./semantic/types"; +import type { SqlParameterOptions } from "./sqlParameters"; + +export interface SqlDiagnosticAnalysisRequest { + sql: string; + cursor: number; + databaseType?: DatabaseType; + dialect?: SqlSemanticBuildOptions["dialect"]; + driverProfile?: string; + parameterOptions?: SqlParameterOptions; + visibleRanges: readonly { from: number; to: number }[]; + referenceAnalysis?: SqlReferenceAnalysis; +} + +export function analyzeSqlDiagnosticDocument(request: SqlDiagnosticAnalysisRequest) { + const { sql, cursor, databaseType, visibleRanges, parameterOptions } = request; + const shouldRun = shouldRunSqlSemanticDiagnostics(sql, cursor, { databaseType }); + const diagnosticRanges = shouldRun ? sqlSemanticDiagnosticRangesForViewport(sql, visibleRanges, databaseType, undefined, parameterOptions) : []; + const sqlServerRoutineRanges = shouldRun && databaseType === "sqlserver" ? sqlServerRoutineDefinitionRangesForViewport(sql, visibleRanges) : []; + return { + shouldRun, + inputContext: shouldRun && isSqlSemanticDiagnosticInputContext(sql, cursor, { databaseType }), + diagnosticRanges, + sqlServerRoutineRanges, + oracleSyntaxDiagnostics: shouldRun ? buildOracleSyntaxDiagnostics(sql, databaseType) : [], + mysqlRoutineAnalysis: shouldRun && databaseType === "mysql" && supportsMysqlRoutineSyntaxDiagnostics(request.driverProfile) ? analyzeMysqlRoutineSyntax(sql) : null, + sqlServerRoutineDiagnostics: sqlServerRoutineRanges.map((range) => buildSqlServerRoutineSyntaxDiagnostics(range.sql, databaseType)), + }; +} + +export interface SqlDiagnosticAnalysisResult { + document?: ReturnType; + referenceAnalysis?: SqlReferenceAnalysis; +} + +export function analyzeSqlDiagnostics(request: SqlDiagnosticAnalysisRequest): SqlDiagnosticAnalysisResult { + if (request.referenceAnalysis) { + return { referenceAnalysis: mergeSqlSemanticReferenceAnalysis(request.referenceAnalysis, buildSqlSemanticModel(request.sql, request.cursor, { databaseType: request.databaseType, dialect: request.dialect })) }; + } + return { document: analyzeSqlDiagnosticDocument(request) }; +} diff --git a/apps/desktop/src/lib/sql/sqlDiagnosticAnalysis.worker.ts b/apps/desktop/src/lib/sql/sqlDiagnosticAnalysis.worker.ts new file mode 100644 index 0000000000..911a1f9f8c --- /dev/null +++ b/apps/desktop/src/lib/sql/sqlDiagnosticAnalysis.worker.ts @@ -0,0 +1,9 @@ +import { analyzeSqlDiagnostics, type SqlDiagnosticAnalysisRequest } from "./sqlDiagnosticAnalysis"; + +self.onmessage = (event: MessageEvent<{ id: number; input: SqlDiagnosticAnalysisRequest }>) => { + try { + self.postMessage({ id: event.data.id, result: analyzeSqlDiagnostics(event.data.input) }); + } catch (error) { + self.postMessage({ id: event.data.id, error: error instanceof Error ? error.message : String(error) }); + } +}; diff --git a/apps/desktop/src/lib/sql/sqlDiagnosticAnalysisWorker.ts b/apps/desktop/src/lib/sql/sqlDiagnosticAnalysisWorker.ts new file mode 100644 index 0000000000..f5303ee858 --- /dev/null +++ b/apps/desktop/src/lib/sql/sqlDiagnosticAnalysisWorker.ts @@ -0,0 +1,6 @@ +import { analyzeSqlDiagnostics } from "./sqlDiagnosticAnalysis"; +import { createSqlAnalysisWorker } from "./sqlAnalysisWorker"; + +export function createSqlDiagnosticAnalysisWorker() { + return createSqlAnalysisWorker(() => new Worker(new URL("./sqlDiagnosticAnalysis.worker.ts", import.meta.url), { type: "module" }), analyzeSqlDiagnostics); +} diff --git a/apps/desktop/src/lib/sql/sqlStatementAnalysis.ts b/apps/desktop/src/lib/sql/sqlStatementAnalysis.ts new file mode 100644 index 0000000000..d89042f3ab --- /dev/null +++ b/apps/desktop/src/lib/sql/sqlStatementAnalysis.ts @@ -0,0 +1,30 @@ +import { EditorState, Text } from "@codemirror/state"; +import * as langSql from "@codemirror/lang-sql"; +import { ensureSyntaxTree } from "@codemirror/language"; +import { createDbxCodeMirrorSqlDialect, type CodeMirrorSqlDialectName } from "@/lib/editor/codemirrorSqlDialect"; +import { computeBlockFoldRanges, type FoldRange } from "@/lib/editor/codemirrorSqlBlockFolding"; +import { executableStatementRangeCacheForDoc, type ExecutableStatementRangeCache } from "./executableStatementRangeCache"; +import type { SqlParameterOptions } from "./sqlParameters"; +import type { DatabaseType } from "@/types/database"; + +export interface SqlStatementAnalysisRequest { + sql: string; + databaseType?: DatabaseType; + parameterOptions?: SqlParameterOptions; + includeFolds?: boolean; + syntaxDialect?: CodeMirrorSqlDialectName; + driverProfile?: string; +} + +export type SqlStatementAnalysisResult = Omit & { folds?: Map }; + +export function analyzeSqlStatements(request: SqlStatementAnalysisRequest): SqlStatementAnalysisResult { + const { doc: _doc, ...result } = executableStatementRangeCacheForDoc(null, Text.of(request.sql.split("\n")), request.databaseType, request.parameterOptions); + if (request.includeFolds) { + const state = EditorState.create({ doc: request.sql, extensions: [langSql.sql({ dialect: createDbxCodeMirrorSqlDialect(langSql, request.syntaxDialect ?? "mysql", request.databaseType, request.driverProfile) })] }); + let tree = ensureSyntaxTree(state, state.doc.length, 1000); + while (!tree) tree = ensureSyntaxTree(state, state.doc.length, 1000); + return { ...result, folds: computeBlockFoldRanges(state, request.databaseType, tree) }; + } + return result; +} diff --git a/apps/desktop/src/lib/sql/sqlStatementAnalysis.worker.ts b/apps/desktop/src/lib/sql/sqlStatementAnalysis.worker.ts new file mode 100644 index 0000000000..42be0dbb4a --- /dev/null +++ b/apps/desktop/src/lib/sql/sqlStatementAnalysis.worker.ts @@ -0,0 +1,9 @@ +import { analyzeSqlStatements, type SqlStatementAnalysisRequest } from "./sqlStatementAnalysis"; + +self.onmessage = (event: MessageEvent<{ id: number; input: SqlStatementAnalysisRequest }>) => { + try { + self.postMessage({ id: event.data.id, result: analyzeSqlStatements(event.data.input) }); + } catch (error) { + self.postMessage({ id: event.data.id, error: error instanceof Error ? error.message : String(error) }); + } +}; diff --git a/apps/desktop/src/lib/sql/sqlStatementAnalysisWorker.ts b/apps/desktop/src/lib/sql/sqlStatementAnalysisWorker.ts new file mode 100644 index 0000000000..0129adb34d --- /dev/null +++ b/apps/desktop/src/lib/sql/sqlStatementAnalysisWorker.ts @@ -0,0 +1,6 @@ +import { analyzeSqlStatements } from "./sqlStatementAnalysis"; +import { createSqlAnalysisWorker } from "./sqlAnalysisWorker"; + +export function createSqlStatementAnalysisWorker() { + return createSqlAnalysisWorker(() => new Worker(new URL("./sqlStatementAnalysis.worker.ts", import.meta.url), { type: "module" }), analyzeSqlStatements); +} diff --git a/scripts/bench/README.md b/scripts/bench/README.md index 3058053ad1..ab6c1684eb 100644 --- a/scripts/bench/README.md +++ b/scripts/bench/README.md @@ -1,5 +1,17 @@ # Startup dependency benchmark +## SQL editor typing + +Run `node scripts/bench/editor-typing.mjs`, open the printed local URL, and click +Run benchmark. It mounts the real QueryEditor with its feature extensions enabled +and runs the actual completion and diagnostic Web Workers alongside typing. +It covers 5,000 and 20,000 statements and edits inside a single 5,000-line INSERT. +The JSON report distinguishes synchronous input-dispatch time from animation-frame +gaps, including delayed analysis after typing. This is an offline browser benchmark: +it does not measure database metadata latency or the native desktop WebView. + +## Startup dependencies + Run `pnpm bench:startup` from the repository root. The benchmark uses the current production Vite configuration, checks generated connection descriptors, and builds in memory without replacing `dist` or writing generated source files. diff --git a/scripts/bench/editor-typing.mjs b/scripts/bench/editor-typing.mjs new file mode 100644 index 0000000000..92ec62f831 --- /dev/null +++ b/scripts/bench/editor-typing.mjs @@ -0,0 +1,26 @@ +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { createServer } from "vite"; + +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../.."); +const page = `SQL editor typing benchmark +

SQL editor typing benchmark

Ready
+`; +const server = await createServer({ + configFile: path.join(root, "apps/desktop/vite.config.ts"), + server: { host: "127.0.0.1", port: 5181, strictPort: true, open: false }, + plugins: [ + { + name: "editor-typing-benchmark", + configureServer(vite) { + vite.middlewares.use(async (request, response, next) => { + if (request.url?.split("?")[0] !== "/editor-typing-benchmark.html") return next(); + response.setHeader("Content-Type", "text/html"); + response.end(await vite.transformIndexHtml(request.url, page)); + }); + }, + }, + ], +}); +await server.listen(); +console.log("Open http://127.0.0.1:5181/editor-typing-benchmark.html and click Run benchmark."); diff --git a/scripts/bench/editor-typing.ts b/scripts/bench/editor-typing.ts new file mode 100644 index 0000000000..f34a6167f0 --- /dev/null +++ b/scripts/bench/editor-typing.ts @@ -0,0 +1,106 @@ +import { createApp, h, ref } from "vue"; +import { createPinia, setActivePinia } from "pinia"; +import { createI18n } from "vue-i18n"; +import { EditorView } from "@codemirror/view"; +import QueryEditor from "@/components/editor/QueryEditor.vue"; +import { useSettingsStore } from "@/stores/settingsStore"; +import { createSqlCompletionAnalysisWorker } from "@/lib/sql/sqlCompletionAnalysisWorker"; +import { createSqlDiagnosticAnalysisWorker } from "@/lib/sql/sqlDiagnosticAnalysisWorker"; + +const pause = (duration: number) => new Promise((resolve) => setTimeout(resolve, duration)); +const report = document.querySelector("#report")!; +const host = document.querySelector("#editor")!; +const button = document.querySelector("#run")!; + +function stats(samples: number[]) { + const sorted = samples.toSorted((left, right) => left - right); + return { median: sorted[Math.floor(sorted.length / 2)], p95: sorted[Math.min(sorted.length - 1, Math.floor(sorted.length * 0.95))], max: Math.max(...sorted) }; +} + +button.onclick = async () => { + button.disabled = true; + delete report.dataset.complete; + delete report.dataset.error; + const results = []; + try { + for (const [name, sql] of [ + ["5000 statements", Array.from({ length: 5_000 }, (_, index) => `SELECT ${index} AS value;`).join("\n")], + ["20000 statements", Array.from({ length: 20_000 }, (_, index) => `SELECT ${index} AS value;`).join("\n")], + ["5000-line INSERT", `INSERT INTO orders (id, total) VALUES\n${Array.from({ length: 5_000 }, (_, index) => `(${index}, 10),`).join("\n")}\n(5000, 20);`], + ]) { + report.textContent = `Running ${name}`; + const pinia = createPinia(); + setActivePinia(pinia); + Object.assign(useSettingsStore().editorSettings, { showStatementRunButtons: true, showCurrentStatementFrame: true, showInsertValueHints: true, sqlSemanticDiagnosticsEnabled: true, completionTriggerMode: "positional" }); + const source = ref(sql); + const app = createApp({ + render: () => + h(QueryEditor, { + modelValue: source.value, + databaseType: "mysql", + dialect: "mysql", + autoFocus: false, + "onUpdate:modelValue": (value: string) => { + source.value = value; + }, + }), + }); + app.use(pinia); + app.use(createI18n({ legacy: false, locale: "en", messages: { en: {} }, missingWarn: false, fallbackWarn: false })); + const completion = createSqlCompletionAnalysisWorker(); + const diagnostics = createSqlDiagnosticAnalysisWorker(); + try { + app.mount(host); + for (let attempt = 0; attempt < 200 && !host.querySelector(".cm-editor"); attempt++) await pause(50); + const element = host.querySelector(".cm-editor"); + if (!element) throw new Error("Editor did not mount"); + const view = EditorView.findFromDOM(element)!; + const insideInsert = name.includes("INSERT"); + view.dispatch({ selection: { anchor: view.state.doc.length - (insideInsert ? 2 : 0) }, scrollIntoView: true }); + view.focus(); + await pause(1500); + const typing: number[] = []; + const frameGaps: number[] = []; + let previousFrame = performance.now(); + let frameRequest = 0; + const frame = (now: number) => { + frameGaps.push(now - previousFrame); + previousFrame = now; + frameRequest = requestAnimationFrame(frame); + }; + frameRequest = requestAnimationFrame(frame); + let pendingCompletion = Promise.resolve(null); + let pendingDiagnostics = Promise.resolve(null); + try { + for (const character of insideInsert ? "123456789012345678901234567890123456789" : "\nSELECT id FROM orders WHERE id = 1;\n") { + const from = view.state.selection.main.head; + const start = performance.now(); + view.dispatch({ changes: { from, insert: character }, selection: { anchor: from + 1 }, userEvent: "input.type" }); + typing.push(performance.now() - start); + const fullSql = view.state.doc.toString(); + pendingCompletion = completion.analyze({ sql: fullSql, cursor: from + 1, databaseType: "mysql", dialect: "mysql", semanticCompletionEnabled: true }); + pendingDiagnostics = diagnostics.analyze({ sql: fullSql, cursor: from + 1, databaseType: "mysql", visibleRanges: view.visibleRanges }); + await pause(45); + } + const analysis = await Promise.all([pendingCompletion, pendingDiagnostics]); + if (analysis.some((result) => result === null)) throw new Error("Background analysis failed"); + await pause(600); + } finally { + cancelAnimationFrame(frameRequest); + } + results.push({ name, lines: view.state.doc.lines, characters: view.state.doc.length, typingMs: stats(typing), frameGapMs: stats(frameGaps), samples: typing.length, fullFeatureExtensions: true }); + } finally { + completion.dispose(); + diagnostics.dispose(); + app.unmount(); + } + } + report.textContent = JSON.stringify({ userAgent: navigator.userAgent, results }, null, 2); + report.dataset.complete = "true"; + } catch (error) { + report.textContent = String(error); + report.dataset.error = "true"; + } finally { + button.disabled = false; + } +};