feat(formatter): add selectable classic layout style

This commit is contained in:
Abeautifulsnow
2026-10-10 07:23:35 +08:00
committed by GitHub
parent b281fbd5fa
commit 6c57b0ca9b
26 changed files with 510 additions and 24 deletions
@@ -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<keyof SqlFormatterOptionSettings, string> = {
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(() => {
</div>
</div>
<div class="grid gap-4 md:grid-cols-[minmax(0,1fr)_10rem_12rem]">
<div class="grid gap-4 md:grid-cols-4">
<div class="space-y-2">
<Label>{{ t("settings.sqlFormatterIndent") }}</Label>
<div class="grid grid-cols-2 gap-2">
@@ -618,6 +629,20 @@ onBeforeUnmount(() => {
</SelectContent>
</Select>
</div>
<div class="space-y-2">
<Label>{{ t("settings.sqlFormatterLayoutStyle") }}</Label>
<Select :model-value="settings.layoutStyle" @update:model-value="onLayoutStyle">
<SelectTrigger class="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem v-for="option in layoutStyleOptions" :key="option.value" :value="option.value">
{{ t(option.labelKey) }}
</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<div class="grid gap-4 md:grid-cols-3">
@@ -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");
});
});
@@ -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("经典");
});
});
+3
View File
@@ -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",
+3
View File
@@ -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",
+3
View File
@@ -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",
+3
View File
@@ -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",
+3
View File
@@ -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",
+3
View File
@@ -7182,6 +7182,9 @@ export default withEnglishFallback({
sqlFormatterIndentStyleStandard: "標準",
sqlFormatterIndentStyleTabularLeft: "表形式左寄せ",
sqlFormatterIndentStyleTabularRight: "表形式右寄せ",
sqlFormatterLayoutStyle: "レイアウトスタイル",
sqlFormatterLayoutStyleDbx: "DBX(デフォルト)",
sqlFormatterLayoutStyleClassic: "クラシック",
sqlFormatterLogicalOperatorNewline: "論理演算子の改行",
sqlFormatterLogicalBefore: "演算子の前",
sqlFormatterLogicalAfter: "演算子の後",
+3
View File
@@ -6997,6 +6997,9 @@ export default withEnglishFallback({
sqlFormatterIndentStyleStandard: "표준",
sqlFormatterIndentStyleTabularLeft: "표 왼쪽 정렬",
sqlFormatterIndentStyleTabularRight: "표 오른쪽 정렬",
sqlFormatterLayoutStyle: "레이아웃 스타일",
sqlFormatterLayoutStyleDbx: "DBX(기본값)",
sqlFormatterLayoutStyleClassic: "클래식",
sqlFormatterLogicalOperatorNewline: "논리 연산자 줄바꿈",
sqlFormatterLogicalBefore: "연산자 앞",
sqlFormatterLogicalAfter: "연산자 뒤",
+3
View File
@@ -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",
+3
View File
@@ -8395,6 +8395,9 @@ export default withEnglishFallback({
sqlFormatterIndentStyleStandard: "Стандартный",
sqlFormatterIndentStyleTabularLeft: "Табличный слева",
sqlFormatterIndentStyleTabularRight: "Табличный справа",
sqlFormatterLayoutStyle: "Стиль разметки",
sqlFormatterLayoutStyleDbx: "DBX (по умолчанию)",
sqlFormatterLayoutStyleClassic: "Классический",
sqlFormatterLogicalOperatorNewline: "Перенос логических операторов",
sqlFormatterLogicalBefore: "Перед оператором",
sqlFormatterLogicalAfter: "После оператора",
+3
View File
@@ -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",
+3
View File
@@ -8072,6 +8072,9 @@ export default withEnglishFallback({
sqlFormatterIndentStyleStandard: "标准",
sqlFormatterIndentStyleTabularLeft: "表格式左对齐",
sqlFormatterIndentStyleTabularRight: "表格式右对齐",
sqlFormatterLayoutStyle: "排版风格",
sqlFormatterLayoutStyleDbx: "DBX(默认)",
sqlFormatterLayoutStyleClassic: "经典",
sqlFormatterLogicalOperatorNewline: "逻辑运算符换行",
sqlFormatterLogicalBefore: "运算符前换行",
sqlFormatterLogicalAfter: "运算符后换行",
+3
View File
@@ -6507,6 +6507,9 @@ export default withEnglishFallback({
sqlFormatterIndentStyleStandard: "標準",
sqlFormatterIndentStyleTabularLeft: "表格式靠左",
sqlFormatterIndentStyleTabularRight: "表格式靠右",
sqlFormatterLayoutStyle: "排版風格",
sqlFormatterLayoutStyleDbx: "DBX(預設)",
sqlFormatterLayoutStyleClassic: "經典",
sqlFormatterLogicalOperatorNewline: "邏輯運算子換行",
sqlFormatterLogicalBefore: "運算子前換行",
sqlFormatterLogicalAfter: "運算子後換行",
+151
View File
@@ -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<SqlFormatterSettings> = { 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<SqlFormatterSettings>)).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"));
});
});
@@ -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." });
});
});
@@ -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" },
@@ -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<string, unknown> = { ...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<string, unknown> = { ...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);
@@ -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" },
@@ -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<string>(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<keyof SqlFormatterOptionSettings>(["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<string, unknown> = { ...(value as Record<string, unknown>) };
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<Record<EditorSettingsDraftKey, (value: un
dataGridTypeColorSchemes: (value) => 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<Record<EditorSettingsDraftKey, (v
tableColumnTemplateFields: isStringArray,
shortcuts: isRawShortcutSettingsShape,
toolbarItems: isRawToolbarItemsShape,
sqlFormatter: isCompleteSqlFormatterSettings,
sqlFormatter: isTransferableSqlFormatterSettings,
sidebarHiddenTablePrefixes: isStringArray,
redisKeyTemplates: isStringArray,
snippets: (value) => isArrayOfShape(value, isSqlSnippetItem),
+19 -10
View File
@@ -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<SqlLayoutOptions> = {
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<string> => {
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;
}
+22 -2
View File
@@ -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<keyof SqlFormatterOptionSettings>([
"layoutStyle",
"keywordCase",
"dataTypeCase",
"functionCase",
@@ -107,6 +125,7 @@ const SQL_FORMATTER_OPTION_KEYS = new Set<keyof SqlFormatterOptionSettings>([
]);
const SQL_FORMATTER_OPTION_VALIDATORS: Record<keyof SqlFormatterOptionSettings, (value: unknown) => 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." };
+10 -3
View File
@@ -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` 中包含未知字段;
- 字段值不在当前代码允许的范围内。
+10 -3
View File
@@ -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.
+23 -2
View File
@@ -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);