diff --git a/apps/desktop/src/lib/__tests__/sql/sqlFormatter.spec.ts b/apps/desktop/src/lib/__tests__/sql/sqlFormatter.spec.ts index 308bb82f2c..dfe269aee7 100644 --- a/apps/desktop/src/lib/__tests__/sql/sqlFormatter.spec.ts +++ b/apps/desktop/src/lib/__tests__/sql/sqlFormatter.spec.ts @@ -14,6 +14,10 @@ describe("sqlFormatter", () => { } }); + it("maps Dameng to its scoped formatter dialect", () => { + expect(sqlFormatDialectForDbType("dameng")).toBe("dameng"); + }); + it("preserves ClickHouse lambda arrows when formatting issue #3573 SQL", async () => { const sql = ` WITH industry_code_donghua_id_RYCzfD AS (SELECT id @@ -50,6 +54,35 @@ describe("sqlFormatter", () => { expect(formatted).toContain("AS id"); }); + it("formats Dameng SQL with a standalone trailing dot without changing the invalid token", async () => { + const sql = `SELECT JS1.REC_CREATOR as "recCreator", JS1.REC_CREATOR_JOB_ID as "recCreatorJobId" FROM APSSC.TMPJS01 JS1 WHERE 1=1 AND JS1.SUBSTR(REC_CREATE_TIME,1,8) = ? ORDER BY DECODE(JS1.STATUS,'DRAFT',1,'PENDING_APPROVAL',2,'APPROVED',3,'POSTED',4,'REJECTED',5,'DELETED',6), JS1.REC_CREATE_TIME DESC .`; + + const formatted = await formatSqlForEditing(sql, sqlFormatDialectForDbType("dameng")); + + expect(formatted).toContain('JS1.REC_CREATOR AS "recCreator"'); + expect(formatted).toContain("DECODE ("); + expect(formatted.endsWith("JS1.REC_CREATE_TIME DESC .")).toBe(true); + }); + + it("only recovers a whitespace-separated final dot", async () => { + await expect(formatSqlText("SELECT schema.", "dameng")).rejects.toThrow(); + await expect(formatSqlText("SELECT 1..", "dameng")).rejects.toThrow(); + await expect(formatSqlText("SELECT 'value .'", "dameng")).resolves.toContain("'value .'"); + }); + + it("preserves whitespace after a recovered trailing dot", async () => { + await expect(formatSqlForEditing("SELECT 1 .\n", "dameng")).resolves.toBe("SELECT\n 1 .\n"); + }); + + it("preserves the newline before a trailing dot after a line comment", async () => { + await expect(formatSqlForEditing("DELETE FROM accounts -- comment\n .", "dameng")).resolves.toBe("DELETE FROM accounts -- comment\n ."); + }); + + it("does not change trailing-dot formatting for other databases", async () => { + await expect(formatSqlText("SELECT 1 .", "generic")).rejects.toThrow(); + await expect(formatSqlForEditing("SELECT 1 .", "generic")).resolves.toBe("SELECT 1 ."); + }); + it("keeps incomplete editor SQL unchanged when the formatter cannot parse it", async () => { const sql = "select *\nfrom dbname.\n;"; diff --git a/apps/desktop/src/lib/sql/sqlFormatter.ts b/apps/desktop/src/lib/sql/sqlFormatter.ts index 6c53e934dc..514b28e440 100644 --- a/apps/desktop/src/lib/sql/sqlFormatter.ts +++ b/apps/desktop/src/lib/sql/sqlFormatter.ts @@ -1,7 +1,7 @@ import { DEFAULT_SQL_FORMATTER_SETTINGS, normalizeSqlFormatterSettings, sqlFormatterOptions, type SqlFormatterSettings } from "@/lib/sql/sqlFormatterConfig"; import { looksLikeXml } from "@/lib/sql/autoFormat"; -export type SqlFormatDialect = "mysql" | "postgres" | "sqlite" | "sqlserver" | "clickhouse" | "generic"; +export type SqlFormatDialect = "mysql" | "postgres" | "sqlite" | "sqlserver" | "clickhouse" | "dameng" | "generic"; export const MAX_SQL_FORMAT_CHARS = 1_000_000; @@ -53,6 +53,8 @@ export function sqlFormatDialectForDbType(dbType: string | null | undefined): Sq return "sqlserver"; case "clickhouse": return "clickhouse"; + case "dameng": + return "dameng"; default: return "generic"; } @@ -75,6 +77,16 @@ function formatterLanguage(dialect: SqlFormatDialect) { } } +function splitTrailingStandaloneDot(sql: string): { body: string; suffix: string } | null { + const match = /\s+\.(\s*)$/.exec(sql); + if (!match || match.index === 0) return null; + + return { + body: sql.slice(0, match.index), + suffix: match[0], + }; +} + export async function formatSqlText(sql: string, dialect: SqlFormatDialect = "generic", settings: Partial = DEFAULT_SQL_FORMATTER_SETTINGS): Promise { if (!sql.trim()) return sql; if (sql.length > MAX_SQL_FORMAT_CHARS) { @@ -89,23 +101,37 @@ export async function formatSqlText(sql: string, dialect: SqlFormatDialect = "ge const normalizedSettings = normalizeSqlFormatterSettings(settings); const options = sqlFormatterOptions(normalizedSettings); const language = formatterLanguage(dialect); - try { - const formatted = format(sql, { language, ...options }); - return applySqlFormatterLayout(formatted, normalizedSettings, dialect); - } catch (err) { - // The generic "sql" dialect can't parse many real-world constructs (PostgreSQL - // `::` casts, GaussDB/openGauss materialized-view DDL, T-SQL specifics, ...). - // Retry once with the more permissive PostgreSQL grammar, which is a superset - // that tolerates most of these, before surfacing the failure. - if (language !== "postgresql") { - try { - const formatted = format(sql, { language: "postgresql", ...options }); - return applySqlFormatterLayout(formatted, normalizedSettings, dialect); - } catch { - // fall through to the original error below + const formatWithFallback = (input: string): string => { + try { + return format(input, { language, ...options }); + } catch (err) { + // The generic "sql" dialect can't parse many real-world constructs (PostgreSQL + // `::` casts, GaussDB/openGauss materialized-view DDL, T-SQL specifics, ...). + // Retry once with the more permissive PostgreSQL grammar, which is a superset + // that tolerates most of these, before surfacing the failure. + if (language !== "postgresql") { + try { + return format(input, { language: "postgresql", ...options }); + } catch { + // fall through to the original error below + } } + throw err; + } + }; + + try { + return applySqlFormatterLayout(formatWithFallback(sql), normalizedSettings, dialect); + } catch (err) { + const trailingDot = dialect === "dameng" ? splitTrailingStandaloneDot(sql) : null; + if (!trailingDot) throw err; + + try { + const formatted = applySqlFormatterLayout(formatWithFallback(trailingDot.body), normalizedSettings, dialect); + return `${formatted}${trailingDot.suffix}`; + } catch { + throw err; } - throw err; } }