fix(editor): normalize LIKE case and completed SQL indentation
This commit is contained in:
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`);
|
||||
});
|
||||
});
|
||||
@@ -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" } }));
|
||||
|
||||
|
||||
Reference in new issue
Block a user