diff --git a/apps/desktop/src/components/editor/SqlFormatterSettingsPanel.vue b/apps/desktop/src/components/editor/SqlFormatterSettingsPanel.vue index 237a11d5e6..40a529729d 100644 --- a/apps/desktop/src/components/editor/SqlFormatterSettingsPanel.vue +++ b/apps/desktop/src/components/editor/SqlFormatterSettingsPanel.vue @@ -24,6 +24,7 @@ import { type SqlFormatterExpressionWidth, type SqlFormatterFromClauseLayout, type SqlFormatterIndentStyle, + type SqlFormatterLayoutStyle, type SqlFormatterLinesBetweenQueries, type SqlFormatterLogicalOperatorNewline, type SqlFormatterOptionSettings, @@ -100,10 +101,16 @@ const indentStyleOptions: { value: SqlFormatterIndentStyle; labelKey: string }[] { value: "tabularRight", labelKey: "settings.sqlFormatterIndentStyleTabularRight" }, ]; +const layoutStyleOptions: { value: SqlFormatterLayoutStyle; labelKey: string }[] = [ + { value: "dbx", labelKey: "settings.sqlFormatterLayoutStyleDbx" }, + { value: "classic", labelKey: "settings.sqlFormatterLayoutStyleClassic" }, +]; + const tabWidthOptions: SqlFormatterTabWidth[] = [2, 4]; const expressionWidthOptions: SqlFormatterExpressionWidth[] = [50, 80, 120]; const linesBetweenQueriesOptions: SqlFormatterLinesBetweenQueries[] = [0, 1, 2]; const sqlFormatterOptionLabelKeys: Record = { + layoutStyle: "settings.sqlFormatterLayoutStyle", keywordCase: "settings.sqlFormatterKeywordCase", dataTypeCase: "settings.sqlFormatterDataTypeCase", functionCase: "settings.sqlFormatterFunctionCase", @@ -201,6 +208,10 @@ function onIndentStyle(value: any) { if (value === "standard" || value === "tabularLeft" || value === "tabularRight") updateOption("indentStyle", value); } +function onLayoutStyle(value: any) { + if (value === "dbx" || value === "classic") updateOption("layoutStyle", value); +} + function onLogicalOperatorNewline(value: any) { if (value === "before" || value === "after" || value === "none") updateOption("logicalOperatorNewline", value); } @@ -578,7 +589,7 @@ onBeforeUnmount(() => { -
+
@@ -618,6 +629,20 @@ onBeforeUnmount(() => {
+ +
+ + +
diff --git a/apps/desktop/src/components/editor/__tests__/SqlFormatterLayoutStylePanel.spec.ts b/apps/desktop/src/components/editor/__tests__/SqlFormatterLayoutStylePanel.spec.ts new file mode 100644 index 0000000000..70b9d0c669 --- /dev/null +++ b/apps/desktop/src/components/editor/__tests__/SqlFormatterLayoutStylePanel.spec.ts @@ -0,0 +1,47 @@ +import { readFileSync } from "node:fs"; +import { describe, expect, it } from "vitest"; + +/** + * Source-level guard for the layout-style control in the SQL formatter panel. + * Mounting the panel would pull in the CodeMirror JSON editor, so the one + * contract that matters for issue #11228 — the style select changes only the + * engine, never any other option — is pinned against the component source + * instead. + * + * There is deliberately no "apply classic preset" action: the user's decision + * was that choosing the classic mode is enough, and a second control that + * writes only part of what the old style needs reads as a partial application + * of it. Do not re-add one — the two option values v0.6.16 also changed are + * documented, not applied for the user. + * + * This assertion reads the component as text, so it depends on its current + * formatting: `functionBody` requires the closing brace at column 0 — re-run + * this spec after any formatter run, and keep comments inside that function + * free of a `word:` shape, which the key-set check also scans for. + */ +const source = readFileSync(new URL("../SqlFormatterSettingsPanel.vue", import.meta.url), "utf8"); + +function functionBody(name: string): string { + const body = source.match(new RegExp(String.raw`function ${name}\([^)]*\) \{\n([\s\S]*?)\n\}`))?.[1]; + expect(body, `${name} not found in SqlFormatterSettingsPanel.vue`).toBeTruthy(); + return body!; +} + +describe("SQL formatter layout style panel wiring", () => { + it("writes only layoutStyle when the style select changes", () => { + const body = functionBody("onLayoutStyle"); + + expect(body).toContain('updateOption("layoutStyle", value)'); + expect(body).not.toContain("expressionWidth"); + expect(body).not.toContain("fromClauseLayout"); + }); + + it("offers both layout styles and no preset action", () => { + expect(source).toContain('{ value: "dbx", labelKey: "settings.sqlFormatterLayoutStyleDbx" }'); + expect(source).toContain('{ value: "classic", labelKey: "settings.sqlFormatterLayoutStyleClassic" }'); + // One control only: the classic mode is chosen here, and nothing writes the + // option values it used to imply. DEFAULT_SQL_FORMATTER_SETTINGS already is + // the dbx end state, so Restore Defaults covers the other direction. + expect(source).not.toContain("ApplyClassicPreset"); + }); +}); diff --git a/apps/desktop/src/i18n/__tests__/sqlFormatterLayoutStyleParity.spec.ts b/apps/desktop/src/i18n/__tests__/sqlFormatterLayoutStyleParity.spec.ts new file mode 100644 index 0000000000..22e5c6a0aa --- /dev/null +++ b/apps/desktop/src/i18n/__tests__/sqlFormatterLayoutStyleParity.spec.ts @@ -0,0 +1,54 @@ +import { describe, expect, it } from "vitest"; +import { readFileSync } from "node:fs"; + +/** + * Every locale must declare the SQL formatter layout-style strings in its own + * source file, not fall back to English. The check has to read the raw source: + * each non-en locale is wrapped in `withEnglishFallback(...)`, which deep-merges + * `en` under the locale at import time, so asserting on the imported modules + * would report the key as present even after a locale drops it. + */ +const LOCALE_FILES = ["az", "en", "es", "id", "it", "ja", "ko", "pt-BR", "ru", "tr", "zh-CN", "zh-TW"]; + +const LAYOUT_STYLE_KEYS = ["sqlFormatterLayoutStyle", "sqlFormatterLayoutStyleDbx", "sqlFormatterLayoutStyleClassic"]; + +function localeSource(name: string): string { + return readFileSync(new URL(`../locales/${name}.ts`, import.meta.url), "utf8"); +} + +function declaredString(source: string, key: string): string | undefined { + return source.match(new RegExp(String.raw`^\s*${key}:\s*"((?:[^"\\]|\\.)*)",\s*$`, "m"))?.[1]; +} + +describe("SQL formatter layout style i18n parity", () => { + it.each(LOCALE_FILES)("%s declares every layout-style key in its source", (name) => { + const source = localeSource(name); + for (const key of LAYOUT_STYLE_KEYS) { + expect(declaredString(source, key), `${name} is missing ${key}`).toBeTruthy(); + } + }); + + it.each(LOCALE_FILES)("%s anchors the cluster after the indent-style keys", (name) => { + const source = localeSource(name); + const anchor = source.indexOf("sqlFormatterIndentStyleTabularRight:"); + const layoutStyle = source.indexOf("sqlFormatterLayoutStyle:"); + + expect(anchor).toBeGreaterThan(-1); + expect(layoutStyle).toBeGreaterThan(anchor); + }); + + it("en carries the labels the settings panel shows", () => { + const source = localeSource("en"); + + expect(declaredString(source, "sqlFormatterLayoutStyle")).toBe("Layout style"); + expect(declaredString(source, "sqlFormatterLayoutStyleDbx")).toBe("DBX (default)"); + expect(declaredString(source, "sqlFormatterLayoutStyleClassic")).toBe("Classic"); + }); + + it("zh-CN translates the labels instead of leaving them in English", () => { + const source = localeSource("zh-CN"); + + expect(declaredString(source, "sqlFormatterLayoutStyle")).toBe("排版风格"); + expect(declaredString(source, "sqlFormatterLayoutStyleClassic")).toContain("经典"); + }); +}); diff --git a/apps/desktop/src/i18n/locales/az.ts b/apps/desktop/src/i18n/locales/az.ts index ae1493543a..dc631ca8eb 100644 --- a/apps/desktop/src/i18n/locales/az.ts +++ b/apps/desktop/src/i18n/locales/az.ts @@ -7086,6 +7086,9 @@ export default withEnglishFallback({ sqlFormatterIndentStyleStandard: "Standart", sqlFormatterIndentStyleTabularLeft: "Sola düzülmüş cədvəl", sqlFormatterIndentStyleTabularRight: "Sağa düzülmüş cədvəl", + sqlFormatterLayoutStyle: "Düzən üslubu", + sqlFormatterLayoutStyleDbx: "DBX (standart)", + sqlFormatterLayoutStyleClassic: "Klassik", sqlFormatterLogicalOperatorNewline: "Məntiqi operator üçün yeni sətir", sqlFormatterLogicalBefore: "Operatordan əvvəl", sqlFormatterLogicalAfter: "Operatordan sonra", diff --git a/apps/desktop/src/i18n/locales/en.ts b/apps/desktop/src/i18n/locales/en.ts index 451c6d5abe..5136aba37f 100644 --- a/apps/desktop/src/i18n/locales/en.ts +++ b/apps/desktop/src/i18n/locales/en.ts @@ -8086,6 +8086,9 @@ export default { sqlFormatterIndentStyleStandard: "Standard", sqlFormatterIndentStyleTabularLeft: "Tabular left", sqlFormatterIndentStyleTabularRight: "Tabular right", + sqlFormatterLayoutStyle: "Layout style", + sqlFormatterLayoutStyleDbx: "DBX (default)", + sqlFormatterLayoutStyleClassic: "Classic", sqlFormatterLogicalOperatorNewline: "Logical operator newline", sqlFormatterLogicalBefore: "Before operator", sqlFormatterLogicalAfter: "After operator", diff --git a/apps/desktop/src/i18n/locales/es.ts b/apps/desktop/src/i18n/locales/es.ts index 40af272bb6..129b838bfc 100644 --- a/apps/desktop/src/i18n/locales/es.ts +++ b/apps/desktop/src/i18n/locales/es.ts @@ -7375,6 +7375,9 @@ export default withEnglishFallback({ sqlFormatterIndentStyleStandard: "Estándar", sqlFormatterIndentStyleTabularLeft: "Tabular izquierda", sqlFormatterIndentStyleTabularRight: "Tabular derecha", + sqlFormatterLayoutStyle: "Estilo de diseño", + sqlFormatterLayoutStyleDbx: "DBX (predeterminado)", + sqlFormatterLayoutStyleClassic: "Clásico", sqlFormatterLogicalOperatorNewline: "Salto de línea en operador lógico", sqlFormatterLogicalBefore: "Antes del operador", sqlFormatterLogicalAfter: "Después del operador", diff --git a/apps/desktop/src/i18n/locales/id.ts b/apps/desktop/src/i18n/locales/id.ts index 3b75874fcb..7372aeb27e 100644 --- a/apps/desktop/src/i18n/locales/id.ts +++ b/apps/desktop/src/i18n/locales/id.ts @@ -7810,6 +7810,9 @@ export default withEnglishFallback({ sqlFormatterIndentStyleStandard: "Standar", sqlFormatterIndentStyleTabularLeft: "Tabular rata kiri", sqlFormatterIndentStyleTabularRight: "Tabular rata kanan", + sqlFormatterLayoutStyle: "Gaya tata letak", + sqlFormatterLayoutStyleDbx: "DBX (bawaan)", + sqlFormatterLayoutStyleClassic: "Klasik", sqlFormatterLogicalOperatorNewline: "Baris baru pada operator logika", sqlFormatterLogicalBefore: "Sebelum operator", sqlFormatterLogicalAfter: "Setelah operator", diff --git a/apps/desktop/src/i18n/locales/it.ts b/apps/desktop/src/i18n/locales/it.ts index d866077d4f..57be5194d5 100644 --- a/apps/desktop/src/i18n/locales/it.ts +++ b/apps/desktop/src/i18n/locales/it.ts @@ -7153,6 +7153,9 @@ export default withEnglishFallback({ sqlFormatterIndentStyleStandard: "Standard", sqlFormatterIndentStyleTabularLeft: "Tabellare sinistra", sqlFormatterIndentStyleTabularRight: "Tabellare destra", + sqlFormatterLayoutStyle: "Stile di impaginazione", + sqlFormatterLayoutStyleDbx: "DBX (predefinito)", + sqlFormatterLayoutStyleClassic: "Classico", sqlFormatterLogicalOperatorNewline: "Nuova riga operatore logico", sqlFormatterLogicalBefore: "Prima dell'operatore", sqlFormatterLogicalAfter: "Dopo l'operatore", diff --git a/apps/desktop/src/i18n/locales/ja.ts b/apps/desktop/src/i18n/locales/ja.ts index 535ff18ef5..1cd5e022c8 100644 --- a/apps/desktop/src/i18n/locales/ja.ts +++ b/apps/desktop/src/i18n/locales/ja.ts @@ -7182,6 +7182,9 @@ export default withEnglishFallback({ sqlFormatterIndentStyleStandard: "標準", sqlFormatterIndentStyleTabularLeft: "表形式左寄せ", sqlFormatterIndentStyleTabularRight: "表形式右寄せ", + sqlFormatterLayoutStyle: "レイアウトスタイル", + sqlFormatterLayoutStyleDbx: "DBX(デフォルト)", + sqlFormatterLayoutStyleClassic: "クラシック", sqlFormatterLogicalOperatorNewline: "論理演算子の改行", sqlFormatterLogicalBefore: "演算子の前", sqlFormatterLogicalAfter: "演算子の後", diff --git a/apps/desktop/src/i18n/locales/ko.ts b/apps/desktop/src/i18n/locales/ko.ts index 155ab95c91..6177b69636 100644 --- a/apps/desktop/src/i18n/locales/ko.ts +++ b/apps/desktop/src/i18n/locales/ko.ts @@ -6997,6 +6997,9 @@ export default withEnglishFallback({ sqlFormatterIndentStyleStandard: "표준", sqlFormatterIndentStyleTabularLeft: "표 왼쪽 정렬", sqlFormatterIndentStyleTabularRight: "표 오른쪽 정렬", + sqlFormatterLayoutStyle: "레이아웃 스타일", + sqlFormatterLayoutStyleDbx: "DBX(기본값)", + sqlFormatterLayoutStyleClassic: "클래식", sqlFormatterLogicalOperatorNewline: "논리 연산자 줄바꿈", sqlFormatterLogicalBefore: "연산자 앞", sqlFormatterLogicalAfter: "연산자 뒤", diff --git a/apps/desktop/src/i18n/locales/pt-BR.ts b/apps/desktop/src/i18n/locales/pt-BR.ts index f8ba4a0b56..73e23dab09 100644 --- a/apps/desktop/src/i18n/locales/pt-BR.ts +++ b/apps/desktop/src/i18n/locales/pt-BR.ts @@ -7153,6 +7153,9 @@ export default withEnglishFallback({ sqlFormatterIndentStyleStandard: "Padrão", sqlFormatterIndentStyleTabularLeft: "Tabular esquerda", sqlFormatterIndentStyleTabularRight: "Tabular direita", + sqlFormatterLayoutStyle: "Estilo de layout", + sqlFormatterLayoutStyleDbx: "DBX (padrão)", + sqlFormatterLayoutStyleClassic: "Clássico", sqlFormatterLogicalOperatorNewline: "Quebra de linha no operador lógico", sqlFormatterLogicalBefore: "Antes do operador", sqlFormatterLogicalAfter: "Depois do operador", diff --git a/apps/desktop/src/i18n/locales/ru.ts b/apps/desktop/src/i18n/locales/ru.ts index 32bd07e657..859bdedf49 100644 --- a/apps/desktop/src/i18n/locales/ru.ts +++ b/apps/desktop/src/i18n/locales/ru.ts @@ -8395,6 +8395,9 @@ export default withEnglishFallback({ sqlFormatterIndentStyleStandard: "Стандартный", sqlFormatterIndentStyleTabularLeft: "Табличный слева", sqlFormatterIndentStyleTabularRight: "Табличный справа", + sqlFormatterLayoutStyle: "Стиль разметки", + sqlFormatterLayoutStyleDbx: "DBX (по умолчанию)", + sqlFormatterLayoutStyleClassic: "Классический", sqlFormatterLogicalOperatorNewline: "Перенос логических операторов", sqlFormatterLogicalBefore: "Перед оператором", sqlFormatterLogicalAfter: "После оператора", diff --git a/apps/desktop/src/i18n/locales/tr.ts b/apps/desktop/src/i18n/locales/tr.ts index 133d4d0807..cfbbd0cdd9 100644 --- a/apps/desktop/src/i18n/locales/tr.ts +++ b/apps/desktop/src/i18n/locales/tr.ts @@ -6967,6 +6967,9 @@ export default withEnglishFallback({ sqlFormatterIndentStyleStandard: "Standart", sqlFormatterIndentStyleTabularLeft: "Tablo biçiminde sola", sqlFormatterIndentStyleTabularRight: "Tablo biçiminde sağa", + sqlFormatterLayoutStyle: "Düzen biçemi", + sqlFormatterLayoutStyleDbx: "DBX (varsayılan)", + sqlFormatterLayoutStyleClassic: "Klasik", sqlFormatterLogicalOperatorNewline: "Mantıksal işleçte satır sonu", sqlFormatterLogicalBefore: "İşleçten önce", sqlFormatterLogicalAfter: "İşleçten sonra", diff --git a/apps/desktop/src/i18n/locales/zh-CN.ts b/apps/desktop/src/i18n/locales/zh-CN.ts index 51378b58b1..93d93e7f93 100644 --- a/apps/desktop/src/i18n/locales/zh-CN.ts +++ b/apps/desktop/src/i18n/locales/zh-CN.ts @@ -8072,6 +8072,9 @@ export default withEnglishFallback({ sqlFormatterIndentStyleStandard: "标准", sqlFormatterIndentStyleTabularLeft: "表格式左对齐", sqlFormatterIndentStyleTabularRight: "表格式右对齐", + sqlFormatterLayoutStyle: "排版风格", + sqlFormatterLayoutStyleDbx: "DBX(默认)", + sqlFormatterLayoutStyleClassic: "经典", sqlFormatterLogicalOperatorNewline: "逻辑运算符换行", sqlFormatterLogicalBefore: "运算符前换行", sqlFormatterLogicalAfter: "运算符后换行", diff --git a/apps/desktop/src/i18n/locales/zh-TW.ts b/apps/desktop/src/i18n/locales/zh-TW.ts index 0c81eb110e..94c5f58166 100644 --- a/apps/desktop/src/i18n/locales/zh-TW.ts +++ b/apps/desktop/src/i18n/locales/zh-TW.ts @@ -6507,6 +6507,9 @@ export default withEnglishFallback({ sqlFormatterIndentStyleStandard: "標準", sqlFormatterIndentStyleTabularLeft: "表格式靠左", sqlFormatterIndentStyleTabularRight: "表格式靠右", + sqlFormatterLayoutStyle: "排版風格", + sqlFormatterLayoutStyleDbx: "DBX(預設)", + sqlFormatterLayoutStyleClassic: "經典", sqlFormatterLogicalOperatorNewline: "邏輯運算子換行", sqlFormatterLogicalBefore: "運算子前換行", sqlFormatterLogicalAfter: "運算子後換行", diff --git a/apps/desktop/src/lib/__tests__/sql/sqlFormatter.spec.ts b/apps/desktop/src/lib/__tests__/sql/sqlFormatter.spec.ts index b6dcb1f0f9..8a05897f2e 100644 --- a/apps/desktop/src/lib/__tests__/sql/sqlFormatter.spec.ts +++ b/apps/desktop/src/lib/__tests__/sql/sqlFormatter.spec.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from "vitest"; import { canFormatSqlForDatabaseType, compressSqlText, formatSqlForDisplay, formatSqlForEditing, formatSqlText, MAX_SQL_FORMAT_CHARS, sqlFormatDialectForDbType, UnsupportedStructuredInputError } from "@/lib/sql/sqlFormatter"; +import type { SqlFormatterSettings } from "@/lib/sql/sqlFormatterConfig"; import { extractSqlParameters } from "@/lib/sql/sqlParameters"; describe("Neo4j Cypher formatting", () => { @@ -650,3 +651,153 @@ AND owner_id = 42`, expect(formatted).toMatch(/,\s*col2\s*-- second column/); }); }); + +/** + * `layoutStyle: "classic"` selects sql-formatter's own layout — the default + * before v0.6.16 replaced it with DBX's printer — so these expectations are the + * byte-for-byte output of the verbatim v0.6.15 `formatSqlText` (issue #11228). + * The layout engine alone is not a v0.6.15 reproduction: v0.6.16 also changed + * the `expressionWidth` and `fromClauseLayout` defaults, so both are set back + * explicitly. The remaining options already match the v0.6.15 defaults. + * + * Regenerate with `git show 455117b8c5^:apps/desktop/src/lib/sql/sqlFormatter.ts` + * imported into a throwaway spec and called with these settings; the goldens + * below are literals on purpose so the spec never depends on the old source. + */ +describe("classic layout style (v0.6.15 reproduction)", () => { + const classic: Partial = { layoutStyle: "classic", expressionWidth: 50, fromClauseLayout: "newLine" }; + const lines = (...rows: string[]) => rows.join("\n"); + + it("reproduces a MySQL SELECT with a JOIN", async () => { + const sql = "select a.id, a.name, b.total, case when a.status = 1 then 'active' else 'inactive' end as status_label from users a left join orders b on a.id = b.user_id where a.created_at >= '2024-01-01' and b.total > 100 order by a.created_at desc limit 50;"; + + expect(await formatSqlText(sql, "mysql", classic)).toBe( + lines( + "SELECT", + " a.id,", + " a.name,", + " b.total,", + " CASE", + " WHEN a.status = 1 THEN 'active'", + " ELSE 'inactive'", + " END AS status_label", + "FROM", + " users a", + " LEFT JOIN orders b ON a.id = b.user_id", + "WHERE", + " a.created_at >= '2024-01-01'", + " AND b.total > 100", + "ORDER BY", + " a.created_at DESC", + "LIMIT", + " 50;", + ), + ); + }); + + it("reproduces a MySQL CTE", async () => { + const sql = "with t as (select id, count(*) c from logs group by id) select id, c from t where c > 10"; + + expect(await formatSqlText(sql, "mysql", classic)).toBe(lines("WITH", " t AS (", " SELECT", " id,", " count(*) c", " FROM", " logs", " GROUP BY", " id", " )", "SELECT", " id,", " c", "FROM", " t", "WHERE", " c > 10")); + }); + + it("reproduces a MySQL CREATE TABLE", async () => { + const sql = + "CREATE TABLE IF NOT EXISTS `delivery_emp_info` (`id` varchar(20) NOT NULL comment 'pk', `emp_id` varchar(20) NOT NULL comment 'emp id', `delivery_type_ids` varchar(512) default NULL comment 'types', `update_time` datetime NOT NULL comment 'updated', CONSTRAINT idx_emp_phone UNIQUE (emp_id)) engine = innodb default charset = utf8mb4 comment 'emp info';"; + + // `default` / `comment` staying lowercase is genuine upstream behaviour + // (they are not keyword-cased in these positions), not a DBX bug to fix. + expect(await formatSqlText(sql, "mysql", classic)).toBe( + lines( + "CREATE TABLE IF NOT EXISTS `delivery_emp_info` (", + " `id` varchar(20) NOT NULL comment 'pk',", + " `emp_id` varchar(20) NOT NULL comment 'emp id',", + " `delivery_type_ids` varchar(512) default NULL comment 'types',", + " `update_time` datetime NOT NULL comment 'updated',", + " CONSTRAINT idx_emp_phone UNIQUE (emp_id)", + ") engine = innodb default charset = utf8mb4 comment 'emp info';", + ), + ); + }); + + it("reproduces comments and blank lines", async () => { + const sql = "-- 头部说明\nselect id, name from users where status = 1;\n\n/* 第二段 */\nselect count(*) from orders where total > 100 and status = 'paid';"; + + expect(await formatSqlText(sql, "mysql", classic)).toBe(lines("-- 头部说明", "SELECT", " id,", " name", "FROM", " users", "WHERE", " status = 1;", "", "/* 第二段 */", "SELECT", " count(*)", "FROM", " orders", "WHERE", " total > 100", " AND status = 'paid';")); + }); + + it("reproduces a Doris/generic query", async () => { + // Doris and StarRocks have no dedicated formatter dialect, so they exercise + // the generic grammar plus its PostgreSQL retry. + const sql = "select `db`.`t`.`k`, sum(`db`.`t`.`v`) as total from `db`.`t` where `db`.`t`.`dt` = '2024-01-01' group by `db`.`t`.`k` having total > 10 order by total desc limit 20;"; + + expect(await formatSqlText(sql, "generic", classic)).toBe(lines("SELECT", " `db`.`t`.`k`,", " sum(`db`.`t`.`v`) AS total", "FROM", " `db`.`t`", "WHERE", " `db`.`t`.`dt` = '2024-01-01'", "GROUP BY", " `db`.`t`.`k`", "HAVING", " total > 10", "ORDER BY", " total DESC", "LIMIT", " 20;")); + }); + + it("reproduces a short statement without collapsing it", async () => { + const sql = "select * from task order by created_at desc;"; + + expect(await formatSqlText(sql, "mysql", classic)).toBe(lines("SELECT", " *", "FROM", " task", "ORDER BY", " created_at DESC;")); + }); + + it("reproduces a nested subquery", async () => { + const sql = "select loc_id, loc_name from (select *, row_number() over (partition by loc_id order by update_time desc) as rn from responsible_area_info where date_format(update_time, '%Y-%m') = date_format(now(), '%Y-%m')) t where rn = 1;"; + + expect(await formatSqlText(sql, "mysql", classic)).toBe( + lines( + "SELECT", + " loc_id,", + " loc_name", + "FROM", + " (", + " SELECT", + " *,", + " row_number() OVER (", + " PARTITION BY", + " loc_id", + " ORDER BY", + " update_time DESC", + " ) AS rn", + " FROM", + " responsible_area_info", + " WHERE", + " date_format(update_time, '%Y-%m') = date_format(now(), '%Y-%m')", + " ) t", + "WHERE", + " rn = 1;", + ), + ); + }); + + it("normalizes the Oracle SUBSTRB family under classic (intentional divergence from v0.6.15)", async () => { + // v0.6.15 emitted `substrb (NAME, 1, 3)` — the byte/multibyte variants were + // unknown to sql-formatter's plsql grammar, so they read as identifiers. + // #11251 registers them as function names, and the classic path keeps that + // fix: these assertions pin the new behaviour, they must not be reverted. + const sql = "select substrb(name, 1, 3), instr2(name, 'x'), lengthb(name) from t;"; + + await expect(formatSqlText(sql, "oracle", { ...classic, functionCase: "upper" })).resolves.toBe(lines("SELECT", " SUBSTRB(NAME, 1, 3),", " INSTR2(NAME, 'x'),", " LENGTHB(NAME)", "FROM", " t;")); + }); + + it("keeps DBX's own printer as the default and for the explicit dbx style", async () => { + const sql = "select * from task order by created_at desc;"; + const collapsed = "SELECT * FROM task ORDER BY created_at DESC;"; + + // Same input, opposite layouts: the collapsed form is the DBX printer's, + // so this proves the routing split rather than the goldens above. + expect(await formatSqlText(sql, "mysql", { layoutStyle: "dbx" })).toBe(collapsed); + expect(await formatSqlText(sql, "mysql")).toBe(collapsed); + // A settings object saved before `layoutStyle` existed normalizes to dbx. + expect(await formatSqlText(sql, "mysql", {} as Partial)).toBe(collapsed); + }); + + it("merges a classic FROM clause onto its source with a single space under the standard indent", async () => { + // `sameLine` (the default) merges the clause line with the source line, and + // the merge must not reuse the source's indent as the gap: that is the + // tabular styles' alignment column, but under the standard indent it would + // read `FROM t`. Selecting classic is now the only path to this merge, so + // the classic style is what pins its appearance. + expect(await formatSqlText("select * from t", "mysql", classic)).toBe(lines("SELECT", " *", "FROM", " t")); + expect(await formatSqlText("select * from t", "mysql", { ...classic, fromClauseLayout: "sameLine" })).toBe(lines("SELECT", " *", "FROM t")); + }); +}); diff --git a/apps/desktop/src/lib/__tests__/sql/sqlFormatterConfig.spec.ts b/apps/desktop/src/lib/__tests__/sql/sqlFormatterConfig.spec.ts index ec37ae58ea..73a417b315 100644 --- a/apps/desktop/src/lib/__tests__/sql/sqlFormatterConfig.spec.ts +++ b/apps/desktop/src/lib/__tests__/sql/sqlFormatterConfig.spec.ts @@ -89,3 +89,42 @@ describe("sqlFormatterConfig shortcut storage", () => { expect("commaPosition" in sqlFormatterOptions({ commaPosition: "before" })).toBe(false); }); }); + +describe("sqlFormatterConfig layout style", () => { + it("defaults to the DBX layout and keeps it out of the sql-formatter options", () => { + const config = JSON.parse(serializeSqlFormatterConfig({})); + + expect(config.options.layoutStyle).toBe("dbx"); + // layoutStyle is DBX-private: handing it to sql-formatter would throw. + expect("layoutStyle" in sqlFormatterOptions({ layoutStyle: "classic" })).toBe(false); + }); + + it("round-trips the classic layout style", () => { + const serialized = JSON.parse(serializeSqlFormatterConfig({ layoutStyle: "classic" })); + expect(serialized.options.layoutStyle).toBe("classic"); + + const parsed = parseSqlFormatterConfig(serializeSqlFormatterConfig({ layoutStyle: "classic" })); + expect(parsed).toEqual(expect.objectContaining({ ok: true })); + if (parsed.ok) expect(parsed.settings.layoutStyle).toBe("classic"); + }); + + it("rejects a layout style outside the accepted values", () => { + const result = parseSqlFormatterConfig(JSON.stringify({ version: 2, formatter: "sql-formatter", options: { layoutStyle: "upstream" } })); + + expect(result).toEqual({ ok: false, message: "Invalid formatter option value: layoutStyle." }); + }); + + it("accepts both config versions and rejects anything else", () => { + // Version 1 predates layoutStyle; absence normalizes to the current + // default and must never be read as a historical choice of classic. + const version1 = parseSqlFormatterConfig(JSON.stringify({ version: 1, formatter: "sql-formatter", options: { keywordCase: "lower" } })); + expect(version1).toEqual(expect.objectContaining({ ok: true })); + if (version1.ok) { + expect(version1.settings.layoutStyle).toBe("dbx"); + expect(version1.settings.keywordCase).toBe("lower"); + } + + expect(parseSqlFormatterConfig(JSON.stringify({ version: 2, formatter: "sql-formatter", options: { layoutStyle: "classic" } }))).toEqual(expect.objectContaining({ ok: true })); + expect(parseSqlFormatterConfig(JSON.stringify({ version: 3, formatter: "sql-formatter", options: {} }))).toEqual({ ok: false, message: "Unsupported config version." }); + }); +}); diff --git a/apps/desktop/src/lib/settings/__tests__/settingsSearch.spec.ts b/apps/desktop/src/lib/settings/__tests__/settingsSearch.spec.ts index 4fc392f79d..6a65a789a7 100644 --- a/apps/desktop/src/lib/settings/__tests__/settingsSearch.spec.ts +++ b/apps/desktop/src/lib/settings/__tests__/settingsSearch.spec.ts @@ -327,7 +327,10 @@ describe("settings search", () => { { titleKey: "settings.sqlFormatterIndent", category: "formatter", targetId: "formatter" }, { titleKey: "settings.sqlFormatterTabWidth", category: "formatter", targetId: "formatter" }, { titleKey: "settings.sqlFormatterIndentStyle", category: "formatter", targetId: "formatter" }, + { titleKey: "settings.sqlFormatterLayoutStyle", category: "formatter", targetId: "formatter" }, { titleKey: "settings.sqlFormatterLogicalOperatorNewline", category: "formatter", targetId: "formatter" }, + { titleKey: "settings.sqlFormatterCommaPosition", category: "formatter", targetId: "formatter" }, + { titleKey: "settings.sqlFormatterFromClauseLayout", category: "formatter", targetId: "formatter" }, { titleKey: "settings.sqlFormatterExpressionWidth", category: "formatter", targetId: "formatter" }, { titleKey: "settings.sqlFormatterLinesBetweenQueries", category: "formatter", targetId: "formatter" }, { titleKey: "settings.sqlFormatterPreserveEmptyLines", category: "formatter", targetId: "formatter" }, diff --git a/apps/desktop/src/lib/settings/__tests__/settingsTransfer.spec.ts b/apps/desktop/src/lib/settings/__tests__/settingsTransfer.spec.ts index daa2d021bd..c5e0910663 100644 --- a/apps/desktop/src/lib/settings/__tests__/settingsTransfer.spec.ts +++ b/apps/desktop/src/lib/settings/__tests__/settingsTransfer.spec.ts @@ -502,6 +502,45 @@ describe("settingsTransfer", () => { expect(result.error.detail).toContain("sqlFormatter"); }); + it("rejects unknown formatter option keys", () => { + const result = parseSettingsTransferFile(fileWith({ sqlFormatter: { ...DEFAULT_EDITOR_SETTINGS.sqlFormatter, layoutEngine: "dbx" } })); + expect(result.ok).toBe(false); + if (result.ok) return; + expect(result.error.detail).toContain("sqlFormatter"); + }); + + it("rejects an invalid value for a formatter option that is present", () => { + const result = parseSettingsTransferFile(fileWith({ sqlFormatter: { ...DEFAULT_EDITOR_SETTINGS.sqlFormatter, layoutStyle: "upstream" } })); + expect(result.ok).toBe(false); + if (result.ok) return; + expect(result.error.detail).toContain("sqlFormatter"); + }); + + it("imports a formatter payload exported before commaPosition existed", () => { + // Regression: v0.6.35 made the completeness check require every current + // key, so files exported by any earlier version failed to import at all. + const legacy: Record = { ...DEFAULT_EDITOR_SETTINGS.sqlFormatter }; + delete legacy.commaPosition; + const result = parseSettingsTransferFile(fileWith({ sqlFormatter: legacy })); + + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.value.editorSettings.sqlFormatter?.commaPosition).toBe("after"); + expect(result.value.editorSettings.sqlFormatter?.keywordCase).toBe(DEFAULT_EDITOR_SETTINGS.sqlFormatter.keywordCase); + }); + + it("imports a formatter payload missing both post-transfer keys and defaults them", () => { + const legacy: Record = { ...DEFAULT_EDITOR_SETTINGS.sqlFormatter }; + delete legacy.commaPosition; + delete legacy.layoutStyle; + const result = parseSettingsTransferFile(fileWith({ sqlFormatter: legacy })); + + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.value.editorSettings.sqlFormatter?.layoutStyle).toBe("dbx"); + expect(result.value.editorSettings.sqlFormatter?.commaPosition).toBe("after"); + }); + it("rejects malformed sql variable syntax overrides", () => { const result = parseSettingsTransferFile(fileWith({ sqlVariableSyntaxOverrides: { mysql: { positional: "yes" } } })); expect(result.ok).toBe(false); diff --git a/apps/desktop/src/lib/settings/settingsSearch.ts b/apps/desktop/src/lib/settings/settingsSearch.ts index 2c97b06589..e38938cbf2 100644 --- a/apps/desktop/src/lib/settings/settingsSearch.ts +++ b/apps/desktop/src/lib/settings/settingsSearch.ts @@ -176,6 +176,7 @@ export const SETTINGS_SEARCH_DEFINITIONS: readonly SettingsSearchDefinition[] = { id: "formatter-indent", category: "formatter", titleKey: "settings.sqlFormatterIndent", targetId: "formatter" }, { id: "formatter-tab-width", category: "formatter", titleKey: "settings.sqlFormatterTabWidth", targetId: "formatter" }, { id: "formatter-indent-style", category: "formatter", titleKey: "settings.sqlFormatterIndentStyle", targetId: "formatter" }, + { id: "formatter-layout-style", category: "formatter", titleKey: "settings.sqlFormatterLayoutStyle", targetId: "formatter" }, { id: "formatter-logical-operator-newline", category: "formatter", titleKey: "settings.sqlFormatterLogicalOperatorNewline", targetId: "formatter" }, { id: "formatter-comma-position", category: "formatter", titleKey: "settings.sqlFormatterCommaPosition", targetId: "formatter" }, { id: "formatter-from-clause-layout", category: "formatter", titleKey: "settings.sqlFormatterFromClauseLayout", targetId: "formatter" }, diff --git a/apps/desktop/src/lib/settings/settingsTransfer.ts b/apps/desktop/src/lib/settings/settingsTransfer.ts index 699e4f094f..47dcf7ca58 100644 --- a/apps/desktop/src/lib/settings/settingsTransfer.ts +++ b/apps/desktop/src/lib/settings/settingsTransfer.ts @@ -3,7 +3,7 @@ import { DEFAULT_CUSTOM_THEME_COLORS, DEFAULT_CUSTOM_THEME_DDL_COLORS, DEFAULT_E import { EDITOR_MAX_FONT_SIZE, EDITOR_MIN_FONT_SIZE } from "@/lib/editor/editorZoom"; import { DATA_GRID_TYPE_COLOR_KEYS, DATA_GRID_TYPE_COLOR_SCHEME_AUTO_ID } from "@/lib/dataGrid/dataGridTypeColorScheme"; import { SHORTCUT_DEFINITIONS } from "@/lib/editor/shortcutRegistry"; -import { isCompleteSqlFormatterSettings } from "@/lib/sql/sqlFormatterConfig"; +import { DEFAULT_SQL_FORMATTER_SETTINGS, isCompleteSqlFormatterSettings, type SqlFormatterOptionSettings } from "@/lib/sql/sqlFormatterConfig"; import { SQL_VARIABLE_SYNTAX_KEYS } from "@/lib/sql/sqlVariableSyntax"; /** @@ -455,6 +455,33 @@ function isRawToolbarItemsShape(value: unknown): boolean { const SQL_VARIABLE_SYNTAX_KEY_SET = new Set(SQL_VARIABLE_SYNTAX_KEYS); +/** + * Formatter option keys introduced after the settings-transfer format shipped + * (v0.6.7). An export written before a key existed legitimately lacks it, so + * requiring every current key would reject the entire file and discard every + * other setting it carries — which is what happened when `commaPosition` + * landed in v0.6.35. Absence is only tolerated for these keys, and only + * because the version boundary is known; nothing is inferred about which + * style the user meant. Every future formatter option must be added here. + */ +const SQL_FORMATTER_KEYS_ADDED_AFTER_TRANSFER_FORMAT = new Set(["commaPosition", "layoutStyle"]); + +/** + * Complete-schema check that tolerates the absence of the post-transfer + * formatter keys: present keys keep the strict check (valid value, no unknown + * key), and the missing baseline-compatible ones are filled with their + * defaults before delegating, so a payload missing a baseline key such as + * `keywordCase` is still rejected. + */ +function isTransferableSqlFormatterSettings(value: unknown): boolean { + if (!value || typeof value !== "object" || Array.isArray(value)) return false; + const filled: Record = { ...(value as Record) }; + for (const key of SQL_FORMATTER_KEYS_ADDED_AFTER_TRANSFER_FORMAT) { + if (!(key in filled)) filled[key] = DEFAULT_SQL_FORMATTER_SETTINGS[key]; + } + return isCompleteSqlFormatterSettings(filled); +} + function isSqlVariableSyntaxOverridesShape(value: unknown, isAllowedToggle: (toggle: unknown) => boolean): boolean { if (!isPlainObject(value)) return false; return Object.values(value).every((entry) => isPlainObject(entry) && Object.entries(entry).every(([key, toggle]) => SQL_VARIABLE_SYNTAX_KEY_SET.has(key) && isAllowedToggle(toggle))); @@ -473,7 +500,7 @@ const NESTED_FIELD_VALIDATORS: Partial isArrayOfShape(value, isDataGridTypeColorSchemeItem), tableColumnTemplateFields: isNonEmptyStringArray, shortcuts: isShortcutSettingsShape, - sqlFormatter: isCompleteSqlFormatterSettings, + sqlFormatter: isTransferableSqlFormatterSettings, sidebarHiddenTablePrefixes: isNonEmptyStringArray, redisKeyTemplates: isNonEmptyStringArray, snippets: (value) => isArrayOfShape(value, isSqlSnippetItem), @@ -498,7 +525,7 @@ const RAW_STRUCTURED_FIELD_VALIDATORS: Partial isArrayOfShape(value, isSqlSnippetItem), diff --git a/apps/desktop/src/lib/sql/sqlFormatter.ts b/apps/desktop/src/lib/sql/sqlFormatter.ts index 988f3d0a1d..0055b647df 100644 --- a/apps/desktop/src/lib/sql/sqlFormatter.ts +++ b/apps/desktop/src/lib/sql/sqlFormatter.ts @@ -1,4 +1,4 @@ -import { DEFAULT_SQL_FORMATTER_SETTINGS, normalizeSqlFormatterSettings, sqlFormatterOptions, type SqlFormatterSettings } from "@/lib/sql/sqlFormatterConfig"; +import { DEFAULT_SQL_FORMATTER_SETTINGS, normalizeSqlFormatterSettings, sqlFormatterOptions, type SqlFormatterIndentStyle, type SqlFormatterSettings } from "@/lib/sql/sqlFormatterConfig"; import { formatSqlLayout, type SqlLayoutOptions } from "@/lib/sql/layout"; import { looksLikeXml } from "@/lib/sql/autoFormat"; import { compressCypherText, formatCypherText } from "@/lib/sql/cypherFormatter"; @@ -449,11 +449,14 @@ export async function formatSqlText(sql: string, dialect: SqlFormatDialect = "ge return emptyLineProtection ? restoreProtectedEmptyLines(laidOut, emptyLineProtection.markers) : laidOut; }; - // DBX's own layout printer produces the default style. It needs the AST and - // sql-formatter's internal layout machinery, so it can decline an input — an - // unparseable statement, an internal shape that moved. `null` means "use the - // public formatter", which is also what the tabular indent styles ask for: - // those are an alternative layout this printer deliberately does not reproduce. + // DBX's own layout printer produces the default `dbx` style. It needs the AST + // and sql-formatter's internal layout machinery, so it can decline an input — + // an unparseable statement, an internal shape that moved. `null` means "use + // the public formatter", which is also what the `classic` layout style asks + // for: that style is sql-formatter's own layout, i.e. the pre-v0.6.16 default, + // and the applySqlFormatterLayout post-passes exist to serve it. The tabular + // indent styles likewise are an alternative layout this printer deliberately + // does not reproduce. const layoutOptions: Partial = { lineWidth: normalizedSettings.expressionWidth, indentWidth: normalizedSettings.tabWidth, @@ -464,7 +467,7 @@ export async function formatSqlText(sql: string, dialect: SqlFormatDialect = "ge logicalOperatorNewline: normalizedSettings.logicalOperatorNewline, commaPosition: normalizedSettings.commaPosition, }; - const usesDefaultStyle = normalizedSettings.indentStyle === "standard"; + const usesDefaultStyle = normalizedSettings.layoutStyle === "dbx" && normalizedSettings.indentStyle === "standard"; const formatOnce = async (input: string): Promise => { const laidOut = usesDefaultStyle ? await formatSqlLayout({ sql: input, language, dialectOptions: resolvedDialect, cfg: formatterOptions, options: layoutOptions }) : null; @@ -698,7 +701,7 @@ function normalizeLikeOperatorCase(sql: string, settings: SqlFormatterSettings, return restoreSpans(normalized, spans); } -function keepFromClauseAndFirstSourceOnSameLine(sql: string): string { +function keepFromClauseAndFirstSourceOnSameLine(sql: string, indentStyle: SqlFormatterIndentStyle): string { const lines = sql.split("\n"); for (let index = 0; index < lines.length - 1; index += 1) { const clauseMatch = lines[index].match(/^(\s*)FROM\s*$/i); @@ -714,7 +717,13 @@ function keepFromClauseAndFirstSourceOnSameLine(sql: string): string { const clauseIndent = clauseMatch[1]; const sourceIndent = sourceMatch[1]; - const separator = sourceIndent.startsWith(clauseIndent) ? sourceIndent.slice(clauseIndent.length) : " "; + // The source line's indent is the gap to reuse only for the tabular styles: + // there it is the column sql-formatter aligned every clause to, and + // collapsing it would destroy that alignment (`FROM t`). Under the + // standard indent the same indent is just the clause body's step, so reusing + // it doubles the gap (`FROM t`) — the upstream one-line form is a single + // space, and that is what merging is supposed to produce. + const separator = indentStyle === "standard" ? " " : sourceIndent.startsWith(clauseIndent) ? sourceIndent.slice(clauseIndent.length) : " "; lines[index] = `${lines[index]}${separator || " "}${source}`; lines.splice(index + 1, 1); index -= 1; @@ -787,7 +796,7 @@ function formatLeadingCommas(sql: string, dialect: SqlFormatDialect = "generic") function applySqlFormatterLayout(sql: string, settings: SqlFormatterSettings, dialect: SqlFormatDialect): string { let formatted = normalizeLikeOperatorCase(sql, settings, dialect); if (settings.logicalOperatorNewline === "none") formatted = keepLogicalOperatorsOnSameLine(formatted, dialect); - if (settings.fromClauseLayout === "sameLine") formatted = keepFromClauseAndFirstSourceOnSameLine(formatted); + if (settings.fromClauseLayout === "sameLine") formatted = keepFromClauseAndFirstSourceOnSameLine(formatted, settings.indentStyle); if (settings.commaPosition === "before") formatted = formatLeadingCommas(formatted, dialect); return formatted; } diff --git a/apps/desktop/src/lib/sql/sqlFormatterConfig.ts b/apps/desktop/src/lib/sql/sqlFormatterConfig.ts index db759530e8..9ef0c7bee7 100644 --- a/apps/desktop/src/lib/sql/sqlFormatterConfig.ts +++ b/apps/desktop/src/lib/sql/sqlFormatterConfig.ts @@ -1,8 +1,14 @@ -export const SQL_FORMATTER_CONFIG_VERSION = 1; +export const SQL_FORMATTER_CONFIG_VERSION = 2; export const SQL_FORMATTER_CONFIG_FORMATTER = "sql-formatter"; +// Version 1 shipped from v0.5.52 through v0.6.37 and predates `commaPosition` +// and `layoutStyle`, so it must keep parsing: a file is a record of the user's +// options, and rejecting it would silently discard their whole config. +const SQL_FORMATTER_CONFIG_VERSIONS = new Set([1, 2]); + const CASE_VALUES = ["preserve", "upper", "lower"] as const; const INDENT_STYLE_VALUES = ["standard", "tabularLeft", "tabularRight"] as const; +const LAYOUT_STYLE_VALUES = ["dbx", "classic"] as const; const LOGICAL_OPERATOR_NEWLINE_VALUES = ["before", "after", "none"] as const; const COMMA_POSITION_VALUES = ["after", "before"] as const; const FROM_CLAUSE_LAYOUT_VALUES = ["newLine", "sameLine"] as const; @@ -15,6 +21,7 @@ const SQL_FORMATTER_LEGACY_OPTION_KEYS = new Set(["params"]); export type SqlFormatterCase = (typeof CASE_VALUES)[number]; export type SqlFormatterIndentStyle = (typeof INDENT_STYLE_VALUES)[number]; +export type SqlFormatterLayoutStyle = (typeof LAYOUT_STYLE_VALUES)[number]; export type SqlFormatterLogicalOperatorNewline = (typeof LOGICAL_OPERATOR_NEWLINE_VALUES)[number]; export type SqlFormatterCommaPosition = (typeof COMMA_POSITION_VALUES)[number]; export type SqlFormatterFromClauseLayout = (typeof FROM_CLAUSE_LAYOUT_VALUES)[number]; @@ -40,6 +47,9 @@ export interface SqlFormatterParamTypes { } export interface SqlFormatterOptionSettings { + // DBX-private: selects which layout engine runs and never reaches + // sql-formatter itself (see sqlFormatterOptions). + layoutStyle: SqlFormatterLayoutStyle; keywordCase: SqlFormatterCase; dataTypeCase: SqlFormatterCase; functionCase: SqlFormatterCase; @@ -69,6 +79,7 @@ export interface SqlFormatterConfigFile { export type SqlFormatterConfigParseResult = { ok: true; settings: SqlFormatterSettings } | { ok: false; message: string }; export const DEFAULT_SQL_FORMATTER_SETTINGS: SqlFormatterSettings = { + layoutStyle: "dbx", keywordCase: "upper", dataTypeCase: "preserve", functionCase: "preserve", @@ -87,7 +98,14 @@ export const DEFAULT_SQL_FORMATTER_SETTINGS: SqlFormatterSettings = { paramTypes: null, }; +// A new key is not done when it is added here: an older settings export +// legitimately lacks it, so it must also be registered in +// `SQL_FORMATTER_KEYS_ADDED_AFTER_TRANSFER_FORMAT` +// (lib/settings/settingsTransfer.ts) or every pre-existing `dbx-settings-*.json` +// stops importing wholesale, and given a row in the `expectedControls` list of +// lib/settings/__tests__/settingsSearch.spec.ts so its search entry stays pinned. const SQL_FORMATTER_OPTION_KEYS = new Set([ + "layoutStyle", "keywordCase", "dataTypeCase", "functionCase", @@ -107,6 +125,7 @@ const SQL_FORMATTER_OPTION_KEYS = new Set([ ]); const SQL_FORMATTER_OPTION_VALIDATORS: Record boolean> = { + layoutStyle: (value) => isStringChoice(value, LAYOUT_STYLE_VALUES), keywordCase: (value) => isStringChoice(value, CASE_VALUES), dataTypeCase: (value) => isStringChoice(value, CASE_VALUES), functionCase: (value) => isStringChoice(value, CASE_VALUES), @@ -190,6 +209,7 @@ function normalizeParamTypes(value: unknown, fallback: SqlFormatterParamTypes | export function sqlFormatterOptionSettings(settings: unknown): SqlFormatterOptionSettings { const input = isObject(settings) ? settings : {}; return { + layoutStyle: normalizeChoice(input.layoutStyle, LAYOUT_STYLE_VALUES, DEFAULT_SQL_FORMATTER_SETTINGS.layoutStyle), keywordCase: normalizeChoice(input.keywordCase, CASE_VALUES, DEFAULT_SQL_FORMATTER_SETTINGS.keywordCase), dataTypeCase: normalizeChoice(input.dataTypeCase, CASE_VALUES, DEFAULT_SQL_FORMATTER_SETTINGS.dataTypeCase), functionCase: normalizeChoice(input.functionCase, CASE_VALUES, DEFAULT_SQL_FORMATTER_SETTINGS.functionCase), @@ -252,7 +272,7 @@ export function parseSqlFormatterConfig(text: string): SqlFormatterConfigParseRe } if (!isObject(parsed)) return { ok: false, message: "Config must be a JSON object." }; - if (parsed.version !== SQL_FORMATTER_CONFIG_VERSION) return { ok: false, message: "Unsupported config version." }; + if (typeof parsed.version !== "number" || !SQL_FORMATTER_CONFIG_VERSIONS.has(parsed.version)) return { ok: false, message: "Unsupported config version." }; if (parsed.formatter !== SQL_FORMATTER_CONFIG_FORMATTER) return { ok: false, message: "Unsupported formatter." }; if (!isObject(parsed.options)) return { ok: false, message: "Config options must be a JSON object." }; diff --git a/docs/content/docs/sql-formatter.cn.mdx b/docs/content/docs/sql-formatter.cn.mdx index a75b62fda2..d243752af2 100644 --- a/docs/content/docs/sql-formatter.cn.mdx +++ b/docs/content/docs/sql-formatter.cn.mdx @@ -28,7 +28,11 @@ MongoDB 查询编辑器使用单独的 Mongo Shell 格式化器,不使用本 ## 默认风格 -默认排版规则:能放下的语句保持一行,放不下的按子句逐行展开,子句的后续元素与第一个元素对齐;条件的逻辑操作符放在续行行首;`CREATE TABLE` 的列定义按列对齐。下面的设置用于调整这些规则,**恢复默认值**会回到该风格。 +默认的 `dbx` 排版规则:能放下的语句保持一行,放不下的按子句逐行展开,子句的后续元素与第一个元素对齐;条件的逻辑操作符放在续行行首;`CREATE TABLE` 的列定义按列对齐。下面的设置用于调整这些规则,**恢复默认值**会回到该风格。 + +把 **排版风格** 设为 `classic` 会改用 sql-formatter 自身的排版,也就是 v0.6.16 之前 DBX 的默认样式。v0.6.16 同时还改过两项默认值,因此还要把 **表达式宽度** 设为 `50`、**FROM 子句布局** 设为换行,才能还原旧输出;等价的 JSON 是 `"expressionWidth": 50`、`"fromClauseLayout": "newLine"`,与 `"layoutStyle": "classic"` 并列。除 Oracle 外结果与 v0.6.15 一致:Oracle 的字节/多字节函数变体(`SUBSTRB`、`INSTR2`、`LENGTHB` 等同族函数)保留较新的修复,会被规范为函数形式。 + +切换排版风格只切引擎,不会顺带改写其它选项。两种缩进样式(`tabularLeft`、`tabularRight`)在两种风格下都使用 sql-formatter 自身的排版。 ## 配置格式化选项 @@ -36,6 +40,7 @@ MongoDB 查询编辑器使用单独的 Mongo Shell 格式化器,不使用本 | 选项 | 可用值或作用 | | --- | --- | +| 排版风格 | `dbx`(DBX 自研排版,默认)或 `classic`(sql-formatter 上游排版,同 v0.6.15) | | 关键字、数据类型、函数、标识符大小写 | `upper`、`lower`、`preserve` | | 缩进样式 | `standard`、`tabularLeft`、`tabularRight` | | 缩进字符 | 空格或 Tab | @@ -57,9 +62,10 @@ JSON 模式适合精确编辑、复制或迁移格式化器配置。当前配置 ```json { - "version": 1, + "version": 2, "formatter": "sql-formatter", "options": { + "layoutStyle": "dbx", "keywordCase": "upper", "dataTypeCase": "preserve", "functionCase": "preserve", @@ -68,6 +74,7 @@ JSON 模式适合精确编辑、复制或迁移格式化器配置。当前配置 "useTabs": false, "tabWidth": 2, "logicalOperatorNewline": "before", + "commaPosition": "after", "fromClauseLayout": "sameLine", "expressionWidth": 120, "linesBetweenQueries": 1, @@ -82,7 +89,7 @@ JSON 模式适合精确编辑、复制或迁移格式化器配置。当前配置 JSON 编辑器会实时校验配置。以下情况会阻止应用或导入: - JSON 语法错误; -- `version` 不是 `1`; +- `version` 不是 `1` 或 `2`; - `formatter` 不是 `sql-formatter`; - `options` 中包含未知字段; - 字段值不在当前代码允许的范围内。 diff --git a/docs/content/docs/sql-formatter.mdx b/docs/content/docs/sql-formatter.mdx index 9088617061..07a25fda7b 100644 --- a/docs/content/docs/sql-formatter.mdx +++ b/docs/content/docs/sql-formatter.mdx @@ -28,7 +28,11 @@ The MongoDB query editor uses a separate Mongo Shell formatter rather than this ## Default Style -The default layout keeps a statement that fits the width on one line, puts each clause of a longer one on its own line, and aligns a clause's remaining elements under its first element. Conditions keep their logical operators at the start of the continuation line, and `CREATE TABLE` definitions are aligned on a column grid. The settings below change those rules; **Restore Defaults** returns to this style. +The default `dbx` layout keeps a statement that fits the width on one line, puts each clause of a longer one on its own line, and aligns a clause's remaining elements under its first element. Conditions keep their logical operators at the start of the continuation line, and `CREATE TABLE` definitions are aligned on a column grid. The settings below change those rules; **Restore Defaults** returns to this style. + +Set **Layout style** to `classic` to use sql-formatter's own layout instead — the default DBX shipped before v0.6.16. v0.6.16 also changed two option defaults, so also set **Expression width** to `50` and **FROM clause layout** to the new-line value to reproduce the old output; the JSON equivalent is `"expressionWidth": 50` and `"fromClauseLayout": "newLine"`, next to `"layoutStyle": "classic"`. The result matches v0.6.15 except on Oracle, where the byte/multibyte function variants (`SUBSTRB`, `INSTR2`, `LENGTHB` and the rest of that family) keep the newer fix that normalizes them as functions. + +Switching the layout style only changes the engine; it never rewrites the other options. The two indent styles (`tabularLeft`, `tabularRight`) always use sql-formatter's own layout, in either mode. ## Configure Formatting Options @@ -36,6 +40,7 @@ Open **Settings → SQL Formatter**. Form mode exposes: | Option | Values or behavior | | --- | --- | +| Layout style | `dbx` (DBX's own layout, default) or `classic` (sql-formatter's upstream layout, as in v0.6.15) | | Keyword, data type, function, and identifier case | `upper`, `lower`, or `preserve` | | Indent style | `standard`, `tabularLeft`, or `tabularRight` | | Indentation | Spaces or tabs | @@ -57,9 +62,10 @@ JSON mode is useful for exact editing, copying, or moving formatter settings. Th ```json { - "version": 1, + "version": 2, "formatter": "sql-formatter", "options": { + "layoutStyle": "dbx", "keywordCase": "upper", "dataTypeCase": "preserve", "functionCase": "preserve", @@ -68,6 +74,7 @@ JSON mode is useful for exact editing, copying, or moving formatter settings. Th "useTabs": false, "tabWidth": 2, "logicalOperatorNewline": "before", + "commaPosition": "after", "fromClauseLayout": "sameLine", "expressionWidth": 120, "linesBetweenQueries": 1, @@ -82,7 +89,7 @@ JSON mode is useful for exact editing, copying, or moving formatter settings. Th The JSON editor validates the draft continuously. DBX blocks apply or import when: - the text is not valid JSON; -- `version` is not `1`; +- `version` is not `1` or `2`; - `formatter` is not `sql-formatter`; - `options` contains an unknown field; or - a value is outside the range accepted by the current build. diff --git a/packages/app-tests/sqlFormatterConfig.test.ts b/packages/app-tests/sqlFormatterConfig.test.ts index 2bf4d8fc39..dbc7061874 100644 --- a/packages/app-tests/sqlFormatterConfig.test.ts +++ b/packages/app-tests/sqlFormatterConfig.test.ts @@ -72,7 +72,7 @@ test("serializes formatter config as a stable versioned envelope", () => { json, JSON.stringify( { - version: 1, + version: 2, formatter: "sql-formatter", options: { ...defaultOptionSettings, @@ -153,11 +153,32 @@ test("ignores legacy editor shortcut fields in formatter config files", () => { test("rejects malformed formatter config files", () => { assert.deepEqual(parseSqlFormatterConfig("{bad json").ok, false); - assert.deepEqual(parseSqlFormatterConfig(JSON.stringify({ version: 2, formatter: "sql-formatter", options: {} })).ok, false); + assert.deepEqual(parseSqlFormatterConfig(JSON.stringify({ version: 3, formatter: "sql-formatter", options: {} })).ok, false); assert.deepEqual(parseSqlFormatterConfig(JSON.stringify({ version: 1, formatter: "prettier", options: {} })).ok, false); assert.deepEqual(parseSqlFormatterConfig(JSON.stringify({ version: 1, formatter: "sql-formatter", options: { unknown: true } })).ok, false); }); +test("accepts config versions 1 and 2 and defaults a missing layoutStyle to dbx", () => { + const version1 = parseSqlFormatterConfig(JSON.stringify({ version: 1, formatter: "sql-formatter", options: { keywordCase: "lower" } })); + assert.equal(version1.ok, true); + if (version1.ok) { + assert.equal(version1.settings.layoutStyle, "dbx"); + assert.equal(version1.settings.keywordCase, "lower"); + } + + const version2 = parseSqlFormatterConfig(JSON.stringify({ version: 2, formatter: "sql-formatter", options: { layoutStyle: "classic" } })); + assert.equal(version2.ok, true); + if (version2.ok) assert.equal(version2.settings.layoutStyle, "classic"); + + const invalidStyle = parseSqlFormatterConfig(JSON.stringify({ version: 2, formatter: "sql-formatter", options: { layoutStyle: "upstream" } })); + assert.equal(invalidStyle.ok, false); + if (!invalidStyle.ok) assert.match(invalidStyle.message, /layoutStyle/); + + assert.equal(normalizeSqlFormatterSettings({}).layoutStyle, "dbx"); + // layoutStyle is DBX-private and must never reach sql-formatter. + assert.equal("layoutStyle" in sqlFormatterOptions({ layoutStyle: "classic" }), false); +}); + test("rejects invalid known formatter option values when parsing config files", () => { const invalidKeywordCase = parseSqlFormatterConfig(JSON.stringify({ version: 1, formatter: "sql-formatter", options: { keywordCase: "camel" } })); assert.equal(invalidKeywordCase.ok, false);