fix(editor): move large SQL analysis off the input path

This commit is contained in:
t8y2
2026-09-28 07:24:23 +08:00
parent 0a6ab8d360
commit adaaec5a3c
30 changed files with 1241 additions and 211 deletions
@@ -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<typeof setTimeout> | 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<HTMLElement>("#root")?.querySelector<HTMLElement>("#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
? '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16" fill="none"><path d="M4.5 6.5l3.5 3.5 3.5-3.5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/></svg>'
: '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16" fill="none"><path d="M6.5 4.5l3.5 3.5-3.5 3.5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/></svg>';
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
? '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16" fill="none"><path d="M4.5 6.5l3.5 3.5 3.5-3.5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/></svg>'
: '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16" fill="none"><path d="M6.5 4.5l3.5 3.5-3.5 3.5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/></svg>';
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() {
@@ -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<typeof actual.executableStatementRangeCacheForDoc>) => {
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<typeof import("@/lib/sql/executableStatementRangeCache")>("@/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");
});
});
@@ -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<QueryEditorProps> = {}) {
function createHarness(overrides: Partial<QueryEditorProps> = {}, configureMetadata?: (metadata: ReturnType<typeof useQueryEditorCompletionMetadata>) => void) {
const props = reactive<QueryEditorProps>({ 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<QueryEditorProps> = {}) {
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<QueryEditorProps> = {}) {
}
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<SqlCompletionAnalysisResult>((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<SqlCompletionAnalysisResult>((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";
@@ -20,7 +20,7 @@ afterEach(() => {
vi.restoreAllMocks();
});
function createHarness() {
function createHarness(doc = "SELECT 1;\nSELECT 2;") {
const view = shallowRef<EditorView | null>(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();
});
});
@@ -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<StatementExecutionMarker[]>();
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<null>();
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);
@@ -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<typeof extractCteDefinitions> } | 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<boolean> {
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<boolean> {
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<typeof getSqlCompletionContext>) {
async function refreshActiveSqlCompletion(fullDoc: string, position: number, completionContext: ReturnType<typeof getSqlCompletionContext>) {
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;
@@ -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<string, SqlCompletionForeignKey[]>();
const loadedColumnsByTable = new Set<string>();
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<SqlCompletionAnalysisResult | null>;
} | null = null;
function getEditorSqlCompletionAnalysis(sql: string, position: number, editorState = view.value?.state): Promise<SqlCompletionAnalysisResult | null> {
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,
@@ -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));
@@ -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<QueryEditorProps>;
@@ -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<ExecutableStatementRangeCache | null> } | null = null;
let statementBoundariesView: {
doc: import("@codemirror/state").Text;
startsIndex: StatementGutterStartIndex;
folds: RangeSet<FoldRangeMarker>;
frameRange: { from: number; to: number } | null;
fresh: boolean;
generation: number;
@@ -35,20 +51,65 @@ export function useQueryEditorStatementBoundaries(options: QueryEditorStatementB
let statementBoundariesRefreshTimer: ReturnType<typeof setTimeout> | 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<number, FoldRange>) {
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<ExecutableStatementRangeCache | null> {
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<FoldRangeMarker>;
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;
},
};
@@ -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);
});
});
@@ -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<string, string>(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();
});
});
@@ -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) : []);
});
});
@@ -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
{
@@ -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<ReturnType<typeof syntaxTree>["resolve"]>;
type Tree = ReturnType<typeof syntaxTree>;
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<number, FoldRange>, 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<number, FoldRa
return true;
}
function computeBlockFoldRanges(state: EditorState, databaseType?: DatabaseType): Map<number, FoldRange> {
const tree = syntaxTree(state);
export function computeBlockFoldRanges(state: EditorState, databaseType?: DatabaseType, tree: Tree = syntaxTree(state)): Map<number, FoldRange> {
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;
});
}
@@ -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;
}
@@ -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<number>;
executableLineStarts: ReadonlySet<number>;
starts: RangeSet<StatementGutterStartMarker>;
executableLineStarts: RangeSet<StatementGutterStartMarker>;
}
class StatementGutterStartMarker extends RangeValue {
startSide = 1;
endSide = 1;
}
const statementGutterStartMarker = new StatementGutterStartMarker();
function statementGutterStartRangeSet(positions: Iterable<number>): RangeSet<StatementGutterStartMarker> {
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<number>, changes: ChangeSet): Set<number> {
const mapped = new Set<number>();
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<StatementGutterStartMarker>, 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[];
@@ -0,0 +1,98 @@
export interface SqlAnalysisWorker<Input, Result> {
analyze: (input: Input, fallback?: () => Result) => Promise<Result | null>;
cancel: () => void;
dispose: () => void;
}
export function createSqlAnalysisWorker<Input, Result>(createWorker: () => Worker, analyzeLocally: (input: Input) => Result): SqlAnalysisWorker<Input, Result> {
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;
},
};
}
+2 -2
View File
@@ -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) {
@@ -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<typeof getPostgresSequenceLiteralCompletionContext>;
databaseLinkContext: ReturnType<typeof oracleDatabaseLinkCompletionContext>;
useDatabaseCompletion: ReturnType<typeof resolveSqlServerUseDatabaseCompletion>;
cteDefinitions: ReturnType<typeof extractCteDefinitions>;
}
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),
};
}
@@ -0,0 +1,16 @@
import { SqlCompletionAnalysisEngine, type SqlCompletionAnalysisRequest } from "./sqlCompletionAnalysis";
interface SqlCompletionAnalysisWorkerRequest {
id: number;
input: SqlCompletionAnalysisRequest;
}
const engine = new SqlCompletionAnalysisEngine();
self.onmessage = (event: MessageEvent<SqlCompletionAnalysisWorkerRequest>) => {
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) });
}
};
@@ -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)),
};
}
@@ -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<typeof analyzeSqlDiagnosticDocument>;
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) };
}
@@ -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) });
}
};
@@ -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);
}
@@ -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<ExecutableStatementRangeCache, "doc"> & { folds?: Map<number, FoldRange> };
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;
}
@@ -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) });
}
};
@@ -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);
}
+12
View File
@@ -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.
+26
View File
@@ -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 = `<!doctype html><html><head><meta charset="utf-8"><title>SQL editor typing benchmark</title></head><body>
<h1>SQL editor typing benchmark</h1><button id="run">Run benchmark</button><pre id="report">Ready</pre><div id="editor" style="height:500px;overflow:hidden"></div>
<script type="module" src="/@fs/${root}/scripts/bench/editor-typing.ts"></script></body></html>`;
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.");
+106
View File
@@ -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<HTMLPreElement>("#report")!;
const host = document.querySelector<HTMLDivElement>("#editor")!;
const button = document.querySelector<HTMLButtonElement>("#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<HTMLElement>(".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<unknown>(null);
let pendingDiagnostics = Promise.resolve<unknown>(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;
}
};