diff --git a/apps/desktop/src/components/editor/QueryEditor.vue b/apps/desktop/src/components/editor/QueryEditor.vue index c18ddb487d..010bf59c65 100644 --- a/apps/desktop/src/components/editor/QueryEditor.vue +++ b/apps/desktop/src/components/editor/QueryEditor.vue @@ -1136,6 +1136,11 @@ function clearScheduledSemanticDiagnostics() { pendingSemanticDiagnosticPreserveOutsideRanges = false; } +function invalidateSemanticDiagnosticsForDocumentChange() { + semanticDiagnosticRunId++; + semanticDiagnostics = []; +} + function shouldSkipSqlSemanticDiagnostics() { return props.databaseType !== "redis" && !settingsStore.editorSettings.sqlSemanticDiagnosticsEnabled; } @@ -1436,9 +1441,33 @@ function unregisterTableReferenceDropListener() { let completionEpoch = 0; let completionDebounceTimer: ReturnType | null = null; +let typedCompletionActivationUntil = 0; +let suppressNextSqlCompletionAutoStartUntil = 0; type QueryCompletionItem = SqlCompletionItem | ElasticsearchCompletionItem | RedisCompletionItem | MongoCompletionItem; +function markTypedCompletionActivation() { + typedCompletionActivationUntil = Date.now() + 500; +} + +function isTypedCompletionActivation(explicit: boolean) { + return explicit && typedCompletionActivationUntil >= Date.now(); +} + +function markCompletionAccepted() { + suppressNextSqlCompletionAutoStartUntil = Date.now() + 750; + completionEpoch++; +} + +function consumeSqlCompletionAutoStartSuppression() { + if (suppressNextSqlCompletionAutoStartUntil < Date.now()) { + suppressNextSqlCompletionAutoStartUntil = 0; + return false; + } + suppressNextSqlCompletionAutoStartUntil = 0; + return true; +} + function buildCompletionResult(items: QueryCompletionItem[], from: number, validFor?: RegExp) { if (items.length === 0) return null; return { @@ -1482,6 +1511,7 @@ function completionOptionForItem(item: QueryCompletionItem) { ...completion, apply(view: EditorViewType, completionItem: unknown, from: number, to: number) { record(); + markCompletionAccepted(); if (typeof originalApply === "function") { originalApply(view, completionItem as never, from, to); } else { @@ -1502,6 +1532,7 @@ function completionOptionForItem(item: QueryCompletionItem) { boost: item.boost, apply(view: EditorViewType, _completionItem: unknown, from: number, to: number) { record(); + markCompletionAccepted(); const insert = item.apply ?? item.label; if (codeMirrorInsertCompletionText) { view.dispatch(codeMirrorInsertCompletionText(view.state, insert, from, to)); @@ -1609,6 +1640,7 @@ async function provideSqlCompletions(context: CompletionContext) { const currentState = context.state; const position = context.pos; const explicit = context.explicit; + const typedActivation = isTypedCompletionActivation(explicit); if (imeCompositionActive || view.value?.compositionStarted || view.value?.composing) return null; if (!props.connectionId) return null; const fullDoc = currentState.doc.toString(); @@ -1670,14 +1702,16 @@ async function provideSqlCompletions(context: CompletionContext) { return buildCompletionResult(items, position - completionContext.prefix.length, getSqlCompletionResultValidFor(fullDoc, position)); } + const tableNameCompletion = isTableNameCompletionContext(completionContext); + const shouldResolveColumnCompletion = completionContext.suggestColumns && completionContext.referencedTables.length > 0 && (completionContext.prefix.length > 0 || typedActivation); + const shouldResolveAsyncCompletion = tableNameCompletion || shouldResolveColumnCompletion; const localResult = buildLocalSqlCompletionResult(completionContext, fullDoc, position); if (localResult) { scheduleCompletionMetadataRefresh(completionContext); - if (!explicit) return localResult; + const hasLocalColumnResult = localResult.options.some((option) => option.type === "column"); + if ((!explicit || typedActivation) && (!shouldResolveColumnCompletion || hasLocalColumnResult)) return localResult; } - const tableNameCompletion = isTableNameCompletionContext(completionContext); - const shouldResolveAsyncCompletion = tableNameCompletion || (completionContext.suggestColumns && completionContext.referencedTables.length > 0 && completionContext.prefix.length > 0); - if (!explicit && !shouldResolveAsyncCompletion) { + if ((!explicit || typedActivation) && !shouldResolveAsyncCompletion) { scheduleCompletionMetadataRefresh(completionContext); return null; } @@ -1703,9 +1737,9 @@ async function provideSqlCompletions(context: CompletionContext) { } try { const result = await performAsyncCompletionWithResult(epoch, completionContext, fullDoc, position); - resolve(result); + resolve(result ?? localResult); } catch { - resolve(null); + resolve(localResult); } }, 150); }); @@ -1718,22 +1752,38 @@ function isEditorComposing(currentView: EditorViewType): boolean { return imeCompositionActive || currentView.compositionStarted || currentView.composing; } +function scheduleSqlCompletionStart(currentView: EditorViewType) { + window.setTimeout(() => { + if (!codeMirrorStartCompletion || isEditorComposing(currentView)) return; + markTypedCompletionActivation(); + codeMirrorStartCompletion(currentView); + }, 0); +} + function flushImeComposition() { const currentView = view.value; if (!currentView || !pendingImeModelEmit) return; pendingImeModelEmit = false; emit("update:modelValue", currentView.state.doc.toString()); + invalidateSemanticDiagnosticsForDocumentChange(); scheduleSemanticDiagnostics(); syncContextMenuState(currentView); emit("selectionChange", selectedSqlFromView(currentView)); emit("cursorChange", currentView.state.selection.main.head); latestSelection = readEditorSelection(currentView); if (editorIsActive) emitEditorSelection(latestSelection); + if (shouldAutoOpenSqlCompletion(currentView.state.doc.toString(), currentView.state.selection.main.head)) { + scheduleSqlCompletionStart(currentView); + } } -function shouldStartSqlCompletionAfterInput(insertedText: string, currentView: EditorViewType): boolean { +function shouldStartSqlCompletionAfterInput(insertedText: string, removedText: string, currentView: EditorViewType): boolean { const position = currentView.state.selection.main.head; const fullDoc = currentView.state.doc.toString(); + if (!insertedText && removedText) { + const completionContext = getSqlCompletionContext(fullDoc, position); + return isTableNameCompletionContext(completionContext) && shouldAutoOpenSqlCompletion(fullDoc, position); + } if (insertedText.endsWith(".")) return true; if (/[,(]$/.test(insertedText)) { const completionContext = getSqlCompletionContext(fullDoc, position); @@ -1744,7 +1794,7 @@ function shouldStartSqlCompletionAfterInput(insertedText: string, currentView: E } if (!/[\w$@]$/.test(insertedText)) return false; const completionContext = getSqlCompletionContext(fullDoc, position); - return isTableNameCompletionContext(completionContext); + return isTableNameCompletionContext(completionContext) || shouldAutoOpenSqlCompletion(fullDoc, position); } function buildLocalSqlCompletionResult(completionContext: ReturnType, fullDoc: string, position: number) { @@ -2278,7 +2328,9 @@ onMounted(async () => { create: buildDecorations, update(value, transaction) { const diagnosticsChanged = !!diagnosticEffect && transaction.effects.some((effect) => effect.is(diagnosticEffect)); - return transaction.docChanged || diagnosticsChanged ? buildDecorations(transaction.state) : value; + if (diagnosticsChanged) return buildDecorations(transaction.state); + if (transaction.docChanged) return Decoration.set([]); + return value; }, provide: (field) => EditorView.decorations.from(field), }); @@ -2462,15 +2514,17 @@ onMounted(async () => { completionEpoch++; } else { emit("update:modelValue", update.state.doc.toString()); + invalidateSemanticDiagnosticsForDocumentChange(); scheduleSemanticDiagnostics(); let insertedText = ""; - update.changes.iterChanges((_fromA, _toA, _fromB, _toB, inserted) => { + let removedText = ""; + update.changes.iterChanges((fromA, toA, _fromB, _toB, inserted) => { insertedText += inserted.toString(); + removedText += update.startState.doc.sliceString(fromA, toA); }); - if (shouldStartSqlCompletionAfterInput(insertedText, update.view)) { - window.setTimeout(() => { - if (!isEditorComposing(update.view)) startCompletion(update.view); - }, 0); + const suppressCompletionAutoStart = consumeSqlCompletionAutoStartSuppression(); + if (!suppressCompletionAutoStart && shouldStartSqlCompletionAfterInput(insertedText, removedText, update.view)) { + scheduleSqlCompletionStart(update.view); } } } diff --git a/apps/desktop/src/lib/__tests__/sql/sqlCompletion.context.spec.ts b/apps/desktop/src/lib/__tests__/sql/sqlCompletion.context.spec.ts index c46da28bef..151b621216 100644 --- a/apps/desktop/src/lib/__tests__/sql/sqlCompletion.context.spec.ts +++ b/apps/desktop/src/lib/__tests__/sql/sqlCompletion.context.spec.ts @@ -1,5 +1,18 @@ import { describe, expect, it } from "vitest"; -import { buildSqlCompletionItems, getSqlCompletionContext } from "@/lib/sql/sqlCompletion"; +import { buildSqlCompletionItems, getSqlCompletionContext, shouldAutoOpenSqlCompletion } from "@/lib/sql/sqlCompletion"; + +describe("sqlCompletion keyword snippets", () => { + it("auto-opens and suggests SELECT when typing sel", () => { + const sql = "sel"; + const items = buildSqlCompletionItems(sql, sql.length, { + tables: [], + columnsByTable: new Map(), + }); + + expect(shouldAutoOpenSqlCompletion(sql, sql.length)).toBe(true); + expect(items).toEqual(expect.arrayContaining([expect.objectContaining({ label: "select *", type: "snippet" }), expect.objectContaining({ label: "SELECT", type: "keyword" })])); + }); +}); describe("sqlCompletion quoted schema qualifiers", () => { it("parses quoted PostgreSQL schema names before a dot", () => { @@ -28,6 +41,20 @@ describe("sqlCompletion quoted schema qualifiers", () => { }); }); +describe("sqlCompletion table targets", () => { + it("does not suggest aliases while completing an empty FROM target before LIMIT", () => { + const sql = "SELECT *\nFROM \nLIMIT 100;"; + const cursor = "SELECT *\nFROM ".length; + const items = buildSqlCompletionItems(sql, cursor, { + tables: [{ name: "users", type: "table" }], + columnsByTable: new Map(), + }); + + expect(items.some((item) => item.type === "snippet" && item.detail === "alias for LIMIT")).toBe(false); + expect(items.some((item) => item.type === "table" && item.label === "users")).toBe(true); + }); +}); + describe("sqlCompletion table aliases", () => { it("applies generated aliases to table completions when enabled", () => { const sql = "SELECT * FROM ord"; @@ -145,6 +172,18 @@ describe("sqlCompletion scoped context classification", () => { expect(context.suggestRoutines).toBe(false); }); + it("auto-opens column completion after WHERE whitespace before LIMIT", () => { + const sql = "SELECT *\nFROM t_0001 AS t0 WHERE \nLIMIT 100;"; + const cursor = "SELECT *\nFROM t_0001 AS t0 WHERE ".length; + const context = getSqlCompletionContext(sql, cursor); + + expect(context.contextKind).toBe("column"); + expect(context.prefix).toBe(""); + expect(context.referencedTables).toEqual(expect.arrayContaining([expect.objectContaining({ name: "t_0001", alias: "t0" })])); + expect(context.suggestColumns).toBe(true); + expect(shouldAutoOpenSqlCompletion(sql, cursor)).toBe(true); + }); + it("classifies CALL routine contexts", () => { const sql = "CALL usp_"; const context = getSqlCompletionContext(sql, sql.length); diff --git a/apps/desktop/src/lib/sql/sqlCompletion.ts b/apps/desktop/src/lib/sql/sqlCompletion.ts index 11ec555954..ed27b88d1f 100644 --- a/apps/desktop/src/lib/sql/sqlCompletion.ts +++ b/apps/desktop/src/lib/sql/sqlCompletion.ts @@ -1262,7 +1262,8 @@ class SqlCompletionProvider { this.items.push(...buildInsertAllColumnItems(context, this.input.columnsByTable, this.t, this.dialect)); } - if (!pendingJoinKeyword && context.referencedTables.length > 0 && !context.suggestColumns && !context.insertTable) { + const emptyTableNameCompletion = !context.prefix && (context.suggestTables || context.exclusiveTableSuggestions); + if (!pendingJoinKeyword && !emptyTableNameCompletion && context.referencedTables.length > 0 && !context.suggestColumns && !context.insertTable) { this.items.push(...buildAliasItems(context, this.databaseType)); } @@ -1304,10 +1305,23 @@ export function shouldAutoOpenSqlCompletion(sql: string, cursor: number): boolea const context = getSqlCompletionContext(sql, cursor); if (previousChar === "(" && context.insertTable) return true; if (/[,;()[\]]/.test(previousChar)) return false; - if (context.exclusiveTableSuggestions || context.exclusiveColumnSuggestions || context.exclusiveRoutineSuggestions || context.suggestTables) { + if (context.exclusiveTableSuggestions || context.exclusiveRoutineSuggestions || context.suggestTables) { return true; } - return /[\w$.@]/.test(previousChar); + if (context.exclusiveColumnSuggestions || shouldAutoOpenColumnCompletion(context, sql, cursor)) return true; + return /[A-Za-z_$@.]/.test(previousChar); +} + +function shouldAutoOpenColumnCompletion(context: SqlCompletionContext, sql: string, cursor: number): boolean { + if (!context.suggestColumns || context.referencedTables.length === 0) return false; + if (context.prefix.length > 0) return true; + return isColumnCompletionExpressionStart(sql.slice(0, cursor)); +} + +function isColumnCompletionExpressionStart(beforeCursor: string): boolean { + const cleaned = stripSqlLiterals(beforeCursor).trimEnd(); + if (!cleaned) return false; + return /(?:\b(?:where|on|having|and|or|not|is|like|in|between|by)\b|[,(])$/i.test(cleaned); } export function isSqlCompletionSuppressedContext(sql: string, cursor: number): boolean { @@ -1819,9 +1833,10 @@ function isInColumnContext(beforeCursor: string): boolean { // Check the last 3 words for column-context keywords for (let i = lastWords.length - 1; i >= Math.max(0, lastWords.length - 3); i--) { - const word = lastWords[i]?.toLowerCase().replace(/[^a-z0-9.]/g, "") ?? ""; + const rawWord = lastWords[i]?.toLowerCase() ?? ""; + if (/^[=<>!+\-/(,]$/.test(rawWord)) return true; + const word = rawWord.replace(/[^a-z0-9.]/g, ""); // Operators that indicate column context - if (/^[=<>!+\-*/(,]$/.test(word)) return true; // Keywords that directly precede column expressions if (["where", "on", "having", "set", "and", "or", "not", "is", "like", "in", "between", "select"].includes(word)) { return true; @@ -2054,6 +2069,7 @@ function isAfterSelectBodyExpression(beforeToken: string): boolean { if (!/^\s*(?:with\b[\s\S]*\bselect\b|select\b)/i.test(cleaned)) return false; if (!/\bfrom\b/i.test(cleaned)) return false; if (/\b(?:limit|offset|union|intersect|except)\b/i.test(cleaned)) return false; + if (/[,.(+\-*/%<>=!&|]$/.test(cleaned)) return false; const lastKeyword = /\b([A-Za-z_][\w$]*)\s*$/.exec(cleaned)?.[1]?.toLowerCase(); if (lastKeyword && SELECT_BODY_INCOMPLETE_TAIL_KEYWORDS.has(lastKeyword)) return false; return true; diff --git a/packages/app-tests/sqlCompletion.test.ts b/packages/app-tests/sqlCompletion.test.ts index ff59115fbb..d49293c014 100644 --- a/packages/app-tests/sqlCompletion.test.ts +++ b/packages/app-tests/sqlCompletion.test.ts @@ -863,6 +863,41 @@ test("auto-opens table completion immediately after FROM context whitespace", () } }); +test("auto-opens column completion immediately after condition context whitespace", () => { + for (const { sql, cursor = sql.length } of [ + { sql: "select * from users where " }, + { + sql: "SELECT *\nFROM t_0001 AS t0 WHERE \nLIMIT 100;", + cursor: "SELECT *\nFROM t_0001 AS t0 WHERE ".length, + }, + ]) { + assert.equal(shouldAutoOpenSqlCompletion(sql, cursor), true, sql); + } +}); + +test("does not auto-open column completion immediately after comparison operators", () => { + for (const sql of ["SELECT * FROM public.users WHERE id>", "SELECT * FROM public.users WHERE id> ", "SELECT * FROM public.users WHERE id = "]) { + assert.equal(shouldAutoOpenSqlCompletion(sql, sql.length), false, sql); + } + const rhsColumnPrefix = "SELECT * FROM public.users WHERE id > i"; + assert.equal(shouldAutoOpenSqlCompletion(rhsColumnPrefix, rhsColumnPrefix.length), true); +}); + +test("does not auto-open keyword completion immediately after a completed numeric WHERE value", () => { + for (const { sql, cursor = sql.length } of [ + { sql: "SELECT * FROM public.users WHERE id > 0" }, + { sql: "SELECT * FROM public.users WHERE id > 0 " }, + { + sql: "SELECT *\nFROM t_0001 AS t0 WHERE id>0\nLIMIT 100;", + cursor: "SELECT *\nFROM t_0001 AS t0 WHERE id>0".length, + }, + ]) { + assert.equal(shouldAutoOpenSqlCompletion(sql, cursor), false, sql); + } + const nextKeywordPrefix = "SELECT * FROM public.users WHERE id > 0 l"; + assert.equal(shouldAutoOpenSqlCompletion(nextKeywordPrefix, nextKeywordPrefix.length), true); +}); + test("auto-opens keyword completion after JOIN modifier whitespace", () => { assert.equal(shouldAutoOpenSqlCompletion("select * from users left ", "select * from users left ".length), true); assert.equal(shouldAutoOpenSqlCompletion("select left ", "select left ".length), false); @@ -1020,6 +1055,17 @@ test("prioritizes referenced columns in WHERE conditions", () => { assert.equal(items[0]?.label, "id"); }); +test("prioritizes referenced columns after WHERE comparison operators", () => { + const sql = "SELECT * FROM public.users WHERE id > i"; + const items = buildSqlCompletionItems(sql, sql.length, { + tables, + columnsByTable, + databaseType: "mysql", + }); + + assert.equal(items[0]?.label, "id"); +}); + test("prioritizes LIMIT after SELECT WHERE condition", () => { const sql = "SELECT * FROM public.users WHERE id > 0 l"; const items = buildSqlCompletionItems(sql, sql.length, { @@ -1154,6 +1200,22 @@ test("keeps snippets below matching WHERE field columns", () => { ); }); +test("suggests referenced table columns after WHERE whitespace", () => { + const sql = "select * from users where "; + const items = buildSqlCompletionItems(sql, sql.length, { + tables, + columnsByTable, + }); + + assert.deepEqual( + items + .filter((item) => item.type === "column") + .slice(0, 3) + .map((item) => item.label), + ["id", "name", "email"], + ); +}); + test("suggests user functions and triggers with fuzzy matching", () => { const sql = "select fun"; const items = buildSqlCompletionItems(sql, sql.length, {