mirror of
https://github.com/t8y2/dbx.git
synced 2026-10-19 16:16:45 +00:00
fix(editor): normalize LIKE case and completed SQL indentation
This commit is contained in:
@@ -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`);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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],
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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`#\{[^}]+\}` }],
|
||||
},
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user