mirror of
https://github.com/t8y2/dbx.git
synced 2026-10-19 16:16:45 +00:00
feat(formatter): add selectable classic layout style
This commit is contained in:
@@ -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">
|
||||
|
||||
+47
@@ -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("经典");
|
||||
});
|
||||
});
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -7182,6 +7182,9 @@ export default withEnglishFallback({
|
||||
sqlFormatterIndentStyleStandard: "標準",
|
||||
sqlFormatterIndentStyleTabularLeft: "表形式左寄せ",
|
||||
sqlFormatterIndentStyleTabularRight: "表形式右寄せ",
|
||||
sqlFormatterLayoutStyle: "レイアウトスタイル",
|
||||
sqlFormatterLayoutStyleDbx: "DBX(デフォルト)",
|
||||
sqlFormatterLayoutStyleClassic: "クラシック",
|
||||
sqlFormatterLogicalOperatorNewline: "論理演算子の改行",
|
||||
sqlFormatterLogicalBefore: "演算子の前",
|
||||
sqlFormatterLogicalAfter: "演算子の後",
|
||||
|
||||
@@ -6997,6 +6997,9 @@ export default withEnglishFallback({
|
||||
sqlFormatterIndentStyleStandard: "표준",
|
||||
sqlFormatterIndentStyleTabularLeft: "표 왼쪽 정렬",
|
||||
sqlFormatterIndentStyleTabularRight: "표 오른쪽 정렬",
|
||||
sqlFormatterLayoutStyle: "레이아웃 스타일",
|
||||
sqlFormatterLayoutStyleDbx: "DBX(기본값)",
|
||||
sqlFormatterLayoutStyleClassic: "클래식",
|
||||
sqlFormatterLogicalOperatorNewline: "논리 연산자 줄바꿈",
|
||||
sqlFormatterLogicalBefore: "연산자 앞",
|
||||
sqlFormatterLogicalAfter: "연산자 뒤",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -8395,6 +8395,9 @@ export default withEnglishFallback({
|
||||
sqlFormatterIndentStyleStandard: "Стандартный",
|
||||
sqlFormatterIndentStyleTabularLeft: "Табличный слева",
|
||||
sqlFormatterIndentStyleTabularRight: "Табличный справа",
|
||||
sqlFormatterLayoutStyle: "Стиль разметки",
|
||||
sqlFormatterLayoutStyleDbx: "DBX (по умолчанию)",
|
||||
sqlFormatterLayoutStyleClassic: "Классический",
|
||||
sqlFormatterLogicalOperatorNewline: "Перенос логических операторов",
|
||||
sqlFormatterLogicalBefore: "Перед оператором",
|
||||
sqlFormatterLogicalAfter: "После оператора",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -8072,6 +8072,9 @@ export default withEnglishFallback({
|
||||
sqlFormatterIndentStyleStandard: "标准",
|
||||
sqlFormatterIndentStyleTabularLeft: "表格式左对齐",
|
||||
sqlFormatterIndentStyleTabularRight: "表格式右对齐",
|
||||
sqlFormatterLayoutStyle: "排版风格",
|
||||
sqlFormatterLayoutStyleDbx: "DBX(默认)",
|
||||
sqlFormatterLayoutStyleClassic: "经典",
|
||||
sqlFormatterLogicalOperatorNewline: "逻辑运算符换行",
|
||||
sqlFormatterLogicalBefore: "运算符前换行",
|
||||
sqlFormatterLogicalAfter: "运算符后换行",
|
||||
|
||||
@@ -6507,6 +6507,9 @@ export default withEnglishFallback({
|
||||
sqlFormatterIndentStyleStandard: "標準",
|
||||
sqlFormatterIndentStyleTabularLeft: "表格式靠左",
|
||||
sqlFormatterIndentStyleTabularRight: "表格式靠右",
|
||||
sqlFormatterLayoutStyle: "排版風格",
|
||||
sqlFormatterLayoutStyleDbx: "DBX(預設)",
|
||||
sqlFormatterLayoutStyleClassic: "經典",
|
||||
sqlFormatterLogicalOperatorNewline: "邏輯運算子換行",
|
||||
sqlFormatterLogicalBefore: "運算子前換行",
|
||||
sqlFormatterLogicalAfter: "運算子後換行",
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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." };
|
||||
|
||||
|
||||
@@ -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` 中包含未知字段;
|
||||
- 字段值不在当前代码允许的范围内。
|
||||
|
||||
@@ -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
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user