fix(editor): distinguish SELECT wildcards from multiplication

This commit is contained in:
Melody Ruch authored and GitHub committed 2026-09-18 20:19:08 +08:00
1 parent 24179cb45d
commit ca8bc9bf12
5 files changed
+185 -14

No files matched your search

@@ -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<string>;
completionOptions: Map<string, QueryCompletionOption>;
@@ -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,
@@ -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);
});
});
@@ -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";
@@ -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 } {
+33 -4
View File
@@ -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<Record<string, SqlCompletionClosin
"[": "]",
};
export function prepareSqlCompletionReplacement(sql: string, cursor: number, context: Pick<SqlCompletionContext, "prefix" | "qualifier" | "replacementRange">, 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<SqlCompletionContext, "prefix" | "qualifier" | "replacementRange" | "selectListWildcardAfterCursor">, 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,