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

This commit is contained in:
zipg authored and GitHub committed 2026-08-17 02:09:50 +08:00
1 parent dbd93dae5a
commit 92ab9bb0dc
9 files changed
+237 -2

No files matched your search

@@ -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" } }));