diff --git a/apps/desktop/src/components/editor/QueryEditor.vue b/apps/desktop/src/components/editor/QueryEditor.vue index 5a86ee5d04..45ccf05346 100644 --- a/apps/desktop/src/components/editor/QueryEditor.vue +++ b/apps/desktop/src/components/editor/QueryEditor.vue @@ -164,7 +164,7 @@ import { buildQueryEditorLineNumbersExtension, createQueryEditorLineNumberAlignm import { searchKeymapWithoutModD } from "@/lib/editor/codemirrorSearchKeymap"; import { defaultKeymapForGlobalShortcuts } from "@/lib/editor/codemirrorDefaultKeymap"; import { appendSqlCompletionSpace } from "@/lib/editor/sqlCompletionInsertion"; -import { batchColumnSelectionColumnList, batchColumnSelectionInsertReplacement, batchColumnSelectionReplaceTo, isBatchColumnSelectionCompletionActive, shouldResolveSqlColumnCompletion } from "@/lib/editor/batchColumnSelection"; +import { batchColumnSelectionColumnList, batchColumnSelectionInsertReplacement, batchColumnSelectionReplaceTo, completionReplacementTo, isBatchColumnSelectionCompletionActive, shouldResolveSqlColumnCompletion } from "@/lib/editor/batchColumnSelection"; import { compareSqlCompletions, completionLabelPresentation } from "@/lib/editor/sqlCompletionPresentation"; import { clampEditorFontSize, createEditorWheelZoomGestureGuard, createEditorZoomCommitScheduler, fontSizeFromGestureScale, fontSizeFromWheelDelta } from "@/lib/editor/editorZoom"; import { buildSqlShortcutExecutionSql, enabledSqlShortcutActions, resolveSqlShortcutForDatabase, uniqueSqlShortcutBindings } from "@/lib/sql/sqlShortcutActions"; @@ -4206,6 +4206,7 @@ interface BatchColumnSelectionSession { from: number; to: number; replaceClosingQuote?: SqlCompletionItem["replaceClosingQuote"]; + replaceSelectWildcard?: true; candidates: BatchColumnSelectionCandidate[]; selectedKeys: Set; completionOptions: Map; @@ -4405,6 +4406,7 @@ function prepareBatchColumnSelectionSession(items: SqlCompletionItem[], document from, to, replaceClosingQuote: selectableItems[0]!.replaceClosingQuote, + replaceSelectWildcard: selectableItems[0]!.replaceSelectWildcard, candidates, selectedKeys: new Set(), completionOptions: new Map(), @@ -4649,10 +4651,12 @@ function applyBatchColumnSelection(view: EditorViewType, item: BatchColumnSelect session.qualifier, ); let replaceTo = batchColumnSelectionReplaceTo({ + from, to, mode: session.mode, nextCharacter: view.state.sliceDoc(to, to + 1), replaceClosingQuote: session.replaceClosingQuote, + replaceSelectWildcard: session.replaceSelectWildcard, }); let insert = columns; if (session.mode === "insert") { @@ -4857,7 +4861,14 @@ function completionOptionForItem(item: QueryCompletionItem | BatchColumnSelectio apply(view: EditorViewType, completionItem: unknown, from: number, to: number) { record(); markCompletionAccepted(item); - const replaceTo = "replaceClosingQuote" in item && item.replaceClosingQuote === view.state.sliceDoc(to, to + 1) ? to + 1 : to; + const nextCharacter = view.state.sliceDoc(to, to + 1); + const replaceTo = completionReplacementTo({ + from, + to, + nextCharacter, + replaceClosingQuote: "replaceClosingQuote" in item ? item.replaceClosingQuote : undefined, + replaceSelectWildcard: "replaceSelectWildcard" in item ? item.replaceSelectWildcard : undefined, + }); if (typeof originalApply === "function") { originalApply(view, completionItem as never, from, replaceTo); } else { @@ -4887,7 +4898,14 @@ function completionOptionForItem(item: QueryCompletionItem | BatchColumnSelectio apply(view: EditorViewType, _completionItem: unknown, from: number, to: number) { record(); markCompletionAccepted(item); - const replaceTo = "replaceClosingQuote" in item && item.replaceClosingQuote === view.state.sliceDoc(to, to + 1) ? to + 1 : to; + const nextCharacterAtCursor = view.state.sliceDoc(to, to + 1); + const replaceTo = completionReplacementTo({ + from, + to, + nextCharacter: nextCharacterAtCursor, + replaceClosingQuote: "replaceClosingQuote" in item ? item.replaceClosingQuote : undefined, + replaceSelectWildcard: "replaceSelectWildcard" in item ? item.replaceSelectWildcard : undefined, + }); const insert = appendSqlCompletionSpace(item.apply ?? item.label, { enabled: ("appendSpace" in item && item.appendSpace === true) || (shouldInsertSqlCompletionSpace() && settingsStore.editorSettings.insertSpaceAfterCompletion), itemType: item.type, diff --git a/apps/desktop/src/lib/__tests__/editor/batchColumnSelection.spec.ts b/apps/desktop/src/lib/__tests__/editor/batchColumnSelection.spec.ts index 16466bf54f..6e08cb2006 100644 --- a/apps/desktop/src/lib/__tests__/editor/batchColumnSelection.spec.ts +++ b/apps/desktop/src/lib/__tests__/editor/batchColumnSelection.spec.ts @@ -1,5 +1,18 @@ +// @vitest-environment happy-dom + +import { snippetCompletion } from "@codemirror/autocomplete"; +import { EditorState } from "@codemirror/state"; +import { EditorView } from "@codemirror/view"; import { describe, expect, it } from "vitest"; -import { batchColumnSelectionColumnList, batchColumnSelectionInsertReplacement, batchColumnSelectionReplaceTo, isBatchColumnSelectionCompletionActive, shouldResolveSqlColumnCompletion } from "@/lib/editor/batchColumnSelection"; +import { batchColumnSelectionColumnList, batchColumnSelectionInsertReplacement, batchColumnSelectionReplaceTo, completionReplacementTo, isBatchColumnSelectionCompletionActive, shouldResolveSqlColumnCompletion, shouldSwallowSelectStar } from "@/lib/editor/batchColumnSelection"; +import { getSqlCompletionContext, prepareSqlCompletionReplacement } from "@/lib/sql/sqlCompletion"; + +const columnItem = { label: "price", type: "column" as const, apply: "price", boost: 0 }; + +function replaceSelectWildcardFor(document: string, to: number): boolean { + const context = getSqlCompletionContext(document, to); + return prepareSqlCompletionReplacement(document, to, context, [columnItem]).items[0]?.replaceSelectWildcard === true; +} describe("batchColumnSelectionColumnList", () => { it("keeps a typed qualifier on every projection after the first", () => { @@ -13,15 +26,79 @@ describe("batchColumnSelectionColumnList", () => { describe("batchColumnSelectionReplaceTo", () => { it("consumes the auto-inserted INSERT closing parenthesis", () => { - expect(batchColumnSelectionReplaceTo({ to: 20, mode: "insert", nextCharacter: ")" })).toBe(21); + expect(batchColumnSelectionReplaceTo({ from: 20, to: 20, mode: "insert", nextCharacter: ")" })).toBe(21); }); it("keeps the replacement boundary when INSERT has no closing parenthesis", () => { - expect(batchColumnSelectionReplaceTo({ to: 20, mode: "insert", nextCharacter: "" })).toBe(20); + expect(batchColumnSelectionReplaceTo({ from: 20, to: 20, mode: "insert", nextCharacter: "" })).toBe(20); }); it("continues consuming a matching closing identifier quote", () => { - expect(batchColumnSelectionReplaceTo({ to: 20, mode: "select", nextCharacter: '"', replaceClosingQuote: '"' })).toBe(21); + expect(batchColumnSelectionReplaceTo({ from: 20, to: 20, mode: "select", nextCharacter: '"', replaceClosingQuote: '"' })).toBe(21); + }); + + it("consumes a lone SELECT * immediately after an empty-prefix completion (#9433-adjacent)", () => { + expect(batchColumnSelectionReplaceTo({ from: 7, to: 7, mode: "select", nextCharacter: "*", replaceSelectWildcard: true })).toBe(8); + }); + + it("does not consume a `*` that is a multiplication operator, not the wildcard", () => { + expect(batchColumnSelectionReplaceTo({ from: 7, to: 7, mode: "select", nextCharacter: "*", replaceSelectWildcard: false })).toBe(7); + }); +}); + +describe("shouldSwallowSelectStar", () => { + it("swallows a bare `*` only with an explicit SELECT wildcard marker", () => { + expect(shouldSwallowSelectStar(7, 7, "*", true)).toBe(true); + expect(shouldSwallowSelectStar(7, 7, "*", false)).toBe(false); + expect(shouldSwallowSelectStar(7, 7, "*")).toBe(false); + }); + + it("leaves a `*` alone once a real prefix was replaced", () => { + expect(shouldSwallowSelectStar(7, 10, "*", true)).toBe(false); + }); + + it("leaves any other trailing character alone", () => { + expect(shouldSwallowSelectStar(7, 7, " ", true)).toBe(false); + expect(shouldSwallowSelectStar(7, 7, "", true)).toBe(false); + }); +}); + +describe("completion wildcard acceptance", () => { + it.each([ + ["SELECT * FROM users", "SELECT price FROM users"], + ["SELECT *qty FROM users", "SELECT price*qty FROM users"], + ])("applies ordinary completion without deleting multiplication in %s", (document, expected) => { + const from = document.indexOf("*"); + const replaceTo = completionReplacementTo({ from, to: from, nextCharacter: "*", replaceSelectWildcard: replaceSelectWildcardFor(document, from) }); + const transaction = EditorState.create({ doc: document }).update({ changes: { from, to: replaceTo, insert: "price" } }); + + expect(transaction.state.doc.toString()).toBe(expected); + }); + + it.each([ + ["SELECT * FROM users", "SELECT price FROM users"], + ["SELECT *qty FROM users", "SELECT price*qty FROM users"], + ])("applies snippet completion without deleting multiplication in %s", (document, expected) => { + const from = document.indexOf("*"); + const replaceTo = completionReplacementTo({ from, to: from, nextCharacter: "*", replaceSelectWildcard: replaceSelectWildcardFor(document, from) }); + const view = new EditorView({ parent: window.document.createElement("div"), state: EditorState.create({ doc: document }) }); + const snippet = snippetCompletion("price", { label: "price", type: "function" }); + + expect(typeof snippet.apply).toBe("function"); + if (typeof snippet.apply === "function") snippet.apply(view, snippet, from, replaceTo); + expect(view.state.doc.toString()).toBe(expected); + view.destroy(); + }); + + it.each([ + ["SELECT * FROM users", "SELECT price, qty FROM users"], + ["SELECT *qty FROM users", "SELECT price, qty*qty FROM users"], + ])("applies batch completion without deleting multiplication in %s", (document, expected) => { + const from = document.indexOf("*"); + const replaceTo = batchColumnSelectionReplaceTo({ from, to: from, mode: "select", nextCharacter: "*", replaceSelectWildcard: replaceSelectWildcardFor(document, from) }); + const transaction = EditorState.create({ doc: document }).update({ changes: { from, to: replaceTo, insert: "price, qty" } }); + + expect(transaction.state.doc.toString()).toBe(expected); }); }); 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 a5ccc9a21b..384f526197 100644 --- a/apps/desktop/src/lib/__tests__/sql/sqlCompletion.context.spec.ts +++ b/apps/desktop/src/lib/__tests__/sql/sqlCompletion.context.spec.ts @@ -6,6 +6,7 @@ import { buildSqlCompletionItemsFromContext, getPostgresSequenceLiteralCompletionContext, getSqlCompletionContext, + prepareSqlCompletionReplacement, selectStarResultColumnsMatch, shouldAutoOpenSqlCompletion, } from "@/lib/sql/sqlCompletion"; @@ -13,6 +14,42 @@ import { sqlCompletionContextFromSemantic } from "@/lib/sql/semantic/completion" import { buildSqlSemanticModel } from "@/lib/sql/semantic/model"; import { originForSqlCompletionProvider, originForTypedSqlCompletionStart, shouldAllowSqlCompletionTrigger, type SqlCompletionTriggerFacts } from "@/lib/sql/sqlCompletionTriggerPolicy"; +describe("SQL completion replacement", () => { + const columnItem = { label: "price", type: "column" as const, apply: "price", boost: 0 }; + + it("marks a standalone SELECT wildcard for replacement", () => { + const sql = "SELECT * FROM users"; + const cursor = sql.indexOf("*"); + const context = getSqlCompletionContext(sql, cursor); + + expect(prepareSqlCompletionReplacement(sql, cursor, context, [columnItem]).items[0]).toMatchObject({ replaceSelectWildcard: true }); + }); + + it("does not mark the multiplication operator before an untyped right operand", () => { + const sql = "SELECT *qty FROM users"; + const cursor = sql.indexOf("*"); + const context = getSqlCompletionContext(sql, cursor); + + expect(prepareSqlCompletionReplacement(sql, cursor, context, [columnItem]).items[0]).not.toHaveProperty("replaceSelectWildcard"); + }); + + it("does not mark an expression operator after another projection operand", () => { + const sql = "SELECT amount + * FROM users"; + const cursor = sql.indexOf("*"); + const context = getSqlCompletionContext(sql, cursor); + + expect(prepareSqlCompletionReplacement(sql, cursor, context, [columnItem]).items[0]).not.toHaveProperty("replaceSelectWildcard"); + }); + + it("marks a standalone wildcard after a SELECT modifier", () => { + const sql = "SELECT DISTINCT * FROM users"; + const cursor = sql.indexOf("*"); + const context = getSqlCompletionContext(sql, cursor); + + expect(prepareSqlCompletionReplacement(sql, cursor, context, [columnItem]).items[0]).toMatchObject({ replaceSelectWildcard: true }); + }); +}); + describe("sqlCompletion keyword snippets", () => { it("auto-opens and suggests SELECT when typing sel", () => { const sql = "sel"; diff --git a/apps/desktop/src/lib/editor/batchColumnSelection.ts b/apps/desktop/src/lib/editor/batchColumnSelection.ts index 798d1c2a86..c7eee85102 100644 --- a/apps/desktop/src/lib/editor/batchColumnSelection.ts +++ b/apps/desktop/src/lib/editor/batchColumnSelection.ts @@ -8,14 +8,24 @@ export function shouldResolveSqlColumnCompletion(options: { suggestColumns: bool return options.suggestColumns && options.hasReferencedTables && (options.prefix.length > 0 || options.typedActivation || options.selectListColumnContext); } +export function shouldSwallowSelectStar(from: number, to: number, nextCharacter: string, replaceSelectWildcard = false): boolean { + return replaceSelectWildcard && from === to && nextCharacter === "*"; +} + +export function completionReplacementTo(options: { from: number; to: number; nextCharacter: string; replaceClosingQuote?: string; replaceSelectWildcard?: boolean }): number { + const { from, to, nextCharacter, replaceClosingQuote, replaceSelectWildcard } = options; + return replaceClosingQuote === nextCharacter || shouldSwallowSelectStar(from, to, nextCharacter, replaceSelectWildcard) ? to + 1 : to; +} + /** * The INSERT batch action writes its own closing parenthesis before VALUES. * Consume an existing one (normally inserted by CodeMirror's auto-close * brackets extension) so the resulting statement has exactly one `)`. */ -export function batchColumnSelectionReplaceTo(options: { to: number; mode: BatchColumnSelectionMode; nextCharacter: string; replaceClosingQuote?: string }): number { - const { to, mode, nextCharacter, replaceClosingQuote } = options; - return replaceClosingQuote === nextCharacter || (mode === "insert" && nextCharacter === ")") ? to + 1 : to; +export function batchColumnSelectionReplaceTo(options: { from: number; to: number; mode: BatchColumnSelectionMode; nextCharacter: string; replaceClosingQuote?: string; replaceSelectWildcard?: boolean }): number { + const { from, to, mode, nextCharacter, replaceClosingQuote, replaceSelectWildcard } = options; + if (mode === "insert" && nextCharacter === ")") return to + 1; + return completionReplacementTo({ from, to, nextCharacter, replaceClosingQuote, replaceSelectWildcard }); } export function batchColumnSelectionInsertReplacement(options: { document: string; to: number; columns: string; valuesKeyword: "values" | "VALUES"; valueCount: number }): { replaceTo: number; insert: string } { diff --git a/apps/desktop/src/lib/sql/sqlCompletion.ts b/apps/desktop/src/lib/sql/sqlCompletion.ts index c51f686317..6f094f910e 100644 --- a/apps/desktop/src/lib/sql/sqlCompletion.ts +++ b/apps/desktop/src/lib/sql/sqlCompletion.ts @@ -1352,6 +1352,7 @@ export interface SqlCompletionItem { boost: number; exactMatch?: boolean; dedupeKey?: string; + replaceSelectWildcard?: true; /** Enables the query editor's checkbox-based batch insertion for this column. */ batchSelectionMode?: "select" | "insert"; /** Qualifier to prepend to every batch-selected column after the first one. */ @@ -1428,6 +1429,7 @@ export interface SqlCompletionContext { comparisonLeftColumn?: string; onStar: boolean; selectListColumnContext: boolean; + selectListWildcardAfterCursor?: boolean; preferredKeywords: string[]; updateTarget?: { table: string; schema?: string }; deleteTarget?: { table: string; schema?: string }; @@ -1448,22 +1450,47 @@ const SQL_COMPLETION_CLOSING_QUOTES: Readonly, items: SqlCompletionItem[]): { from: number; items: SqlCompletionItem[] } { +const SELECT_WILDCARD_FOLLOWING_CLAUSES = new Set(["except", "fetch", "for", "from", "group", "having", "intersect", "into", "limit", "offset", "order", "qualify", "union", "where", "window"]); +const SELECT_PROJECTION_MODIFIERS = new Set(["all", "distinct", "distinctrow"]); + +function isStandaloneSelectWildcard(sql: string, cursor: number, selectListColumnContext: boolean, statementKind: SqlStatementKind, dialectId?: string): boolean { + if (!selectListColumnContext || statementKind !== "select" || sql[cursor] !== "*") return false; + + const tokens = tokenizeSqlSemantic(sql, dialectId).filter((token) => token.kind !== "comment"); + const starIndex = tokens.findIndex((token) => token.span.start === cursor && token.text === "*"); + if (starIndex < 0) return false; + + const star = tokens[starIndex]!; + let previousIndex = starIndex - 1; + while (previousIndex >= 0 && tokens[previousIndex]!.depth === star.depth && tokens[previousIndex]!.kind === "word" && SELECT_PROJECTION_MODIFIERS.has(tokens[previousIndex]!.normalized)) previousIndex--; + const previous = tokens[previousIndex]; + const startsProjection = previous?.depth === star.depth && (previous.normalized === "select" || previous.text === ","); + if (!startsProjection) return false; + + const next = tokens.slice(starIndex + 1).find((token) => token.depth <= star.depth); + if (!next) return true; + if (next.depth !== star.depth) return false; + return next.text === "," || next.text === ";" || (next.kind === "word" && SELECT_WILDCARD_FOLLOWING_CLAUSES.has(next.normalized)); +} + +export function prepareSqlCompletionReplacement(sql: string, cursor: number, context: Pick, items: SqlCompletionItem[]): { from: number; items: SqlCompletionItem[] } { const range = context.replacementRange; const from = range && range.start >= 0 && range.start <= cursor && range.end === cursor ? range.start : cursor - context.prefix.length; const closingQuote = from < cursor ? SQL_COMPLETION_CLOSING_QUOTES[sql[from] ?? ""] : undefined; - if (!closingQuote) return { from, items }; const replaceClosingQuote = sql[cursor] === closingQuote ? closingQuote : undefined; + const replaceSelectWildcard = from === cursor && context.selectListWildcardAfterCursor === true; + if (!closingQuote && !replaceSelectWildcard) return { from, items }; return { from, items: items.map((item) => { let prepared = item; const apply = item.apply ?? item.label; - if (item.type === "column" && !(apply.startsWith(sql[from] ?? "") && apply.endsWith(closingQuote)) && (context.qualifier || !apply.includes("."))) { + if (closingQuote && item.type === "column" && !(apply.startsWith(sql[from] ?? "") && apply.endsWith(closingQuote)) && (context.qualifier || !apply.includes("."))) { const escaped = apply.replaceAll(closingQuote, closingQuote + closingQuote); prepared = { ...prepared, apply: `${sql[from]}${escaped}${closingQuote}` }; } - return replaceClosingQuote && !prepared.replaceClosingQuote ? { ...prepared, replaceClosingQuote } : prepared; + if (replaceClosingQuote && !prepared.replaceClosingQuote) prepared = { ...prepared, replaceClosingQuote }; + return replaceSelectWildcard && !prepared.replaceSelectWildcard ? { ...prepared, replaceSelectWildcard: true } : prepared; }), }; } @@ -2435,6 +2462,7 @@ export function getSqlCompletionContext(sql: string, cursor: number, options: Sq const suggestRoutines = inCallRoutineContext || oracleTableFunctionContext || inPotentialPackageMemberContext || (!exclusiveTableSuggestions && !exclusiveColumnSuggestions && !insertInfo && !updateInfo?.inSetClause && prefix.length >= 2); const statementKind = detectStatementKind(beforeCursor || fullStatement); + const selectListWildcardAfterCursor = isStandaloneSelectWildcard(rawStatement, cursor - statementSpan.start, selectListColumnContext, statementKind, resolveSqlDialectId(options)); const dataTypeContext = isCreateTableColumnTypeContext(beforeToken, options.databaseType); const preferredValueKeywords = sqlServerDatepartCompletionValues(beforeCursor, options.databaseType); const preferredKeywords = qualifier ? [] : preferredKeywordsForCompletion(beforeCursor, beforeToken, selectListColumnContext, exclusiveTableSuggestions, updateInfo, deleteInfo, options.databaseType); @@ -2480,6 +2508,7 @@ export function getSqlCompletionContext(sql: string, cursor: number, options: Sq comparisonLeftColumn: detectComparisonLeftColumn(beforeCursor), onStar: detectOnStar(beforeCursor), selectListColumnContext, + selectListWildcardAfterCursor, preferredKeywords, updateTarget: updateInfo?.target, deleteTarget: deleteInfo?.target,