From 0b4159372ea2f9eda03ffcd7a806c2e8bd22b1c8 Mon Sep 17 00:00:00 2001 From: zipg Date: Mon, 24 Aug 2026 20:31:51 +0800 Subject: [PATCH] fix(formatter): keep ClickHouse date abbreviation alias case --- .../lib/__tests__/sql/sqlFormatter.spec.ts | 36 +++++++++++++++++++ apps/desktop/src/lib/sql/sqlFormatter.ts | 27 +++++++++++++- 2 files changed, 62 insertions(+), 1 deletion(-) diff --git a/apps/desktop/src/lib/__tests__/sql/sqlFormatter.spec.ts b/apps/desktop/src/lib/__tests__/sql/sqlFormatter.spec.ts index 49a7149928..9d38a9f2e4 100644 --- a/apps/desktop/src/lib/__tests__/sql/sqlFormatter.spec.ts +++ b/apps/desktop/src/lib/__tests__/sql/sqlFormatter.spec.ts @@ -124,6 +124,42 @@ OR inside$$ as note expect(formatted).toContain("x -> dictGet"); expect(formatted).not.toContain("- >"); }); + + it("preserves the ClickHouse table alias from issue #7079", async () => { + const formatted = await formatSqlText("SELECT *\nFROM MATERIAL m\nLIMIT 100;", sqlFormatDialectForDbType("clickhouse")); + + expect(formatted).toBe("SELECT\n *\nFROM\n MATERIAL m\nLIMIT\n 100;"); + }); + + it.each(["d", "dd", "h", "hh", "m", "mcs", "mi", "mm", "ms", "n", "ns", "q", "qq", "s", "ss", "wk", "ww", "yy", "yyyy"])("preserves ClickHouse identifier-like date part %s while formatting keywords", async (identifier) => { + const formatted = await formatSqlText(`select ${identifier}, t.${identifier} from material ${identifier} limit 100;`, sqlFormatDialectForDbType("clickhouse")); + + expect(formatted).toContain(` ${identifier},`); + expect(formatted).toContain(`material ${identifier}`); + expect(formatted).toContain("SELECT"); + expect(formatted).toContain("FROM"); + expect(formatted).toContain("LIMIT"); + }); + + it("keeps ClickHouse identifier casing independent from keyword casing", async () => { + const sql = "SELECT * FROM MATERIAL M LIMIT 100;"; + + const lowerKeywords = await formatSqlText(sql, sqlFormatDialectForDbType("clickhouse"), { keywordCase: "lower", identifierCase: "preserve" }); + const lowerIdentifiers = await formatSqlText(sql, sqlFormatDialectForDbType("clickhouse"), { keywordCase: "upper", identifierCase: "lower" }); + + expect(lowerKeywords).toContain("from\n MATERIAL M"); + expect(lowerKeywords).toContain("limit\n 100"); + expect(lowerIdentifiers).toContain("FROM\n material m"); + expect(lowerIdentifiers).toContain("LIMIT\n 100"); + }); + + it("still formats unambiguous ClickHouse interval keywords", async () => { + const formatted = await formatSqlText("select now() + interval 1 minutes from source_table m;", sqlFormatDialectForDbType("clickhouse")); + + expect(formatted).toContain("INTERVAL 1 MINUTES"); + expect(formatted).toContain("FROM\n source_table m"); + }); + it("preserves DBX brace placeholders in generic and MySQL SQL", async () => { const sql = "SELECT ${x} AS shell_value, #{x} AS mybatis_value, '${date}' AS quoted_value"; diff --git a/apps/desktop/src/lib/sql/sqlFormatter.ts b/apps/desktop/src/lib/sql/sqlFormatter.ts index 69f0f4370f..25a5468c09 100644 --- a/apps/desktop/src/lib/sql/sqlFormatter.ts +++ b/apps/desktop/src/lib/sql/sqlFormatter.ts @@ -5,6 +5,27 @@ export type SqlFormatDialect = "mysql" | "postgres" | "sqlite" | "sqlserver" | " export const MAX_SQL_FORMAT_CHARS = 1_000_000; +type SqlFormatterModule = typeof import("sql-formatter"); + +// sql-formatter classifies ClickHouse date-part abbreviations as reserved +// keywords even where ClickHouse accepts them as ordinary identifiers. Keep +// them identifier-like so keyword casing cannot rewrite aliases such as `m`. +const CLICKHOUSE_IDENTIFIER_LIKE_DATE_PARTS = new Set(["D", "DD", "H", "HH", "M", "MCS", "MI", "MM", "MS", "N", "NS", "Q", "QQ", "S", "SS", "WK", "WW", "YY", "YYYY"]); +let clickHouseIdentifierSafeDialect: SqlFormatterModule["clickhouse"] | null = null; + +function resolveClickHouseIdentifierSafeDialect(sqlFormatter: SqlFormatterModule): SqlFormatterModule["clickhouse"] { + if (clickHouseIdentifierSafeDialect) return clickHouseIdentifierSafeDialect; + + clickHouseIdentifierSafeDialect = { + ...sqlFormatter.clickhouse, + tokenizerOptions: { + ...sqlFormatter.clickhouse.tokenizerOptions, + reservedKeywords: sqlFormatter.clickhouse.tokenizerOptions.reservedKeywords.filter((keyword) => !CLICKHOUSE_IDENTIFIER_LIKE_DATE_PARTS.has(keyword)), + }, + }; + return clickHouseIdentifierSafeDialect; +} + export function canFormatSqlForDatabaseType(dbType: string | null | undefined): boolean { return dbType !== "victoriametrics"; } @@ -223,7 +244,8 @@ export async function formatSqlText(sql: string, dialect: SqlFormatDialect = "ge throw new UnsupportedStructuredInputError("xml"); } - const { format } = await import("sql-formatter"); + const sqlFormatter = await import("sql-formatter"); + const { format, formatDialect } = sqlFormatter; const normalizedSettings = normalizeSqlFormatterSettings(settings); const options = sqlFormatterOptions(normalizedSettings); const language = formatterLanguage(dialect); @@ -244,6 +266,9 @@ export async function formatSqlText(sql: string, dialect: SqlFormatDialect = "ge : options; const formatWithFallback = (input: string): string => { try { + if (dialect === "clickhouse") { + return formatDialect(input, { ...formatterOptions, dialect: resolveClickHouseIdentifierSafeDialect(sqlFormatter) }); + } return format(input, { language, ...formatterOptions }); } catch (err) { // The generic "sql" dialect can't parse many real-world constructs (PostgreSQL