fix(editor): normalize LIKE case and completed SQL indentation

This commit is contained in:
zipg
2026-08-17 02:09:50 +08:00
committed by GitHub
parent dbd93dae5a
commit 92ab9bb0dc
9 changed files with 237 additions and 2 deletions
@@ -23,6 +23,7 @@ import { detectAndFormatStructured } from "@/lib/sql/autoFormat";
import { enabledSqlParameterSyntaxes, resolveSqlVariableSyntaxToggles } from "@/lib/sql/sqlVariableSyntax";
import { blankLineDeletionChanges, replaceSelectedEditorText } from "@/lib/editor/queryEditorTextEdits";
import { joinQueryEditorLines } from "@/lib/editor/queryEditorJoinLines";
import { insertQueryEditorNewline } from "@/lib/editor/queryEditorNewline";
import { createSqlSignatureTooltipDom } from "@/lib/editor/sqlSignatureTooltip";
import { buildSqlInConditionFromPasteSource, insertTextForSqlInCondition } from "@/lib/sql/sqlInListPaste";
import { resolveSqlSingleQuoteKeyAction } from "@/lib/sql/sqlQuoteCaret";
@@ -1940,7 +1941,7 @@ function runKeymapExtension(codeMirrorKeymap: (typeof import("@codemirror/view")
function insertNewlineWithoutCompletion(view: EditorViewType): boolean {
codeMirrorCloseCompletion?.(view);
suppressNextSqlCompletionAutoStartUntil = Date.now() + 750;
const handled = codeMirrorInsertNewlineKeepIndent?.(view) ?? false;
const handled = insertQueryEditorNewline(view, codeMirrorInsertNewlineKeepIndent, props.databaseType);
if (!handled) suppressNextSqlCompletionAutoStartUntil = 0;
return handled;
}
@@ -88,11 +88,13 @@ function createHarness(options: {
"codeMirrorAcceptCompletion",
"codeMirrorCloseCompletion",
"codeMirrorInsertNewlineKeepIndent",
"insertQueryEditorNewline",
"codeMirrorNextSnippetField",
"codeMirrorIndentMore",
"settingsStore",
"normalizeShortcutSettings",
"shortcutToCodeMirrorKey",
"props",
`${javascript}\nreturn { handleTab, insertNewlineWithoutCompletion, acceptCompletionOrNextSnippetField, clearPendingCompletionTab, consumeSqlCompletionAutoStartSuppression };`,
);
return factory(
@@ -100,6 +102,7 @@ function createHarness(options: {
options.acceptCompletion ?? (() => false),
options.closeCompletion ?? (() => false),
options.insertNewlineKeepIndent ?? (() => false),
(view: MockView, fallback: ((view: MockView) => boolean) | null | undefined) => fallback?.(view) ?? false,
options.nextSnippetField ?? (() => false),
options.indentMore ?? (() => false),
{
@@ -110,6 +113,7 @@ function createHarness(options: {
},
normalizeShortcutSettings,
shortcutToCodeMirrorKey,
{ databaseType: "mysql" },
) as TabHarness;
}
@@ -0,0 +1,93 @@
import { EditorState, type Transaction } from "@codemirror/state";
import { insertNewlineKeepIndent } from "@codemirror/commands";
import { describe, expect, it, vi } from "vitest";
import { insertQueryEditorNewline, shouldStartNextSqlStatementAtColumnZero } from "@/lib/editor/queryEditorNewline";
import type { DatabaseType } from "@/types/database";
function createState(doc: string, cursor = doc.length) {
const state = EditorState.create({ doc });
return state.update({ selection: { anchor: cursor === doc.length ? state.doc.length : cursor } }).state;
}
function insertNewline(doc: string, databaseType: DatabaseType = "mysql") {
let state = createState(doc);
const dispatch = vi.fn((transaction: Transaction) => {
state = transaction.state;
});
const handled = insertQueryEditorNewline(
{
get state() {
return state;
},
dispatch,
} as never,
insertNewlineKeepIndent,
databaseType,
);
return { dispatch, handled, state };
}
describe("queryEditorNewline", () => {
it("starts a new top-level statement at column zero after a formatted SQL terminator", () => {
const result = insertNewline("SELECT\n *\nFROM\n users;");
expect(result.handled).toBe(true);
expect(result.state.doc.toString()).toBe("SELECT\n *\nFROM\n users;\n");
expect(result.state.selection.main.head).toBe(result.state.doc.length);
});
it("keeps indentation while the current SQL statement is unfinished", () => {
const result = insertNewline("SELECT\n *\nFROM\n users");
expect(result.state.doc.toString()).toBe("SELECT\n *\nFROM\n users\n ");
});
it("keeps indentation after an internal MySQL routine semicolon", () => {
const sqlText = "CREATE PROCEDURE p() BEGIN\n SET value = 1;";
const result = insertNewline(sqlText, "mysql");
expect(shouldStartNextSqlStatementAtColumnZero(createState(sqlText), "mysql")).toBe(false);
expect(result.state.doc.toString()).toBe(`${sqlText}\n `);
});
it("recognizes the final semicolon of a MySQL routine as a top-level terminator", () => {
const sqlText = "CREATE PROCEDURE p() BEGIN\n SET value = 1;\nEND;";
expect(shouldStartNextSqlStatementAtColumnZero(createState(sqlText), "mysql")).toBe(true);
});
it("does not treat semicolons in strings or comments as statement terminators", () => {
for (const sqlText of [" SELECT ';'", " -- unfinished note;", "SELECT 1;\n -- trailing note;"]) {
expect(shouldStartNextSqlStatementAtColumnZero(createState(sqlText), "mysql")).toBe(false);
expect(insertNewline(sqlText).state.doc.toString()).toBe(`${sqlText}\n `);
}
});
it("distinguishes internal and final Oracle PLSQL semicolons", () => {
const internal = "BEGIN\n NULL;";
const complete = `${internal}\nEND;`;
expect(shouldStartNextSqlStatementAtColumnZero(createState(internal), "oracle")).toBe(false);
expect(shouldStartNextSqlStatementAtColumnZero(createState(complete), "oracle")).toBe(true);
});
it("keeps indentation inside SQL Server routine batches with CRLF line endings", () => {
const sqlText = "CREATE OR ALTER PROCEDURE dbo.p AS\r\nBEGIN\r\n SELECT 1;";
const result = insertNewline(sqlText, "sqlserver");
expect(shouldStartNextSqlStatementAtColumnZero(createState(sqlText), "sqlserver")).toBe(false);
expect(result.state.doc.toString()).toMatch(/SELECT 1;\n $/);
});
it("resets indentation after a completed statement in the next SQL Server batch", () => {
const sqlText = "CREATE PROCEDURE dbo.p AS\r\nBEGIN\r\n SELECT 1;\r\nEND;\r\nGO\r\n SELECT 2;";
expect(shouldStartNextSqlStatementAtColumnZero(createState(sqlText), "sqlserver")).toBe(true);
});
it("resets indentation when spaces follow the terminating semicolon", () => {
const sqlText = " SELECT 1; ";
expect(insertNewline(sqlText).state.doc.toString()).toBe(`${sqlText}\n`);
});
});
+47
View File
@@ -52,6 +52,53 @@ describe("sqlFormatter", () => {
}
});
it.each(["mysql", "sqlite"] as const)("applies keyword case to LIKE operators in the %s dialect", async (dialect) => {
const sql = "select * from users where name like '%like%' and note not like 'LIKE'";
const upper = await formatSqlText(sql, dialect, { keywordCase: "upper", functionCase: "preserve" });
const lower = await formatSqlText(sql.toUpperCase(), dialect, { keywordCase: "lower", functionCase: "preserve", identifierCase: "lower" });
expect(upper).toContain("name LIKE '%like%'");
expect(upper).toContain("note NOT LIKE 'LIKE'");
expect(lower).toContain("name like '%LIKE%'");
expect(lower).toContain("note not like 'LIKE'");
});
it("keeps SQLite LIKE functions and qualified identifiers under their own case settings", async () => {
const formatted = await formatSqlText("select like('%a%', name), filters.like from users", "sqlite", {
keywordCase: "upper",
functionCase: "preserve",
identifierCase: "preserve",
});
expect(formatted).toContain("like(");
expect(formatted).toContain("filters.like");
});
it.each(["mysql", "sqlite"] as const)("does not rewrite LIKE inside DBX placeholders in the %s dialect", async (dialect) => {
for (const [lowerPlaceholder, upperPlaceholder] of [
["${like}", "${LIKE}"],
["#{like}", "#{LIKE}"],
[":like", ":LIKE"],
["@like", "@LIKE"],
]) {
const upper = await formatSqlText(`select * from users where name like 'a';\nselect ${lowerPlaceholder} as marker`, dialect, {
keywordCase: "upper",
functionCase: "preserve",
});
const lower = await formatSqlText(`SELECT * FROM users WHERE name LIKE 'a';\nSELECT ${upperPlaceholder} AS marker`, dialect, {
keywordCase: "lower",
functionCase: "preserve",
identifierCase: "lower",
});
expect(upper).toContain(lowerPlaceholder);
expect(upper).toContain("name LIKE 'a'");
expect(lower).toContain(upperPlaceholder);
expect(lower).toContain("name like 'a'");
}
});
it("falls back to the postgres formatter when the generic dialect cannot parse SQL", async () => {
const formatted = await formatSqlText("SELECT 1::int AS id;", "generic");
@@ -40,6 +40,14 @@ describe("sqlFormatterConfig shortcut storage", () => {
});
});
it("recognizes DBX positional and named parameter syntaxes by default", () => {
expect(sqlFormatterOptions({}).paramTypes).toEqual({
positional: true,
named: [":", "@"],
custom: [{ regex: String.raw`\$\{[^}]+\}` }, { regex: String.raw`#\{[^}]+\}` }],
});
});
it("accepts the same-line logical operator mode", () => {
const result = parseSqlFormatterConfig(JSON.stringify({ version: 1, formatter: "sql-formatter", options: { logicalOperatorNewline: "none" } }));
@@ -0,0 +1,63 @@
import type { EditorState } from "@codemirror/state";
import type { Command, EditorView } from "@codemirror/view";
import { trailingStatementDelimiterPosition } from "@/lib/sql/statementDelimiter";
import { splitSqlStatementRanges } from "@/lib/sql/sqlStatementRanges";
import type { DatabaseType } from "@/types/database";
function trailingStatementTerminator(state: EditorState): number | null {
if (state.selection.ranges.length !== 1 || !state.selection.main.empty) return null;
const cursor = state.selection.main.head;
const line = state.doc.lineAt(cursor);
const match = /;[ \t]*$/.exec(state.sliceDoc(line.from, cursor));
if (!match) return null;
return line.from + match.index;
}
function sqlServerBatchStartsRoutine(sql: string): boolean {
const goLine = /^[ \t]*GO(?:[ \t]+\d+)?[ \t]*(?:\r\n|\r|\n|$)/gim;
let batchStart = 0;
for (const match of sql.matchAll(goLine)) batchStart = (match.index ?? 0) + match[0].length;
const batch = sql.slice(batchStart).replace(/^(?:\s|--[^\r\n]*(?:\r\n|\r|\n|$)|\/\*[\s\S]*?\*\/)+/u, "");
return /^(?:CREATE(?:\s+OR\s+ALTER)?|ALTER)\s+(?:PROC(?:EDURE)?|FUNCTION|TRIGGER)\b/iu.test(batch);
}
export function shouldStartNextSqlStatementAtColumnZero(state: EditorState, databaseType?: DatabaseType): boolean {
const terminator = trailingStatementTerminator(state);
if (terminator === null) return false;
const throughTerminator = state.sliceDoc(0, terminator + 1);
if (databaseType === "sqlserver" && sqlServerBatchStartsRoutine(throughTerminator)) return false;
// Appending one complete sentinel statement lets the shared splitter decide
// whether this semicolon closes a top-level statement or is still inside a
// routine/PLSQL block. Inspect the preceding range's actual delimiter so a
// semicolon in a trailing comment cannot borrow an earlier statement boundary.
const sentinelStart = throughTerminator.length + 1;
const withTerminator = `${throughTerminator}\nSELECT 1;`;
const ranges = splitSqlStatementRanges(withTerminator, databaseType);
const sentinelIndex = ranges.findIndex((range) => range.from === sentinelStart);
if (sentinelIndex <= 0) return false;
const previousRange = ranges[sentinelIndex - 1];
return previousRange.to === terminator + 1 || trailingStatementDelimiterPosition(withTerminator, previousRange.to) === terminator;
}
export function insertQueryEditorNewline(view: EditorView, fallback: Command | null | undefined, databaseType?: DatabaseType): boolean {
if (view.state.readOnly || !shouldStartNextSqlStatementAtColumnZero(view.state, databaseType)) {
return fallback?.(view) ?? false;
}
const cursor = view.state.selection.main.head;
view.dispatch(
view.state.update({
changes: { from: cursor, to: cursor, insert: "\n" },
selection: { anchor: cursor + 1 },
scrollIntoView: true,
userEvent: "input",
}),
);
return true;
}
+15 -1
View File
@@ -339,6 +339,19 @@ function keepLogicalOperatorsOnSameLine(sql: string, dialect: SqlFormatDialect =
return restoreSpans(collapsed, spans);
}
function normalizeLikeOperatorCase(sql: string, settings: SqlFormatterSettings, dialect: SqlFormatDialect): string {
if ((dialect !== "mysql" && dialect !== "sqlite") || settings.keywordCase === "preserve") return sql;
// sql-formatter classifies LIKE as a reserved function in these dialects,
// so an operator follows functionCase instead of keywordCase. Only adjust
// operator-shaped occurrences; keep LIKE(...) functions and qualified
// identifiers under their existing function/identifier case settings.
const { masked, spans } = maskStringAndCommentSpans(sql, dialect);
const keyword = settings.keywordCase === "upper" ? "LIKE" : "like";
const normalized = masked.replace(/(?<![.\w$:@{#])LIKE\b(?!\s*\()/gi, keyword);
return restoreSpans(normalized, spans);
}
function keepFromClauseAndFirstSourceOnSameLine(sql: string): string {
const lines = sql.split("\n");
for (let index = 0; index < lines.length - 1; index += 1) {
@@ -364,7 +377,8 @@ function keepFromClauseAndFirstSourceOnSameLine(sql: string): string {
}
function applySqlFormatterLayout(sql: string, settings: SqlFormatterSettings, dialect: SqlFormatDialect): string {
let formatted = settings.logicalOperatorNewline === "none" ? keepLogicalOperatorsOnSameLine(sql, dialect) : sql;
let formatted = normalizeLikeOperatorCase(sql, settings, dialect);
if (settings.logicalOperatorNewline === "none") formatted = keepLogicalOperatorsOnSameLine(formatted, dialect);
if (settings.fromClauseLayout === "sameLine") formatted = keepFromClauseAndFirstSourceOnSameLine(formatted);
return formatted;
}
@@ -269,6 +269,8 @@ export function sqlFormatterOptions(settings: unknown) {
newlineBeforeSemicolon: normalized.newlineBeforeSemicolon,
paramTypes: {
...paramTypes,
positional: paramTypes.positional ?? true,
named: paramTypes.named ?? [":", "@"],
custom: [...(paramTypes.custom ?? []), ...DBX_CUSTOM_PARAM_TYPES],
},
};
+3
View File
@@ -250,6 +250,8 @@ test("maps DBX formatter settings to sql-formatter options", () => {
denseOperators: false,
newlineBeforeSemicolon: true,
paramTypes: {
positional: true,
named: [":", "@"],
custom: [{ regex: String.raw`\$\{[^}]+\}` }, { regex: String.raw`#\{[^}]+\}` }],
},
},
@@ -275,6 +277,7 @@ test("maps DBX formatter settings to sql-formatter options", () => {
newlineBeforeSemicolon: false,
paramTypes: {
positional: true,
named: [":", "@"],
custom: [{ regex: String.raw`\$\{[^}]+\}` }, { regex: String.raw`#\{[^}]+\}` }],
},
},