diff --git a/apps/desktop/src/lib/sql/layout/ddl.ts b/apps/desktop/src/lib/sql/layout/ddl.ts index 3e10ab230e..2dc5c9f8de 100644 --- a/apps/desktop/src/lib/sql/layout/ddl.ts +++ b/apps/desktop/src/lib/sql/layout/ddl.ts @@ -143,7 +143,10 @@ export function createTableLayout(statement: StatementNode, ctx: SqlLayoutContex if (endsWithLineComment(rest)) writer.markLineComment(); } } - if (statement.hasSemicolon) writer.write(";"); + if (statement.hasSemicolon) { + if (ctx.options.newlineBeforeSemicolon) writer.newline(0); + writer.write(";"); + } return writer.toString(); } diff --git a/apps/desktop/src/lib/sql/layout/index.ts b/apps/desktop/src/lib/sql/layout/index.ts index 4b5a6988ff..93aa398932 100644 --- a/apps/desktop/src/lib/sql/layout/index.ts +++ b/apps/desktop/src/lib/sql/layout/index.ts @@ -20,6 +20,7 @@ export const DEFAULT_SQL_LAYOUT_OPTIONS: SqlLayoutOptions = { useTabs: false, fromClauseSourceOnSameLine: true, commaPosition: "after", + newlineBeforeSemicolon: false, }; export interface SqlLayoutRequest { @@ -59,6 +60,7 @@ export async function formatSqlLayout(request: SqlLayoutRequest): Promise node.type === "clause" && isProjectionClauseName(node.nameKw.text) && splitByComma(node.children).length > 1); const collapsed = hasMultiItemProjection ? null : renderInline(collapsedContext(ctx), statement.children, ctx.options.lineWidth); - if (collapsed) return statement.hasSemicolon ? `${collapsed};` : collapsed; + if (collapsed) { + if (!statement.hasSemicolon) return collapsed; + return ctx.options.newlineBeforeSemicolon ? `${collapsed}\n;` : `${collapsed};`; + } if (isCreateTable(statement)) return createTableLayout(statement, ctx); @@ -371,7 +374,10 @@ function printStatement(statement: StatementNode, ctx: SqlLayoutContext): string writeBodyNode(writer, node, 0, ctx); first = false; } - if (statement.hasSemicolon) writer.write(";"); + if (statement.hasSemicolon) { + if (ctx.options.newlineBeforeSemicolon) writer.newline(0); + writer.write(";"); + } return writer.toString(); } diff --git a/apps/desktop/src/lib/sql/sqlFormatter.ts b/apps/desktop/src/lib/sql/sqlFormatter.ts index c9a3de3bcf..8255fea4c6 100644 --- a/apps/desktop/src/lib/sql/sqlFormatter.ts +++ b/apps/desktop/src/lib/sql/sqlFormatter.ts @@ -478,6 +478,7 @@ export async function formatSqlText(sql: string, dialect: SqlFormatDialect = "ge keywordCase: normalizedSettings.keywordCase, logicalOperatorNewline: normalizedSettings.logicalOperatorNewline, commaPosition: normalizedSettings.commaPosition, + newlineBeforeSemicolon: normalizedSettings.newlineBeforeSemicolon, }; const usesDefaultStyle = normalizedSettings.layoutStyle === "dbx" && normalizedSettings.indentStyle === "standard"; diff --git a/packages/app-tests/sqlFormatter.test.ts b/packages/app-tests/sqlFormatter.test.ts index 549e1d6139..6940f856e4 100644 --- a/packages/app-tests/sqlFormatter.test.ts +++ b/packages/app-tests/sqlFormatter.test.ts @@ -18,10 +18,7 @@ test("rejects very large SQL before importing formatter", async () => { }); test("formats SQL with uppercase keywords and readable line breaks by default", async () => { - const formatted = await formatSqlText( - "select id, name, email, created_at, updated_at, deleted_at, tenant_id from users where active = 1 and verified = 1 order by name, created_at desc", - "postgres", - ); + const formatted = await formatSqlText("select id, name, email, created_at, updated_at, deleted_at, tenant_id from users where active = 1 and verified = 1 order by name, created_at desc", "postgres"); assert.match(formatted, /^SELECT\b/); assert.match(formatted, /\nFROM\b/); @@ -39,10 +36,7 @@ test("aligns SELECT field continuations under the first field", async () => { test("aligns explicit SELECT aliases in the default layout", async () => { const formatted = await formatSqlText("select bh.id as unique_value, bh.title as title, bh.house_type as type from biz_house bh", "mysql"); - assert.equal( - formatted, - "SELECT bh.id AS unique_value,\n bh.title AS title,\n bh.house_type AS type\nFROM biz_house bh", - ); + assert.equal(formatted, "SELECT bh.id AS unique_value,\n bh.title AS title,\n bh.house_type AS type\nFROM biz_house bh"); assert.equal(await formatSqlText(formatted, "mysql"), formatted); }); @@ -53,22 +47,18 @@ test("collapses a single-field statement that fits on one line", async () => { }); test("formats SQL with custom keyword case and indentation settings", async () => { - const formatted = await formatSqlText( - "select id, name, email, created_at, updated_at, deleted_at, tenant_id from users where active = 1 and verified = 1 order by name, created_at desc", - "postgres", - { - keywordCase: "lower", - dataTypeCase: "preserve", - functionCase: "preserve", - useTabs: true, - tabWidth: 2, - logicalOperatorNewline: "before", - expressionWidth: 50, - linesBetweenQueries: 1, - denseOperators: false, - newlineBeforeSemicolon: false, - }, - ); + const formatted = await formatSqlText("select id, name, email, created_at, updated_at, deleted_at, tenant_id from users where active = 1 and verified = 1 order by name, created_at desc", "postgres", { + keywordCase: "lower", + dataTypeCase: "preserve", + functionCase: "preserve", + useTabs: true, + tabWidth: 2, + logicalOperatorNewline: "before", + expressionWidth: 50, + linesBetweenQueries: 1, + denseOperators: false, + newlineBeforeSemicolon: false, + }); assert.match(formatted, /^select\b/); assert.match(formatted, /\nfrom\b/); @@ -91,16 +81,9 @@ test("keeps a trailing line comment from swallowing the column after it", async // line is followed by the next column on its own line. A `--` comment consumes // the rest of its line, so the next column has to start a new line instead of // being appended to that comment. - const formatted = await formatSqlText( - 'CREATE VIEW "TEMP_TEST_VIEW" AS SELECT trunc(sysdate) AS dates, -- 测试\n(SELECT sysdate FROM dual t) AS nows\nFROM dual;', - "oracle", - { keywordCase: "upper" }, - ); + const formatted = await formatSqlText('CREATE VIEW "TEMP_TEST_VIEW" AS SELECT trunc(sysdate) AS dates, -- 测试\n(SELECT sysdate FROM dual t) AS nows\nFROM dual;', "oracle", { keywordCase: "upper" }); - assert.equal( - formatted, - 'CREATE VIEW "TEMP_TEST_VIEW" AS\nSELECT trunc(sysdate) AS dates,\n -- 测试\n (SELECT sysdate FROM dual t) AS nows\nFROM dual;', - ); + assert.equal(formatted, 'CREATE VIEW "TEMP_TEST_VIEW" AS\nSELECT trunc(sysdate) AS dates,\n -- 测试\n (SELECT sysdate FROM dual t) AS nows\nFROM dual;'); }); test("keeps a line comment from swallowing the separator after it", async () => { @@ -336,3 +319,57 @@ test("formats SQL preserving original keyword case when keywordCase is preserve assert.doesNotMatch(formatted, /\bFROM\b/); assert.doesNotMatch(formatted, /\bWHERE\b/); }); + +test("formats SQL placing semicolons on their own line when newlineBeforeSemicolon is true (#12440)", async () => { + const selectSql = "SELECT id, name FROM users WHERE id = 1;"; + const formattedSelect = await formatSqlText(selectSql, "mysql", { + layoutStyle: "dbx", + newlineBeforeSemicolon: true, + }); + assert.match(formattedSelect, /\n;$/); + + const updateSql = "UPDATE users SET name = 'Alice' WHERE id = 1;"; + const formattedUpdate = await formatSqlText(updateSql, "mysql", { + layoutStyle: "dbx", + newlineBeforeSemicolon: true, + }); + assert.match(formattedUpdate, /\n;$/); + + const collapsedSql = "SELECT 1;"; + const formattedCollapsed = await formatSqlText(collapsedSql, "mysql", { + layoutStyle: "dbx", + newlineBeforeSemicolon: true, + }); + assert.equal(formattedCollapsed, "SELECT 1\n;"); + + const ddlSql = "CREATE TABLE users (id INT PRIMARY KEY, name VARCHAR(50));"; + const formattedDdl = await formatSqlText(ddlSql, "mysql", { + layoutStyle: "dbx", + newlineBeforeSemicolon: true, + }); + assert.match(formattedDdl, /\n;$/); + + const multiSql = "SELECT 1; SELECT 2;"; + const formattedMulti = await formatSqlText(multiSql, "mysql", { + layoutStyle: "dbx", + newlineBeforeSemicolon: true, + }); + assert.match(formattedMulti, /SELECT 1\n;\n\nSELECT 2\n;/); +}); + +test("formats SQL with semicolons on same line by default when newlineBeforeSemicolon is false (#12440)", async () => { + const selectSql = "SELECT id, name FROM users WHERE id = 1;"; + const formattedSelect = await formatSqlText(selectSql, "mysql", { + layoutStyle: "dbx", + newlineBeforeSemicolon: false, + }); + assert.match(formattedSelect, /1;$/); + assert.doesNotMatch(formattedSelect, /\n;$/); + + const collapsedSql = "SELECT 1;"; + const formattedCollapsed = await formatSqlText(collapsedSql, "mysql", { + layoutStyle: "dbx", + newlineBeforeSemicolon: false, + }); + assert.equal(formattedCollapsed, "SELECT 1;"); +});