mirror of
https://github.com/t8y2/dbx.git
synced 2026-10-02 02:34:42 +08:00
fix(editor): move large SQL analysis off the input path
This commit is contained in:
@@ -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() {
|
||||
|
||||
+85
-3
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
+37
-1
@@ -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";
|
||||
|
||||
+43
-3
@@ -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;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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.");
|
||||
@@ -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;
|
||||
}
|
||||
};
|
||||
Reference in New Issue
Block a user