feat(formatter): add custom default SQL layout engine

Statements that fit the configured width stay on one line; longer ones
put each clause on its own line with elements aligned under the first
one, logical operators start continuation lines, and CREATE TABLE
definitions align on a column grid. The printer walks the AST from
sql-formatter's bundled parser and degrades to the public format() API
for tabular indent styles and inputs it cannot model. Defaults change
to fromClauseLayout=sameLine and expressionWidth=120.
This commit is contained in:
t8y2
2026-09-17 14:56:54 +08:00
parent 254b9baaff
commit 5304506d95
20 changed files with 1588 additions and 175 deletions
@@ -14,7 +14,11 @@ import { DEFAULT_SQL_FORMATTER_SETTINGS } from "@/lib/sql/sqlFormatterConfig";
// QueryEditor.vue survives across the editor mounts in this file, so every test
// must advance its ids monotonically (never reuse a smaller id) to mirror the
// production counter.
const ORIGINAL_SQL = "select id, name\nfrom users\nwhere id = 1";
//
// The sample is long enough that formatting cannot collapse it onto one line:
// the default style joins a statement that fits onto one line, and the replay
// assertions below need a multi-line result.
const ORIGINAL_SQL = "select id, name, email, status, created_at, updated_at, tenant_id\nfrom users\nwhere status = 'active'\nand tenant_id = 42\nand deleted_at is null";
const COMPRESSED_SQL = compressSqlText(ORIGINAL_SQL, "mysql");
const FORMATTED_SQL = await formatSqlForEditing(COMPRESSED_SQL, "mysql", DEFAULT_SQL_FORMATTER_SETTINGS);
+80 -37
View File
@@ -34,10 +34,10 @@ describe("sqlFormatter", () => {
expect(defaultFormatted).toContain("-- tstt\nSELECT");
expect(preserved).toContain("-- tstt\n\nSELECT");
expect(preservedConsecutive).toContain("-- section\n\n\nSELECT");
expect(preservedQueriesNoSpacing).toBe("SELECT\n 1;\n\nSELECT\n 2;");
expect(preservedQueries).toBe("SELECT\n 1;\n\nSELECT\n 2;");
expect(preservedQueriesWideSpacing).toBe("SELECT\n 1;\n\n\nSELECT\n 2;");
expect(preservedQueriesWithTwoEmptyLines).toBe("SELECT\n 1;\n\n\nSELECT\n 2;");
expect(preservedQueriesNoSpacing).toBe("SELECT 1;\n\nSELECT 2;");
expect(preservedQueries).toBe("SELECT 1;\n\nSELECT 2;");
expect(preservedQueriesWideSpacing).toBe("SELECT 1;\n\n\nSELECT 2;");
expect(preservedQueriesWithTwoEmptyLines).toBe("SELECT 1;\n\n\nSELECT 2;");
expect(preserved).not.toContain("__DBX_PRESERVE_EMPTY_LINE_");
});
@@ -69,7 +69,7 @@ describe("sqlFormatter", () => {
});
it("formats an OceanBase Oracle single-line view DDL into readable multi-line SQL (issue #7540)", async () => {
const singleLine = `CREATE OR REPLACE VIEW "APP"."ACTIVE_USERS" AS SELECT ID, NAME FROM USERS WHERE STATUS = 'ACTIVE'`;
const singleLine = `create or replace view "APP"."ACTIVE_USERS" as select ID, NAME, EMAIL, CREATED_AT from USERS where STATUS = 'ACTIVE' and DELETED_AT is null order by CREATED_AT desc`;
const formatted = await formatSqlForDisplay(singleLine, sqlFormatDialectForDbType("oceanbase-oracle"));
expect(formatted).not.toBe(singleLine);
@@ -83,11 +83,23 @@ describe("sqlFormatter", () => {
expect(formatted).toContain('"ACTIVE_USERS"');
});
it("collapses a short OceanBase Oracle view DDL onto one line (issue #7540)", async () => {
// The default style joins a statement that fits the line width onto one
// line, so a short view body comes back with its keywords cased rather
// than spread over several.
const singleLine = `create or replace view "APP"."ACTIVE_USERS" as select ID, NAME from USERS where STATUS = 'ACTIVE'`;
const formatted = await formatSqlForDisplay(singleLine, sqlFormatDialectForDbType("oceanbase-oracle"));
expect(formatted).toBe(`CREATE OR REPLACE VIEW "APP"."ACTIVE_USERS" AS SELECT ID, NAME FROM USERS WHERE STATUS = 'ACTIVE'`);
expect(formatted).toContain("'ACTIVE'");
expect(formatted).toContain('"ACTIVE_USERS"');
});
it("formats a bare OceanBase Oracle view source wrapped as CREATE VIEW (issue #7540)", async () => {
// Mirrors the backend build_view_ddl_sql output for a fallback ALL_VIEWS.TEXT
// body: `CREATE VIEW <name> AS <single-line SELECT>`.
const wrapped = `CREATE VIEW "APP"."ACTIVE_USERS" AS
SELECT ID, NAME FROM USERS WHERE STATUS = 'ACTIVE';`;
const wrapped = `create view "APP"."ACTIVE_USERS" as
select ID, NAME, EMAIL, CREATED_AT from USERS where STATUS = 'ACTIVE' and DELETED_AT is null order by CREATED_AT desc;`;
const formatted = await formatSqlForDisplay(wrapped, sqlFormatDialectForDbType("oceanbase-oracle"));
expect(formatted.split("\n").length).toBeGreaterThan(2);
@@ -121,27 +133,40 @@ WHERE STATUS = 'ACTIVE';`;
});
it("keeps issue #7138 Oracle hierarchy clauses intact", async () => {
const sql = "SELECT ctt.u_dm, ctt.u_mc, ctt.parent_id, ctt.level_num, ctt.create_time FROM cte_test ctt WHERE ctt.status = 'A' AND ctt.deleted = 0 START WITH ctt.su_dm IN ('16','17','18') CONNECT BY PRIOR ctt.u_dm = ctt.su_dm;";
// `START WITH` and `CONNECT BY PRIOR` are clauses of their own, not part of
// the WHERE condition: each opens a line, and the hierarchy is never dropped.
await expect(formatSqlForEditing(sql, sqlFormatDialectForDbType("oracle"))).resolves.toBe(`SELECT ctt.u_dm,
ctt.u_mc,
ctt.parent_id,
ctt.level_num,
ctt.create_time
FROM cte_test ctt
WHERE ctt.status = 'A'
AND ctt.deleted = 0
START WITH ctt.su_dm IN ('16', '17', '18')
CONNECT BY PRIOR ctt.u_dm = ctt.su_dm;`);
});
it("collapses a short Oracle hierarchy query onto one line", async () => {
const sql = "SELECT ctt.U_DM FROM cte_test ctt START WITH ctt.SU_DM IN ('16','17','18','19') CONNECT BY PRIOR ctt.U_DM = HY.SU_DM;";
await expect(formatSqlForEditing(sql, sqlFormatDialectForDbType("oracle"))).resolves.toBe(`SELECT
ctt.U_DM
FROM
cte_test ctt
START WITH ctt.SU_DM IN ('16', '17', '18', '19')
CONNECT BY PRIOR ctt.U_DM = HY.SU_DM;`);
await expect(formatSqlForEditing(sql, sqlFormatDialectForDbType("oracle"))).resolves.toBe(`SELECT ctt.U_DM FROM cte_test ctt START WITH ctt.SU_DM IN ('16', '17', '18', '19') CONNECT BY PRIOR ctt.U_DM = HY.SU_DM;`);
});
it("formats valid Oracle hierarchy clauses with the same alias", async () => {
const sql = "SELECT ctt.U_DM FROM cte_test ctt START WITH ctt.SU_DM = '16' CONNECT BY PRIOR ctt.U_DM = ctt.SU_DM;";
const sql = "select ctt.u_dm, ctt.u_mc, ctt.parent_id, ctt.level_num, ctt.create_time from cte_test ctt where ctt.status = 'A' and ctt.deleted = 0 start with ctt.su_dm = '16' connect by prior ctt.u_dm = ctt.su_dm;";
const formatted = await formatSqlForEditing(sql, sqlFormatDialectForDbType("oracle"));
expect(formatted).toContain("FROM\n cte_test ctt\nSTART WITH ctt.SU_DM = '16'");
expect(formatted).toContain("\nCONNECT BY PRIOR ctt.U_DM = ctt.SU_DM;");
expect(formatted).toContain("FROM cte_test ctt");
expect(formatted).toContain("\nSTART WITH ctt.su_dm = '16'");
expect(formatted).toContain("\nCONNECT BY PRIOR ctt.u_dm = ctt.su_dm;");
});
it("formats ordinary Oracle SQL and anonymous PL/SQL", async () => {
await expect(formatSqlForEditing("select employee_id from employees where department_id = 10;", sqlFormatDialectForDbType("oracle"))).resolves.toBe("SELECT\n employee_id\nFROM\n employees\nWHERE\n department_id = 10;");
await expect(formatSqlForEditing("select employee_id from employees where department_id = 10;", sqlFormatDialectForDbType("oracle"))).resolves.toBe("SELECT employee_id FROM employees WHERE department_id = 10;");
await expect(formatSqlForEditing("declare v_count number := 1; begin v_count := v_count + 1; end;", sqlFormatDialectForDbType("oracle"))).resolves.toBe("DECLARE v_count number := 1;\n\nBEGIN v_count := v_count + 1;\n\nEND;");
});
@@ -243,14 +268,15 @@ OR inside$$ as note
it("preserves the ClickHouse table alias from issue #7079", async () => {
const formatted = await formatSqlText("SELECT *\nFROM MATERIAL m\nLIMIT 100;", sqlFormatDialectForDbType("clickhouse"));
expect(formatted).toBe("SELECT\n *\nFROM\n MATERIAL m\nLIMIT\n 100;");
expect(formatted).toBe("SELECT * FROM MATERIAL m LIMIT 100;");
});
it.each(["d", "dd", "h", "hh", "m", "mcs", "mi", "mm", "ms", "n", "ns", "q", "qq", "s", "ss", "wk", "ww", "yy", "yyyy"])("preserves ClickHouse identifier-like date part %s while formatting keywords", async (identifier) => {
const formatted = await formatSqlText(`select ${identifier}, t.${identifier} from material ${identifier} limit 100;`, sqlFormatDialectForDbType("clickhouse"));
expect(formatted).toContain(` ${identifier},`);
expect(formatted).toContain(`material ${identifier}`);
// Alias casing is the tokenizer's business, not the keyword casing's: the
// date part stays an identifier, so `keywordCase: "upper"` leaves it alone.
expect(formatted).toBe(`SELECT ${identifier}, t.${identifier} FROM material ${identifier} LIMIT 100;`);
expect(formatted).toContain("SELECT");
expect(formatted).toContain("FROM");
expect(formatted).toContain("LIMIT");
@@ -262,17 +288,15 @@ OR inside$$ as note
const lowerKeywords = await formatSqlText(sql, sqlFormatDialectForDbType("clickhouse"), { keywordCase: "lower", identifierCase: "preserve" });
const lowerIdentifiers = await formatSqlText(sql, sqlFormatDialectForDbType("clickhouse"), { keywordCase: "upper", identifierCase: "lower" });
expect(lowerKeywords).toContain("from\n MATERIAL M");
expect(lowerKeywords).toContain("limit\n 100");
expect(lowerIdentifiers).toContain("FROM\n material m");
expect(lowerIdentifiers).toContain("LIMIT\n 100");
expect(lowerKeywords).toBe("select * from MATERIAL M limit 100;");
expect(lowerIdentifiers).toBe("SELECT * FROM material m LIMIT 100;");
});
it("still formats unambiguous ClickHouse interval keywords", async () => {
const formatted = await formatSqlText("select now() + interval 1 minutes from source_table m;", sqlFormatDialectForDbType("clickhouse"));
expect(formatted).toContain("INTERVAL 1 MINUTES");
expect(formatted).toContain("FROM\n source_table m");
expect(formatted).toContain("FROM source_table m");
});
it("preserves DBX brace placeholders in generic and MySQL SQL", async () => {
@@ -344,19 +368,19 @@ OR inside$$ as note
it("formats complete backtick-quoted spans with the PostgreSQL dialect", async () => {
const formatted = await formatSqlText("select `schema`.`odd``name` from user;", "postgres");
expect(formatted).toBe("SELECT\n `schema`.`odd``name`\nFROM\n user;");
expect(formatted).toBe("SELECT `schema`.`odd``name` FROM user;");
});
it("keeps PostgreSQL double-quoted identifiers and casts unchanged", async () => {
const formatted = await formatSqlText('select "display""name" from records where payload::jsonb is not null;', "postgres");
expect(formatted).toBe('SELECT\n "display""name"\nFROM\n records\nWHERE\n payload::jsonb IS NOT NULL;');
expect(formatted).toBe('SELECT "display""name" FROM records WHERE payload::jsonb IS NOT NULL;');
});
it("keeps MySQL backtick formatting unchanged", async () => {
const formatted = await formatSqlText("select `schema`.`odd``name` from `user`;", "mysql");
expect(formatted).toBe("SELECT\n `schema`.`odd``name`\nFROM\n `user`;");
expect(formatted).toBe("SELECT `schema`.`odd``name` FROM `user`;");
});
it("keeps malformed PostgreSQL backtick input unchanged while editing", async () => {
@@ -372,7 +396,9 @@ OR inside$$ as note
const formatted = await formatSqlForEditing(sql, sqlFormatDialectForDbType("dameng"));
expect(formatted).toContain('JS1.REC_CREATOR AS "recCreator"');
expect(formatted).toContain("DECODE (");
// Known and unknown functions alike are written without a space before the
// parenthesis.
expect(formatted).toContain("DECODE(");
expect(formatted.endsWith("JS1.REC_CREATE_TIME DESC .")).toBe(true);
});
@@ -383,7 +409,7 @@ OR inside$$ as note
});
it("preserves whitespace after a recovered trailing dot", async () => {
await expect(formatSqlForEditing("SELECT 1 .\n", "dameng")).resolves.toBe("SELECT\n 1 .\n");
await expect(formatSqlForEditing("SELECT 1 .\n", "dameng")).resolves.toBe("SELECT 1 .\n");
});
it("preserves the newline before a trailing dot after a line comment", async () => {
@@ -438,7 +464,18 @@ OR inside$$ as note
it("still formats selected SQL comparison fragments", async () => {
await expect(formatSqlText(`< 10`, "postgres")).resolves.toBe(`< 10`);
await expect(formatSqlText(`< 10 AND score > 2`, "postgres")).resolves.toBe(`< 10\nAND score > 2`);
// A short fragment collapses like any other short statement.
await expect(formatSqlText(`< 10 AND score > 2`, "postgres")).resolves.toBe(`< 10 AND score > 2`);
// A wider one has no clause to lay out, so it falls back to sql-formatter's
// own operand-per-line rendering of a fragment rather than the clause rules.
await expect(formatSqlText(`< 10 AND score > 2 AND status = 'active' AND deleted_at IS NULL AND created_at > now() - interval '30 days' AND owner_id = 42`, "postgres")).resolves.toBe(
`< 10
AND score > 2
AND status = 'active'
AND deleted_at IS NULL
AND created_at > now() - interval '30 days'
AND owner_id = 42`,
);
});
it("keeps logical conditions on one line when configured", async () => {
@@ -492,16 +529,22 @@ OR inside$$ as note
tabWidth: 4,
});
expect(formatted).toContain("FROM\ttVillage AS tv");
expect(formatted).toContain("ON tv.villageId = tl.villageId AND 1 = 1");
// The join keeps `FROM` and its source together (the setting's whole point),
// indents by one tab width, and the `ON` condition stays on the join's line.
expect(formatted).toBe("SELECT *\nFROM tVillage AS tv\n\tINNER JOIN tLand AS tl ON tv.villageId = tl.villageId AND 1 = 1");
});
it("keeps derived tables multiline with FROM same-line layout", async () => {
const formatted = await formatSqlText("SELECT * FROM (SELECT * FROM tVillage) AS tv", "sqlserver", { fromClauseLayout: "sameLine" });
const sql = "SELECT * FROM (SELECT villageId, villageName, townshipId, createdAt, updatedAt, deletedAt FROM tVillage WHERE deletedAt IS NULL AND villageName LIKE '%x%') AS tv";
expect(formatted).toContain("FROM\n");
expect(formatted).toContain("\n SELECT");
expect(formatted).toContain("FROM tVillage");
const formatted = await formatSqlText(sql, "sqlserver", { fromClauseLayout: "sameLine" });
// The derived table still expands — `FROM (` stays on the line it opens and
// the subquery's clauses align under that parenthesis, so the same-line
// setting only decides whether the group starts on the keyword's line.
expect(formatted).toContain("FROM (SELECT villageId,");
expect(formatted).toContain("\n FROM tVillage");
expect(formatted.endsWith(") AS tv")).toBe(true);
});
it("keeps display formatting lossless for XML/JSON-looking input", async () => {
+235
View File
@@ -0,0 +1,235 @@
import { describe, expect, it } from "vitest";
import { formatSqlText } from "@/lib/sql/sqlFormatter";
import { DEFAULT_SQL_FORMATTER_SETTINGS, type SqlFormatterSettings } from "@/lib/sql/sqlFormatterConfig";
/**
* Golden tests for the layout engine behind DBX's default formatting style.
* The expectations for the queries in issues #827, #4850 and #5170 pin the
* real-world shapes those reports asked for; the rest pin the rules those
* examples exercise — the collapse of short statements, the wrap of long ones,
* element alignment, and column alignment in `CREATE TABLE`.
*/
type Dialect = Parameters<typeof formatSqlText>[1];
async function format(sql: string, overrides: Partial<SqlFormatterSettings> = {}, dialect: Dialect = "mysql") {
return formatSqlText(sql, dialect, { ...DEFAULT_SQL_FORMATTER_SETTINGS, ...overrides });
}
const lines = (...rows: string[]) => rows.join("\n");
describe("sql layout", () => {
it("formats a query with a derived table (#827)", async () => {
const sql = lines(
"SELECT loc_id, loc_name, loc_type, delivery_emp_num, update_time",
"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;",
);
// The subquery's clauses align under the `(` that opens it, and the closing
// `)` stays on the last clause's line.
//
// The subquery's two select elements could be split; the screenshots in
// #827 and #4850 disagree on that point (both are two-element lists, one
// split, one not), so the keeping rule here is the one #4850's rendering
// matches.
expect(await format(sql)).toBe(
lines(
"SELECT loc_id,",
" loc_name,",
" loc_type,",
" delivery_emp_num,",
" update_time",
"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("keeps a long statement's clauses apart (#4850)", async () => {
const sql = lines(
"SELECT HIMMS_XDSBDJ.HIMMS_XDSBDJ_ID, HIMMS_XDSBDJ.YNKE, himms_zwxsysj_sysj(V_HIMMS_XDSBDJ.HIMMS_XDSBDJ_ID) AS SJSYSJ",
"FROM HIMMS_XDSBDJ",
"LEFT JOIN (SELECT himms_xdsbdj_id, MAX(SJ) SJ FROM himms_zwxqdjc GROUP BY himms_xdsbdj_id) SUB_QDJC ON HIMMS_XDSBDJ.HIMMS_XDSBDJ_ID = SUB_QDJC.himms_xdsbdj_id",
"WHERE HIMMS_XDSBDJ.tybz IS NULL;",
);
// The user-defined function is written without a space, and the join's
// subquery expands rather than pushing `ON` onto a line of its own.
expect(await format(sql)).toBe(
lines(
"SELECT HIMMS_XDSBDJ.HIMMS_XDSBDJ_ID, HIMMS_XDSBDJ.YNKE, himms_zwxsysj_sysj(V_HIMMS_XDSBDJ.HIMMS_XDSBDJ_ID) AS SJSYSJ",
"FROM HIMMS_XDSBDJ",
" LEFT JOIN (SELECT himms_xdsbdj_id, MAX(SJ) SJ",
" FROM himms_zwxqdjc",
" GROUP BY himms_xdsbdj_id) SUB_QDJC ON HIMMS_XDSBDJ.HIMMS_XDSBDJ_ID = SUB_QDJC.himms_xdsbdj_id",
"WHERE HIMMS_XDSBDJ.tybz IS NULL;",
),
);
});
it("collapses a statement that fits on one line (#5170)", async () => {
const sql = lines("SELECT", " *", "FROM", " task", "ORDER BY", " created_at DESC;");
expect(await format(sql)).toBe("SELECT * FROM task ORDER BY created_at DESC;");
});
it("aligns CREATE TABLE columns", async () => {
const sql = lines(
"CREATE TABLE IF NOT EXISTS `delivery_emp_info`",
"(",
" `id` varchar(20) PRIMARY KEY NOT NULL COMMENT '主键',",
" `emp_id` varchar(20) NOT NULL COMMENT '员工id',",
" `delivery_type_ids` varchar(512) DEFAULT NULL COMMENT '负责的运送类型id集合',",
" `update_time` datetime NOT NULL COMMENT '更新时间',",
" CONSTRAINT idx_emp_phone UNIQUE (emp_id, emp_phone_num) COMMENT '运送员id电话唯一'",
") ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COLLATE = utf8mb4_bin COMMENT '运达员信息表';",
);
// Name, type and modifiers each get their own column, so the definitions'
// `COMMENT`s line up; the constraint's `(` keeps its space.
expect(await format(sql)).toBe(
lines(
"CREATE TABLE IF NOT EXISTS `delivery_emp_info`",
"(",
" `id` varchar(20) PRIMARY KEY NOT NULL COMMENT '主键',",
" `emp_id` varchar(20) NOT NULL COMMENT '员工id',",
" `delivery_type_ids` varchar(512) DEFAULT NULL COMMENT '负责的运送类型id集合',",
" `update_time` datetime NOT NULL COMMENT '更新时间',",
" CONSTRAINT idx_emp_phone UNIQUE (emp_id, emp_phone_num) COMMENT '运送员id电话唯一'",
") ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COLLATE = utf8mb4_bin COMMENT '运达员信息表';",
),
);
});
it("keeps a call shaped like a call at any nesting depth", async () => {
const sql = "INSERT INTO t (a, b) VALUES (f(x), g(y)) ON DUPLICATE KEY UPDATE b = f(g(b));";
// The insert target's column list is spaced; the calls inside the values,
// and inside another call's arguments, are not.
expect(await format(sql)).toBe("INSERT INTO t (a, b) VALUES (f(x), g(y)) ON DUPLICATE KEY UPDATE b = f(g(b));");
});
it("breaks a long condition before each logical operator", async () => {
const sql = "SELECT a, b FROM t WHERE alpha = 1 AND beta = 2 AND gamma = 3 AND delta = 4 AND epsilon = 5 AND zeta = 6 AND eta = 7 AND theta = 8;";
// The statement is too long to collapse, and the default
// `logicalOperatorNewline: "before"` starts each continuation line with the
// operator, aligned under the condition's first operand like any other
// clause item.
expect(await format(sql)).toBe(lines("SELECT a, b", "FROM t", "WHERE alpha = 1", " AND beta = 2", " AND gamma = 3", " AND delta = 4", " AND epsilon = 5", " AND zeta = 6", " AND eta = 7", " AND theta = 8;"));
});
it("puts a wrapped condition's operators where the setting asks", async () => {
const sql = "SELECT a, b FROM t WHERE alpha = 1 AND beta = 2 AND gamma = 3 AND delta = 4 AND epsilon = 5 AND zeta = 6 AND eta = 7 AND theta = 8;";
// `after` ends the previous line with the operator instead.
expect(await format(sql, { logicalOperatorNewline: "after" })).toContain("WHERE alpha = 1 AND\n beta = 2 AND");
// `none` keeps the condition on the clause's line, however long it gets.
expect(await format(sql, { logicalOperatorNewline: "none" })).toBe(lines("SELECT a, b", "FROM t", "WHERE alpha = 1 AND beta = 2 AND gamma = 3 AND delta = 4 AND epsilon = 5 AND zeta = 6 AND eta = 7 AND theta = 8;"));
});
it("expands a parenthesized list that does not fit, aligned under its first item", async () => {
const sql = `SELECT * FROM t WHERE id IN (${Array.from({ length: 16 }, (_, index) => 100000 + index).join(", ")});`;
expect(await format(sql)).toBe(
lines(
"SELECT *",
"FROM t",
"WHERE id IN (100000,",
" 100001,",
" 100002,",
" 100003,",
" 100004,",
" 100005,",
" 100006,",
" 100007,",
" 100008,",
" 100009,",
" 100010,",
" 100011,",
" 100012,",
" 100013,",
" 100014,",
" 100015);",
),
);
});
it("keeps a small subquery inline", async () => {
expect(await format("SELECT * FROM t WHERE id IN (SELECT id FROM u WHERE a = 1 AND b = 2);")).toBe("SELECT * FROM t WHERE id IN (SELECT id FROM u WHERE a = 1 AND b = 2);");
});
it("collapses each clause independently", async () => {
const sql = "SELECT (SELECT count(*) FROM orders o WHERE o.customer_id = c.id AND o.status = 'paid') AS order_count, c.name FROM customers c;";
// The scalar subquery fits, the select list does not: only the second one
// breaks.
expect(await format(sql)).toBe(lines("SELECT (SELECT count(*) FROM orders o WHERE o.customer_id = c.id AND o.status = 'paid') AS order_count, c.name", "FROM customers c;"));
});
it("never joins a CASE onto one line", async () => {
expect(await format("SELECT CASE WHEN a = 1 THEN 'one' WHEN a = 2 THEN 'two' ELSE 'other' END AS label FROM t;")).toBe(lines("SELECT CASE", " WHEN a = 1 THEN 'one'", " WHEN a = 2 THEN 'two'", " ELSE 'other'", " END AS label", "FROM t;"));
});
it("gives a set operation its own line", async () => {
expect(await format("SELECT a FROM t UNION ALL SELECT b FROM u;")).toBe(lines("SELECT a", "FROM t", "UNION ALL", "SELECT b", "FROM u;"));
});
it("keeps comments and does not fold them onto a line", async () => {
const sql = lines("-- heading", "SELECT a, -- why", " b", "FROM t; /* trailing */");
expect(await format(sql)).toBe(lines("-- heading", "SELECT a,", " -- why", " b", "FROM t;", "", "/* trailing */"));
});
it("keeps PostgreSQL casts tight", async () => {
expect(await format("select coalesce(a, b)::text, (x + y)::int from t;", {}, "postgres")).toBe("SELECT coalesce(a, b)::text, (x + y)::int FROM t;");
});
it("keeps the comma of a MySQL LIMIT offset", async () => {
expect(await format("SELECT a FROM t LIMIT 5, 10;", {}, "mysql")).toBe("SELECT a FROM t LIMIT 5, 10;");
});
it("indents with tabs when asked", async () => {
const sql = "SELECT alpha_column, beta_column, gamma_column, delta_column FROM some_really_long_table_name WHERE first_condition = 1 AND second_condition = 2 AND third_condition = 3;";
// Only the leading indentation becomes a tab; the alignment padding stays
// spaces so a continuation column lands where it was measured.
expect(await format(sql, { useTabs: true, tabWidth: 4 })).toBe(["SELECT alpha_column,", "\t beta_column,", "\t gamma_column,", "\t delta_column", "FROM some_really_long_table_name", "WHERE first_condition = 1", "\t AND second_condition = 2", "\t AND third_condition = 3;"].join("\n"));
});
it("moves a FROM source to its own line when configured", async () => {
const sql = "SELECT alpha_column, beta_column, gamma_column, delta_column FROM some_really_long_table_name WHERE first_condition = 1 AND second_condition = 2 AND third_condition = 3;";
expect(await format(sql, { fromClauseLayout: "newLine" })).toBe(
lines("SELECT alpha_column,", " beta_column,", " gamma_column,", " delta_column", "FROM", " some_really_long_table_name", "WHERE first_condition = 1", " AND second_condition = 2", " AND third_condition = 3;"),
);
});
it("honours the blank lines between queries", async () => {
const sql = "SELECT 1;\nSELECT 2;";
expect(await format(sql, { linesBetweenQueries: 0 })).toBe(lines("SELECT 1;", "SELECT 2;"));
expect(await format(sql, { linesBetweenQueries: 1 })).toBe(lines("SELECT 1;", "", "SELECT 2;"));
expect(await format(sql, { linesBetweenQueries: 2 })).toBe(lines("SELECT 1;", "", "", "SELECT 2;"));
});
it("keeps a blank source line when the setting is on", async () => {
expect(await format(lines("-- heading", "", "SELECT a FROM t;"), { preserveEmptyLines: true })).toBe(lines("-- heading", "", "SELECT a", "FROM t;"));
});
it("falls back to sql-formatter for the tabular indent styles", async () => {
// The tabular layouts pad every clause to a fixed column; the default
// layout rules do not reproduce them, so those settings keep the upstream
// formatter.
expect(await format("SELECT a, b FROM t WHERE x = 1;", { indentStyle: "tabularLeft" })).toBe(lines("SELECT a,", " b", "FROM t", "WHERE x = 1;"));
});
it("formats a dialect without its own grammar", async () => {
expect(await format("select * from t where a = 1 and b = 2;", {}, "generic")).toBe("SELECT * FROM t WHERE a = 1 AND b = 2;");
});
});
+160
View File
@@ -0,0 +1,160 @@
import type { AstNode, ClauseNode, ParenthesisNode, StatementNode } from "sql-formatter/dist/esm/parser/ast.js";
import { CREATE_TABLE_KEYWORD, keywordText, renderInline, splitByComma, Writer, type SqlLayoutContext } from "./primitives";
/**
* Column alignment for `CREATE TABLE`.
*
* The definition list is laid out on a character grid so the three parts of
* every column — name, type, nullability/default — line up, which is what makes
* a wide table readable:
*
* `id` varchar(20) PRIMARY KEY NOT NULL COMMENT '主键',
* `delivery_type_ids` varchar(512) DEFAULT NULL COMMENT '…',
* `update_time` datetime NOT NULL COMMENT '…'
*
* The definition list goes one column per line under its own `(`, and the table
* options move below the closing `)`.
*
* This is applied only to statements that match the shape exactly (see
* `isCreateTable`). Anything else — a `CREATE TABLE ... AS SELECT`, a dialect
* keyword sql-formatter parses differently — keeps the ordinary query layout
* rather than being aligned on a guess.
*/
/** The pieces of one column definition, already rendered. */
interface ColumnDefinition {
name: string;
type: string;
modifiers: string;
/** Everything from `COMMENT` onwards. */
tail: string;
}
type Definition = { kind: "column"; column: ColumnDefinition } | { kind: "other"; nodes: AstNode[] };
function isDataType(node: AstNode | undefined): boolean {
return node?.type === "data_type" || node?.type === "parameterized_data_type";
}
function isCommentNode(node: AstNode | undefined): boolean {
return node?.type === "identifier" && node.text.toUpperCase() === "COMMENT";
}
/**
* Splits one comma-separated definition into its aligned parts, or `null` when
* the item is not a plain `name type [modifiers] [COMMENT …]` column.
*/
function splitColumn(ctx: SqlLayoutContext, item: AstNode[]): ColumnDefinition | null {
const [nameNode, typeNode] = item;
if (nameNode?.type !== "identifier" || !isDataType(typeNode)) return null;
const commentAt = item.findIndex((node, index) => index >= 2 && isCommentNode(node));
const modifierNodes = item.slice(2, commentAt === -1 ? item.length : commentAt);
const tailNodes = commentAt === -1 ? [] : item.slice(commentAt);
const name = renderInline(ctx, [nameNode], ctx.options.lineWidth);
const type = renderInline(ctx, [typeNode], ctx.options.lineWidth);
const modifiers = renderInline(ctx, modifierNodes, ctx.options.lineWidth);
const tail = renderInline(ctx, tailNodes, ctx.options.lineWidth);
if (!name || !type || (modifierNodes.length > 0 && !modifiers) || (tailNodes.length > 0 && !tail)) return null;
return { name, type, modifiers: modifiers ?? "", tail: tail ?? "" };
}
/**
* Whether `statement` is a `CREATE TABLE` whose body is a list of column
* definitions this module can align. Everything else keeps the query layout.
*/
export function isCreateTable(statement: StatementNode): boolean {
return createTableParts(statement) !== null;
}
function createTableParts(statement: StatementNode): { clause: ClauseNode; name: AstNode; paren: ParenthesisNode; rest: AstNode[] } | null {
if (statement.children.length !== 1) return null;
const clause = statement.children[0];
if (clause.type !== "clause") return null;
if (!CREATE_TABLE_KEYWORD.test(clause.nameKw.text.trim())) return null;
const [name, paren] = clause.children;
if (name?.type !== "identifier" || paren?.type !== "parenthesis") return null;
// A `CREATE TABLE ... AS SELECT` has no column list to align.
if (paren.children.length === 0) return null;
return { clause, name, paren, rest: clause.children.slice(2) };
}
/** Emits a `CREATE TABLE`, aligning the definition list. */
export function createTableLayout(statement: StatementNode, ctx: SqlLayoutContext): string {
const parts = createTableParts(statement);
if (!parts) throw new Error("createTableLayout called on a statement that is not a CREATE TABLE");
const { clause, name, paren, rest } = parts;
const writer = new Writer(ctx.options);
const indent = ctx.options.indentWidth;
const nameText = renderInline(ctx, [name], ctx.options.lineWidth - clause.nameKw.text.length - 1);
if (!nameText) throw new Error("a CREATE TABLE table name must fit on one line");
writer.write(`${keywordText(clause.nameKw.text, ctx)} ${nameText}`);
writer.newline(0);
writer.write("(");
const definitions: Definition[] = splitByComma(paren.children).map((item) => {
const column = splitColumn(ctx, item);
return column ? { kind: "column", column } : { kind: "other", nodes: item };
});
// Column widths are shared by every line, so measure before emitting.
const columns = definitions.flatMap((definition) => (definition.kind === "column" ? [definition.column] : []));
const nameWidth = Math.max(0, ...columns.map((column) => column.name.length));
const typeWidth = Math.max(0, ...columns.map((column) => column.type.length));
const modifierWidth = Math.max(0, ...columns.map((column) => column.modifiers.length));
definitions.forEach((definition, index) => {
writer.newline(indent);
if (definition.kind === "column") {
const { name: columnName, type, modifiers, tail } = definition.column;
writer.write(columnName.padEnd(nameWidth));
if (type) writer.write(` ${type.padEnd(typeWidth)}`);
if (modifiers) writer.write(` ${modifiers.padStart(modifierWidth)}`);
if (tail) writer.write(` ${tail}`);
} else {
emitDefinition(writer, definition.nodes, indent, ctx);
}
if (index < definitions.length - 1) writer.write(",");
});
writer.newline(0);
writer.write(")");
if (rest.length > 0) {
const options = renderInline(ctx, rest, ctx.options.lineWidth - 1);
if (options) {
writer.write(` ${options}`);
} else {
writer.newline(indent);
writer.write(ctx.renderers.block(rest).trim());
}
}
if (statement.hasSemicolon) writer.write(";");
return writer.toString();
}
/**
* Emits a definition that is not a plain column — a table constraint. The
* constraint name gets its own line and its body is indented one step.
*/
function emitDefinition(writer: Writer, nodes: AstNode[], indent: number, ctx: SqlLayoutContext): void {
const flat = renderInline(ctx, nodes, ctx.options.lineWidth - indent);
if (flat) {
writer.write(flat);
return;
}
const [first, second] = nodes;
if (first?.type === "keyword" && second?.type === "identifier") {
writer.write(`${keywordText(first.text, ctx)} ${renderInline(ctx, [second], ctx.options.lineWidth) ?? second.text}`);
writer.newline(indent + ctx.options.indentWidth);
writer.write(ctx.renderers.block(nodes.slice(2)).trim());
return;
}
writer.write(ctx.renderers.block(nodes).trim());
}
+83
View File
@@ -0,0 +1,83 @@
import type { DialectOptions } from "sql-formatter/dist/esm/dialect.js";
import { createRenderers, loadEngine, type SqlFormatterEngine, type SqlFormatterParamValues } from "./internals";
import { printSqlLayout, type SqlLayoutOptions } from "./printer";
export type { SqlLayoutOptions } from "./printer";
/**
* Default layout knobs: a 120-column hard wrap, element lists of up to three
* kept on one line, a two-space indent, and joins indented one step below their
* FROM clause.
*/
export const DEFAULT_SQL_LAYOUT_OPTIONS: SqlLayoutOptions = {
lineWidth: 120,
keepElementsOnOneLine: 3,
indentWidth: 2,
joinIndentWidth: 4,
keywordCase: "preserve",
logicalOperatorNewline: "before",
linesBetweenQueries: 1,
useTabs: false,
fromClauseSourceOnSameLine: true,
};
export interface SqlLayoutRequest {
sql: string;
/** sql-formatter dialect key, e.g. `mysql` / `postgresql` / `plsql`. */
language: string;
/**
* The dialect options to parse with, when the caller has already resolved
* them. Parsing with anything but the caller's own dialect would make this
* layout disagree with the statements the public formatter produces for the
* same input — DBX's ClickHouse tweak, which keeps date-part abbreviations
* out of the reserved list, is exactly such a case.
*/
dialectOptions?: DialectOptions;
/** sql-formatter `FormatOptions` (casing flags, tab width, param types, ...). */
cfg: Record<string, unknown>;
options?: Partial<SqlLayoutOptions>;
}
function dialectFor(engine: SqlFormatterEngine, request: SqlLayoutRequest) {
const dialectOptions = request.dialectOptions ?? engine.dialects[request.language] ?? engine.dialects.sql;
return engine.createDialect(dialectOptions);
}
/**
* Formats `sql` with the default style's layout rules.
*
* Returns `null` when sql-formatter's internals are unavailable or the input
* cannot be parsed, so callers can fall back to the public `format()` API
* instead of surfacing a failure to the user.
*/
export async function formatSqlLayout(request: SqlLayoutRequest): Promise<string | null> {
const engine = await loadEngine();
if (!engine) return null;
const dialect = dialectFor(engine, request);
const options: SqlLayoutOptions = {
...DEFAULT_SQL_LAYOUT_OPTIONS,
keywordCase: (request.cfg.keywordCase as SqlLayoutOptions["keywordCase"]) ?? "preserve",
...request.options,
};
// `indentStyle` selects sql-formatter's tabular layouts, which pad every clause
// to a fixed column, and `useTabs` makes it indent with tabs. The rules here
// replace that layout and write their own indentation, so both are pinned: the
// renderers must emit space-indented text relative to column 0, which the
// printer then re-bases onto the column it measured.
// The annotation keeps the index signature spread objects would otherwise lose,
// so `paramTypes`/`params` stay readable below.
const cfg: Record<string, unknown> = { ...request.cfg, indentStyle: "standard", useTabs: false };
try {
const statements = engine.parse(request.sql, dialect, (cfg.paramTypes as Record<string, unknown>) ?? {});
return printSqlLayout(statements, {
renderers: createRenderers(engine, dialect, cfg, new engine.Params(cfg.params as SqlFormatterParamValues), options.indentWidth),
options,
});
} catch {
// Parse failures are the formatter's normal "not supported yet" signal; the
// caller degrades to sql-formatter's own output or to the original text.
return null;
}
}
@@ -0,0 +1,198 @@
import type { Dialect, DialectOptions } from "sql-formatter/dist/esm/dialect.js";
import type ExpressionFormatter from "sql-formatter/dist/esm/formatter/ExpressionFormatter.js";
import type Indentation from "sql-formatter/dist/esm/formatter/Indentation.js";
import type InlineLayout from "sql-formatter/dist/esm/formatter/InlineLayout.js";
import type Layout from "sql-formatter/dist/esm/formatter/Layout.js";
import type Params from "sql-formatter/dist/esm/formatter/Params.js";
import type { AstNode, StatementNode } from "sql-formatter/dist/esm/parser/ast.js";
/**
* Access to sql-formatter's bundled parser and layout machinery.
*
* sql-formatter's public surface is `format()` / `formatDialect()`, and its
* output layout is not configurable enough for the default style DBX targets:
* clause keywords must sit flush left, a clause's continuation lines align under
* its first item, and parenthesized groups stay inline while they fit. All of
* that needs the AST plus the "measure, don't wrap" layout the package only
* ships internally, so these imports go through the `sql-formatter/dist/` alias
* set up in vite.config.ts and vitest.config.ts.
*
* This module is the single place that reaches into the package internals. If a
* dependency bump ever breaks one of these shapes, `loadEngine()` reports that
* and every caller falls back to the public `format()` API rather than throwing.
*/
type InlineLayoutCtor = typeof InlineLayout;
type InlineLayoutErrorCtor = typeof import("sql-formatter/dist/esm/formatter/InlineLayout.js").InlineLayoutError;
type ExpressionFormatterCtor = typeof ExpressionFormatter;
type ParamsCtor = typeof Params;
type LayoutCtor = typeof Layout;
type IndentationCtor = typeof Indentation;
/**
* The values `Params` resolves placeholders against, as sql-formatter types them:
* `cfg.params`, which DBX never sets — the config importer rejects that legacy
* key — so placeholders keep their literal text in the output.
*/
export type SqlFormatterParamValues = ConstructorParameters<ParamsCtor>[0];
/** The subset of sql-formatter's internals the layout printer needs. */
export interface SqlFormatterEngine {
/** Every bundled dialect, keyed by the name `format()` accepts. */
readonly dialects: Record<string, DialectOptions>;
readonly createDialect: (options: DialectOptions) => Dialect;
readonly parse: (sql: string, dialect: Dialect, paramTypes: Record<string, unknown>) => StatementNode[];
readonly ExpressionFormatter: ExpressionFormatterCtor;
readonly InlineLayout: InlineLayoutCtor;
readonly InlineLayoutError: InlineLayoutErrorCtor;
readonly Params: ParamsCtor;
readonly Layout: LayoutCtor;
readonly Indentation: IndentationCtor;
}
let enginePromise: Promise<SqlFormatterEngine | null> | null = null;
async function importEngine(): Promise<SqlFormatterEngine | null> {
try {
// Literal specifiers only: Vite cannot statically analyse a computed path,
// and these must resolve through the `sql-formatter/dist/` alias.
const [parserMod, dialectMod, allDialects, expressionMod, inlineLayoutMod, paramsMod, layoutMod, indentationMod] = await Promise.all([
import("sql-formatter/dist/esm/parser/createParser.js"),
import("sql-formatter/dist/esm/dialect.js"),
import("sql-formatter/dist/esm/allDialects.js"),
import("sql-formatter/dist/esm/formatter/ExpressionFormatter.js"),
import("sql-formatter/dist/esm/formatter/InlineLayout.js"),
import("sql-formatter/dist/esm/formatter/Params.js"),
import("sql-formatter/dist/esm/formatter/Layout.js"),
import("sql-formatter/dist/esm/formatter/Indentation.js"),
]);
const createParser = parserMod.createParser;
const createDialect = dialectMod.createDialect;
const ExpressionFormatter = expressionMod.default;
const InlineLayout = inlineLayoutMod.default;
const InlineLayoutError = inlineLayoutMod.InlineLayoutError;
const Params = paramsMod.default;
const Layout = layoutMod.default;
const Indentation = indentationMod.default;
if (
typeof createParser !== "function" ||
typeof createDialect !== "function" ||
typeof ExpressionFormatter !== "function" ||
typeof InlineLayout !== "function" ||
typeof InlineLayoutError !== "function" ||
typeof Params !== "function" ||
typeof Layout !== "function" ||
typeof Indentation !== "function" ||
!allDialects ||
typeof allDialects !== "object"
) {
return null;
}
return {
dialects: allDialects as unknown as Record<string, DialectOptions>,
createDialect,
parse: (sql, dialect, paramTypes) => createParser(dialect.tokenizer).parse(sql, paramTypes),
ExpressionFormatter,
InlineLayout,
InlineLayoutError,
Params,
Layout,
Indentation,
};
} catch {
// Older/newer sql-formatter layouts move these files around. Report "no
// engine" so the caller uses the public formatter instead of failing the
// user's format action outright.
return null;
}
}
/** Loads (and caches) the engine, or `null` when the internals are unavailable. */
export function loadEngine(): Promise<SqlFormatterEngine | null> {
enginePromise ??= importEngine();
return enginePromise;
}
/**
* The two rendering primitives the layout printer builds on.
*
* Both render *relative to column 0*; positioning is the printer's job, which is
* what lets it align clause items under a column that depends on the enclosing
* `(` rather than on a fixed indent level.
*/
export interface SqlLayoutRenderers {
/** Renders `nodes` on a single line, or `null` when they do not fit `width`. */
inline(nodes: AstNode[], width: number): string | null;
/** Renders `nodes` on one or more lines, indented relative to column 0. */
block(nodes: AstNode[]): string;
/**
* Whether the dialect writes `text` without a space in front of it.
*
* The printer renders a parenthesis group and its surroundings in separate
* steps, so it decides the separating space itself and has to agree with the
* renderer: sql-formatter drops the space for a "dense" operator such as
* PostgreSQL's `::`, and `cfg.denseOperators` makes every operator dense.
*/
denseOperator(text: string): boolean;
}
/**
* Measures `nodes` with sql-formatter's `InlineLayout`, returning `null` instead
* of throwing when the group cannot be a single line.
*
* `InlineLayout` raises for both reasons we care about — a construct that
* genuinely requires a line break (a clause, a `--` comment) and text that would
* overflow `width` — so one call answers "would this fit on the line?" exactly,
* which is what the collapse rule is decided on.
*/
export function renderInline(engine: SqlFormatterEngine, nodes: AstNode[], dialect: Dialect, cfg: Record<string, unknown>, params: InstanceType<ParamsCtor>, width: number): string | null {
if (nodes.length === 0 || width <= 0) return null;
// `formatParenthesis` measures nested groups against `cfg.expressionWidth`, so
// point it at the width available here. Rendering is synchronous, and the
// previous value is restored below.
const previousWidth = cfg.expressionWidth;
cfg.expressionWidth = width;
try {
const formatter = new engine.ExpressionFormatter({
cfg: cfg as never,
dialectCfg: dialect.formatOptions,
params: params as never,
layout: new engine.InlineLayout(width) as never,
inline: true,
});
// No whitespace collapsing here: an inline layout never emits a newline (it
// raises first) and squashing runs of spaces would corrupt string literals.
const text = formatter.format(nodes).toString().trim();
return text || null;
} catch (error) {
if (error instanceof engine.InlineLayoutError) return null;
throw error;
} finally {
cfg.expressionWidth = previousWidth;
}
}
/** Creates the inline/block renderer pair for one dialect and configuration. */
export function createRenderers(engine: SqlFormatterEngine, dialect: Dialect, cfg: Record<string, unknown>, params: InstanceType<ParamsCtor>, indentWidth: number): SqlLayoutRenderers {
const indent = " ".repeat(Math.max(1, indentWidth));
const alwaysDenseOperators = dialect.formatOptions.alwaysDenseOperators ?? [];
return {
inline: (nodes, width) => renderInline(engine, nodes, dialect, cfg, params, width),
block: (nodes) => {
const layout = new engine.Layout(new engine.Indentation(indent));
const formatter = new engine.ExpressionFormatter({
cfg: cfg as never,
dialectCfg: dialect.formatOptions,
params: params as never,
layout: layout as never,
inline: false,
});
return formatter.format(nodes).toString();
},
denseOperator: (text) => cfg.denseOperators === true || alwaysDenseOperators.includes(text),
};
}
@@ -0,0 +1,440 @@
import type { AstNode, ClauseNode, KeywordNode, LimitClauseNode, ParenthesisNode, SetOperationNode } from "sql-formatter/dist/esm/parser/ast.js";
import type { SqlLayoutRenderers } from "./internals";
/**
* The building blocks both layout strategies share: a column-tracking output
* buffer, the width measurements that decide whether a group fits, and the
* helpers that recognise the structural nodes of a statement body.
*
* The layout rules themselves live in `printer.ts` (queries) and `ddl.ts`
* (`CREATE TABLE` column alignment).
*/
export interface SqlLayoutOptions {
/** Hard wrap column; a group wider than this is broken across lines. */
lineWidth: number;
/** Maximum number of clause items kept on the keyword's line. */
keepElementsOnOneLine: number;
/** One indent step, in columns. */
indentWidth: number;
/** Extra indent applied to JOIN keywords, in columns. */
joinIndentWidth: number;
/** Keyword casing, matching sql-formatter's `keywordCase`. */
keywordCase: "preserve" | "upper" | "lower";
/**
* Where a wrapped condition puts its `AND`/`OR`, matching DBX's own
* "logical operator newline" setting.
*
* `before` starts the continuation line with the operator, `after` ends the
* previous line with it, and `none` keeps the whole condition on one line
* wherever it fits, matching the element alignment rule.
*/
logicalOperatorNewline: "before" | "after" | "none";
/** Blank lines left between two statements. */
linesBetweenQueries: number;
/** Whether indentation is written with tab characters. */
useTabs: boolean;
/**
* Whether a `FROM` clause's first source stays on the keyword's line.
*
* The default keeps it there; DBX exposes the alternative as its
* "FROM clause" setting.
*/
fromClauseSourceOnSameLine: boolean;
}
export interface SqlLayoutContext {
renderers: SqlLayoutRenderers;
options: SqlLayoutOptions;
/**
* Parentheses that must not be glued to the name before them, even though it
* is an identifier. Set only for an `INSERT` target's column list; see
* {@link clauseChildContext}.
*/
nonCallParens?: ReadonlySet<AstNode>;
/**
* Whether a condition may be joined onto one line here.
*
* Defaults to `logicalOperatorNewline === "none"`: the other two values exist
* precisely because the caller wants the operators broken out. Collapse
* measurements override it — see {@link collapsedContext}.
*/
joinLogicalOperators?: boolean;
}
/**
* Column-tracking output buffer.
*
* Positions are tracked as display columns rather than string offsets, because
* the layout aligns continuations under a column that depends on the enclosing
* `(`. With `useTabs` only the leading indentation becomes tabs — the padding
* used for alignment stays spaces, so a column target that is not a multiple of
* the tab width still lands where it was measured.
*/
export class Writer {
private lines: string[] = [""];
private columns: number[] = [0];
constructor(private readonly options: Pick<SqlLayoutOptions, "useTabs" | "indentWidth">) {}
/** Column the cursor currently sits at, on the last line. */
get column(): number {
return this.columns[this.columns.length - 1];
}
/** Appends text verbatim. */
write(text: string): void {
this.lines[this.lines.length - 1] += text;
this.columns[this.columns.length - 1] += text.length;
}
/** Appends a single separating space, unless one is already there. */
space(): void {
const line = this.lines[this.lines.length - 1];
if (line.length > 0 && !line.endsWith(" ")) {
this.lines[this.lines.length - 1] = `${line} `;
this.columns[this.columns.length - 1] += 1;
}
}
/** Starts a new line at `indent` columns, dropping trailing blanks first. */
newline(indent: number): void {
const last = this.lines.length - 1;
this.lines[last] = this.lines[last].trimEnd();
const column = Math.max(0, indent);
this.lines.push(this.indentation(column));
this.columns.push(column);
}
toString(): string {
return this.lines
.map((line) => line.trimEnd())
.join("\n")
.trim();
}
/** The whitespace achieving `column` columns of indentation. */
private indentation(column: number): string {
if (!this.options.useTabs) return " ".repeat(column);
const width = Math.max(1, this.options.indentWidth);
return "\t".repeat(Math.floor(column / width)) + " ".repeat(column % width);
}
}
export function isParenthesis(node: AstNode): node is ParenthesisNode {
return node.type === "parenthesis";
}
/**
* The keyword a node opens its line with, or `null` for a node that is part of
* an expression. Clauses, set operations and limit clauses each start a line.
*/
export function bodyKeyword(node: AstNode): string | null {
if (node.type === "clause" || node.type === "set_operation") {
return (node as ClauseNode | SetOperationNode).nameKw.text;
}
if (node.type === "limit_clause") {
return (node as LimitClauseNode).limitKw.text;
}
return null;
}
export function isBodyNode(node: AstNode): boolean {
return bodyKeyword(node) !== null;
}
/**
* The context a collapse measurement renders in.
*
* The collapse rule *joins* statements the element placement would otherwise
* spread over several lines, so a condition inside a
* statement — or a subquery — being measured for one line joins its operators
* even when the user asked for `logicalOperatorNewline: "before"`.
*/
export function collapsedContext(ctx: SqlLayoutContext): SqlLayoutContext {
return ctx.joinLogicalOperators ? ctx : { ...ctx, joinLogicalOperators: true };
}
/**
* The comma the parser does not materialise for `LIMIT 5, 10`, where the offset
* and the count are separate expression chains. sql-formatter writes the
* separator itself while formatting; here the two are joined as ordinary clause
* items, so the separator has to be an item.
*/
const LIMIT_SEPARATOR = { type: "comma" } as unknown as AstNode;
/** The expressions a `LIMIT` clause renders, in sql-formatter's own order. */
export function limitChildren(node: LimitClauseNode): AstNode[] {
return node.offset ? [...node.offset, LIMIT_SEPARATOR, ...node.count] : [...node.count];
}
export function keywordText(text: string, ctx: SqlLayoutContext): string {
switch (ctx.options.keywordCase) {
case "upper":
return text.toUpperCase();
case "lower":
return text.toLowerCase();
default:
return text;
}
}
export function splitByComma(children: AstNode[]): AstNode[][] {
const groups: AstNode[][] = [];
let current: AstNode[] = [];
for (const child of children) {
if (child.type === "comma") {
groups.push(current);
current = [];
} else {
current.push(child);
}
}
groups.push(current);
return groups;
}
/**
* Keywords that introduce an object name rather than an expression operand, so
* a parenthesis directly after that name is a definition list, not a call:
* `INTO t (a, b)`, `REFERENCES t (col)`, `ON t (col)`.
*/
const OBJECT_NAME_KEYWORD = /^(?:INTO|TABLE|VIEW|INDEX|REFERENCES|ON)$/i;
/**
* Whether `(` at `index` is a function call on the name before it. The default
* style writes `f(x)`, not `f (x)`; sql-formatter separates an unknown function name
* from its argument list because it cannot tell a call from a keyword.
*
* `INSERT INTO t (a, b)` and `REFERENCES t (col)` have the same shape but are
* not calls. The keyword in front of the name is what tells the two apart here;
* a statement head puts its object name in a clause of its own, where that
* keyword is not a sibling — see {@link clauseChildContext}.
*/
export function isCallParen(nodes: AstNode[], index: number, ctx: SqlLayoutContext): boolean {
const node = nodes[index];
if (ctx.nonCallParens?.has(node)) return false;
const previous = index > 0 ? nodes[index - 1] : undefined;
if (previous?.type !== "identifier" && previous?.type !== "property_access") return false;
const before = index > 1 ? nodes[index - 2] : undefined;
return !(before?.type === "keyword" && OBJECT_NAME_KEYWORD.test(before.text.trim()));
}
/** `CREATE TABLE` plus the modifiers dialects may put around it. */
const CREATE_TABLE_HEAD = "CREATE\\s+(?:OR\\s+REPLACE\\s+)?(?:GLOBAL\\s+|LOCAL\\s+)?(?:TEMP(?:ORARY)?\\s+)?(?:UNLOGGED\\s+)?TABLE(?:\\s+IF\\s+NOT\\s+EXISTS)?";
/**
* A `CREATE TABLE` head. Shared with `ddl.ts` so that the column alignment and
* the definition-list rule below agree on exactly which statements they describe.
*/
export const CREATE_TABLE_KEYWORD = new RegExp(`^${CREATE_TABLE_HEAD}$`, "i");
/**
* Clause keywords whose object name is followed by a parenthesized *definition
* list* rather than call arguments: `INSERT INTO t (a, b)` takes column names,
* `CREATE TABLE t (id int)` takes column definitions.
*/
const DEFINITION_LIST_KEYWORD = new RegExp(`^(?:INSERT\\b[\\s\\S]*\\bINTO|${CREATE_TABLE_HEAD})$`, "i");
/**
* The context a clause's own children are rendered in.
*
* A statement head holds its object name as a clause child (`t` in
* `INSERT INTO t (a, b)`), so the keyword identifying it is the clause's own and
* {@link isCallParen}'s lookback cannot see it. Marking the group rather than
* the whole clause leaves every other parenthesis in the clause — an
* `ON DUPLICATE KEY UPDATE b = f(c)`, which the parser also puts here — a call.
*/
export function clauseChildContext(ctx: SqlLayoutContext, clause: ClauseNode): SqlLayoutContext {
if (clause.children.length < 2 || !DEFINITION_LIST_KEYWORD.test(clause.nameKw.text.trim())) return ctx;
const at = clause.children.findIndex(isParenthesis);
if (at <= 0) return ctx;
return { ...ctx, nonCallParens: new Set([...(ctx.nonCallParens ?? []), clause.children[at]]) };
}
/**
* Renders `nodes` on one line, or `null` when they do not fit in `width`.
*
* Two cases go beyond a straight call to `renderers.inline`:
*
* - Parenthesized groups are rendered here, by {@link renderParenGroup}. Going
* through our own rules at every nesting depth is what keeps `f(x)` free of a
* space — inside another call's arguments as much as at the top level.
* - A run of clauses is collapsible. `renderers.inline` refuses anything whose
* layout needs a line break, clauses included, but the default style does put
* `SELECT a FROM t` on one line when it fits, and the same rule keeps a small
* subquery inline.
*
* Logical operators are joined by {@link renderLogicalJoin} when the caller's
* `logicalOperatorNewline` asks for it, so a condition stays on its clause's line
* while it fits; the printer splits the operators onto their own lines otherwise.
*/
export function renderInline(ctx: SqlLayoutContext, nodes: AstNode[], width: number): string | null {
if (nodes.length === 0 || width <= 0) return null;
const parenAt = nodes.findIndex(isParenthesis);
if (parenAt >= 0) {
const group = renderParenGroup(ctx, nodes, parenAt, width);
if (group) return group;
}
const direct = ctx.renderers.inline(nodes, width);
if (direct) return direct;
const joinsOperators = ctx.joinLogicalOperators ?? ctx.options.logicalOperatorNewline === "none";
const joined = joinsOperators && nodes.some(isLogicalOperator) ? renderLogicalJoin(ctx, nodes, width) : null;
if (joined) return joined;
if (!nodes.some(isBodyNode)) return null;
const parts: string[] = [];
let used = 0;
for (const node of nodes) {
const keyword = bodyKeyword(node);
if (!keyword) return null;
const childCtx = node.type === "limit_clause" ? ctx : clauseChildContext(ctx, node as ClauseNode);
const children = node.type === "limit_clause" ? limitChildren(node as LimitClauseNode) : (node as ClauseNode).children;
const text = renderInline(childCtx, children, width - used - keyword.length - 2);
if (!text) return null;
used += (parts.length > 0 ? 1 : 0) + keyword.length + 1 + text.length;
if (used > width) return null;
parts.push(`${keywordText(keyword, ctx)} ${text}`);
}
return parts.join(" ");
}
/**
* Renders the parenthesis group at `at` and the rest of `nodes` behind it, or
* `null` when the line does not fit.
*
* The group is measured from the inside out: its contents get the width left
* after the name before it, and whatever follows gets what the group left. A
* call — a name directly in front of the group — is written without a
* separator, everything else with one, which is the only difference between
* `f(x)` and `IN (x)`.
*/
function renderParenGroup(ctx: SqlLayoutContext, nodes: AstNode[], at: number, width: number): string | null {
const node = nodes[at] as ParenthesisNode;
// A group that fits is a collapsed statement in its own right, so its contents
// join their logical operators whatever the caller's operator placement is.
const groupCtx = collapsedContext(ctx);
const head = at === 0 ? "" : renderInline(groupCtx, nodes.slice(0, at), width);
if (head === null) return null;
const separator = at === 0 || isCallParen(nodes, at, ctx) ? "" : " ";
const inner = renderInline(groupCtx, node.children, width - head.length - separator.length - node.openParen.length - node.closeParen.length);
if (!inner) return null;
const group = `${head}${separator}${node.openParen}${inner}${node.closeParen}`;
const rest = nodes.slice(at + 1);
if (rest.length === 0) return group.length <= width ? group : null;
const tail = renderInline(groupCtx, rest, width - group.length - 1);
if (!tail) return null;
const text = `${group}${separatorBefore(rest[0], ctx)}${tail}`;
return text.length <= width ? text : null;
}
/** The space that belongs between a rendered group and the node following it. */
function separatorBefore(node: AstNode, ctx: SqlLayoutContext): string {
if (node.type === "comma") return "";
if (node.type === "operator" && ctx.renderers.denseOperator(node.text)) return "";
return " ";
}
/** Whether `node` is an `AND`/`OR`/`XOR` joining two conditions. */
export function isLogicalOperator(node: AstNode): node is KeywordNode {
return node.type === "keyword" && (node.tokenType === "AND" || node.tokenType === "OR" || node.tokenType === "XOR");
}
/** Whether `node` opens a new join element — the `JOIN` keyword itself. */
export function isJoinKeyword(node: AstNode): node is KeywordNode {
return node.type === "keyword" && node.tokenType === "RESERVED_JOIN";
}
/**
* The operands of a condition, each after the first starting with the `AND`/`OR`
* that introduces it, or `null` when `nodes` is not such a chain.
*
* Only the operators at this level count: one nested inside a parenthesis belongs
* to that group, which is printed — and split — on its own.
*/
export function splitLogicalOperands(nodes: AstNode[]): AstNode[][] | null {
const operands: AstNode[][] = [];
let current: AstNode[] = [];
for (const node of nodes) {
if (!isLogicalOperator(node)) {
current.push(node);
continue;
}
if (current.length === 0) return null;
operands.push(current);
current = [node];
}
if (operands.length === 0 || current.length === 0) return null;
operands.push(current);
return operands;
}
/**
* Joins the operands of a condition onto one line, or returns `null` when the
* result would not fit.
*
* `renderers.inline` raises on a logical operator because sql-formatter always
* breaks the line there. The default style instead keeps a clause's elements on
* the clause's line and wraps them only past the hard wrap column, so `WHERE a = 1
* AND b = 2` is one line while `... AND <a long enough operand>` is not.
*
* Splitting here rather than relaxing the renderer keeps the refusal intact for
* the constructs that must stay multi-line: a JOIN or a CASE is not a split
* point, so neither is ever folded onto one line by this.
*/
function renderLogicalJoin(ctx: SqlLayoutContext, nodes: AstNode[], width: number): string | null {
const parts: string[] = [];
let run: AstNode[] = [];
let used = 0;
const flush = (): boolean => {
if (run.length === 0) return true;
const text = renderInline(ctx, run, width - used);
if (!text) return false;
used += text.length + 1;
parts.push(text);
run = [];
return true;
};
for (const node of nodes) {
if (!isLogicalOperator(node)) {
run.push(node);
continue;
}
if (!flush()) return null;
const operator = keywordText(node.text, ctx);
used += operator.length + 1;
parts.push(operator);
}
if (!flush()) return null;
return used - 1 <= width ? parts.join(" ") : null;
}
/**
* Writes possibly multi-line `text`, re-basing each continuation line on the
* column the block starts at. `text` is rendered relative to column 0, so its
* own leading whitespace carries the nesting levels and only needs shifting.
*/
export function emitText(writer: Writer, text: string, baseColumn: number): void {
const lines = text.split("\n");
writer.write(lines[0].trimEnd());
for (let index = 1; index < lines.length; index++) {
const line = lines[index];
if (line.trim() === "") {
writer.newline(0);
continue;
}
writer.newline(baseColumn + line.length - line.trimStart().length);
writer.write(line.trimStart());
}
}
+282
View File
@@ -0,0 +1,282 @@
import type { AstNode, ClauseNode, LimitClauseNode, ParenthesisNode, StatementNode } from "sql-formatter/dist/esm/parser/ast.js";
import { createTableLayout, isCreateTable } from "./ddl";
import { clauseChildContext, collapsedContext, emitText, isBodyNode, isCallParen, isJoinKeyword, isLogicalOperator, isParenthesis, keywordText, limitChildren, renderInline, splitByComma, splitLogicalOperands, Writer, type SqlLayoutContext } from "./primitives";
export type { SqlLayoutContext, SqlLayoutOptions } from "./primitives";
/**
* SQL layout — DBX's default formatting style.
*
* The rules:
*
* - Clause keywords start at the block's left margin, with no padding after
* them.
* - A clause's first item stays on the keyword's line, and its remaining items
* align under the first item's column.
* - Short element lists stay collapsed.
* - A statement whose clauses all fit the line width is emitted as a single
* line.
* - Join keywords are indented relative to their FROM clause.
*
* Alignment is *column-relative*, not indent-level-relative: a subquery's
* clauses line up under the `(` that opens it, wherever that `(` happens to be.
* sql-formatter's `Layout`/`Indentation` model cannot express that, which is why
* this printer walks the AST itself and only delegates the rendering of
* individual expressions to sql-formatter (see `internals.ts`).
*
* Everything here degrades towards "expand": whenever a group cannot be measured
* or does not fit, it is laid out across multiple lines rather than truncated.
*/
/**
* Emits one clause: its keyword, then its items — the first kept on the keyword's
* line and the rest aligned under it — unless the whole list is short enough to
* stay on one line.
*/
function printClause(writer: Writer, clause: ClauseNode, baseColumn: number, ctx: SqlLayoutContext): void {
const keyword = keywordText(clause.nameKw.text, ctx);
const items = splitByComma(clause.children);
const itemColumn = baseColumn + keyword.length + 1;
// `FROM` is the one clause DBX lets users push its first source off of, so it
// skips both the collapse and the "element on the keyword's line" rule.
const sourceOnOwnLine = clause.nameKw.text.toUpperCase() === "FROM" && !ctx.options.fromClauseSourceOnSameLine;
const childCtx = clauseChildContext(ctx, clause);
if (!sourceOnOwnLine && items.length <= ctx.options.keepElementsOnOneLine) {
const flat = renderInline(childCtx, clause.children, ctx.options.lineWidth - writer.column - keyword.length - 1);
if (flat) {
writer.write(`${keyword} ${flat}`);
return;
}
}
items.forEach((item, index) => {
if (index === 0) {
writer.write(keyword);
if (sourceOnOwnLine) writer.newline(baseColumn + ctx.options.indentWidth);
else writer.space();
} else {
writer.newline(itemColumn);
}
printElement(writer, item, baseColumn, childCtx);
if (index < items.length - 1) writer.write(",");
});
}
/** Emits a `LIMIT` clause, which sql-formatter renders as keyword + expressions. */
function printLimitClause(writer: Writer, clause: LimitClauseNode, ctx: SqlLayoutContext): void {
const keyword = keywordText(clause.limitKw.text, ctx);
const children = limitChildren(clause);
const flat = renderInline(ctx, children, ctx.options.lineWidth - writer.column - keyword.length - 1);
if (flat) {
writer.write(`${keyword} ${flat}`);
return;
}
writer.write(keyword);
writer.newline(writer.column + ctx.options.indentWidth);
emitText(writer, ctx.renderers.block(children).trim(), writer.column);
}
/** Index just past the nodes that share a line with the group at `from`. */
function joinElementEnd(nodes: AstNode[], from: number): number {
let end = from;
while (end < nodes.length && !isJoinKeyword(nodes[end])) end += 1;
return end;
}
/**
* Renders the contents of the parenthesis at `index` on one line, or `null` when
* the group has to expand across lines.
*
* The measurement covers the rest of the join element, not just the group: a
* subquery stays inline only while `(SELECT ...) alias ON ...` fits the line as
* a whole. The group expands when it does not, rather than keeping a
* short subquery inline and pushing a long `ON` onto a line of its own.
*/
function measureParen(writer: Writer, nodes: AstNode[], index: number, ctx: SqlLayoutContext): string | null {
const node = nodes[index] as ParenthesisNode;
const width = ctx.options.lineWidth;
const afterOpen = writer.column + node.openParen.length;
const collapseCtx = collapsedContext(ctx);
const inner = renderInline(collapseCtx, node.children, width - afterOpen - node.closeParen.length);
if (!inner) return null;
const end = joinElementEnd(nodes, index + 1);
if (end === index + 1) return inner;
const remaining = width - afterOpen - inner.length - node.closeParen.length;
return renderInline(collapseCtx, nodes.slice(index + 1, end), remaining) ? inner : null;
}
/**
* Emits a condition that does not fit on one line, breaking it at its top-level
* logical operators the way `logicalOperatorNewline` asks: `before` starts each
* continuation line with the operator, `after` ends the previous line with it.
*
* The operands align under the first one, the same rule a clause's other items
* follow — the style keeps a clause's elements in one column rather than
* indenting them another step.
*/
function printCondition(writer: Writer, operands: AstNode[][], baseColumn: number, ctx: SqlLayoutContext): void {
const column = writer.column;
for (const [head, ...rest] of operands) {
if (!isLogicalOperator(head)) {
printElement(writer, [head, ...rest], baseColumn, ctx);
continue;
}
const operator = keywordText(head.text, ctx);
if (ctx.options.logicalOperatorNewline === "before") {
writer.newline(column);
writer.write(operator);
writer.space();
} else {
writer.space();
writer.write(operator);
writer.newline(column);
}
printElement(writer, rest, baseColumn, ctx);
}
}
/**
* Emits one clause item, expanding only those parentheses that do not fit on the
* current line.
*/
function printElement(writer: Writer, nodes: AstNode[], baseColumn: number, ctx: SqlLayoutContext): void {
const whole = renderInline(ctx, nodes, ctx.options.lineWidth - writer.column);
if (whole) {
writer.write(whole);
return;
}
// A condition is split at its own operators before anything else is tried, so
// `AND`/`OR` end up on the lines the setting asks for rather than wherever the
// block fallback happens to place them. `none` asks for no break of its own,
// and is folded back together after formatting (see the caller's post-pass).
const operands = ctx.options.logicalOperatorNewline === "none" ? null : splitLogicalOperands(nodes);
if (operands) {
printCondition(writer, operands, baseColumn, ctx);
return;
}
let index = 0;
while (index < nodes.length) {
const node = nodes[index];
if (isParenthesis(node)) {
if (!isCallParen(nodes, index, ctx)) writer.space();
const inner = measureParen(writer, nodes, index, ctx);
if (inner) {
writer.write(`${node.openParen}${inner}${node.closeParen}`);
} else {
writer.write(node.openParen);
printBlock(writer, node.children, writer.column, ctx);
writer.write(node.closeParen);
}
index += 1;
continue;
}
// A JOIN keyword opens a new line one "Join indent" in from the FROM clause.
if (isJoinKeyword(node)) {
writer.newline(baseColumn + ctx.options.joinIndentWidth);
writer.write(keywordText(node.text, ctx));
index += 1;
continue;
}
// Gather the run of plain nodes up to the next construct needing care, so
// they are measured together instead of one node at a time.
let end = index;
while (end < nodes.length && !isParenthesis(nodes[end]) && !isJoinKeyword(nodes[end])) {
end += 1;
}
const run = nodes.slice(index, end);
const rendered = renderInline(ctx, run, ctx.options.lineWidth - writer.column);
writer.space();
if (rendered) {
writer.write(rendered);
} else {
// A construct too wide to inline (a long CASE, a nested function call):
// fall back to sql-formatter's own multi-line rendering of it.
emitText(writer, ctx.renderers.block(run).trim(), writer.column);
}
index = end;
}
}
/**
* Emits a statement body: a subquery's clauses or a statement's own clauses.
*
* Called only once the caller has established that the body does not fit on one
* line, so there is no second collapse attempt here — re-flattening would undo
* the very expansion the caller asked for.
*/
function printBlock(writer: Writer, nodes: AstNode[], baseColumn: number, ctx: SqlLayoutContext): void {
if (!nodes.some(isBodyNode)) {
// A plain expression group (not a subquery) that did not fit: let
// sql-formatter break it, then re-base the block on this column.
emitText(writer, ctx.renderers.block(nodes).trim(), baseColumn);
return;
}
let first = true;
for (const node of nodes) {
if (!first) writer.newline(baseColumn);
writeBodyNode(writer, node, baseColumn, ctx);
first = false;
}
}
/** Emits one clause / set operation / limit clause of a statement body. */
function writeBodyNode(writer: Writer, node: AstNode, baseColumn: number, ctx: SqlLayoutContext): void {
if (node.type === "clause") printClause(writer, node, baseColumn, ctx);
else if (node.type === "set_operation") writer.write(keywordText(node.nameKw.text, ctx));
else if (node.type === "limit_clause") printLimitClause(writer, node, ctx);
else emitText(writer, ctx.renderers.block([node]).trim(), writer.column);
}
/** Emits a whole statement, one clause per line unless the statement collapses. */
function printStatement(statement: StatementNode, ctx: SqlLayoutContext): string | null {
const collapsed = renderInline(collapsedContext(ctx), statement.children, ctx.options.lineWidth);
if (collapsed) return statement.hasSemicolon ? `${collapsed};` : collapsed;
if (isCreateTable(statement)) return createTableLayout(statement, ctx);
// A statement without clauses is a bare expression — a fragment the user
// selected in the editor — and has no clause layout to apply. Declining hands
// the whole input to sql-formatter, which formats such a fragment operand by
// operand; emitting it clause by clause would put every token on its own line.
if (!statement.children.some(isBodyNode)) return null;
const writer = new Writer(ctx.options);
let first = true;
for (const node of statement.children) {
if (!first) writer.newline(0);
writeBodyNode(writer, node, 0, ctx);
first = false;
}
if (statement.hasSemicolon) writer.write(";");
return writer.toString();
}
/**
* Formats already-parsed statements using the default style's layout rules.
*
* Returns `null` when any statement lies outside the model these rules describe,
* so the caller can format the input as a whole with sql-formatter instead of
* mixing two styles inside one script.
*/
export function printSqlLayout(statements: StatementNode[], ctx: SqlLayoutContext): string | null {
// Same convention as sql-formatter: the setting counts blank lines, so the
// separator is one newline longer.
const separator = "\n".repeat(ctx.options.linesBetweenQueries + 1);
const rendered: string[] = [];
for (const statement of statements) {
const text = printStatement(statement, ctx);
if (text === null) return null;
rendered.push(text);
}
return rendered.join(separator);
}
+39 -4
View File
@@ -1,4 +1,5 @@
import { DEFAULT_SQL_FORMATTER_SETTINGS, normalizeSqlFormatterSettings, sqlFormatterOptions, type SqlFormatterSettings } from "@/lib/sql/sqlFormatterConfig";
import { formatSqlLayout, type SqlLayoutOptions } from "@/lib/sql/layout";
import { looksLikeXml } from "@/lib/sql/autoFormat";
export type SqlFormatDialect = "mysql" | "postgres" | "sqlite" | "sqlserver" | "oracle" | "clickhouse" | "dameng" | "duckdb" | "generic";
@@ -361,10 +362,11 @@ export async function formatSqlText(sql: string, dialect: SqlFormatDialect = "ge
},
}
: options;
const resolvedDialect = dialect === "clickhouse" ? resolveClickHouseIdentifierSafeDialect(sqlFormatter) : undefined;
const formatWithFallback = (input: string): string => {
try {
if (dialect === "clickhouse") {
return formatDialect(input, { ...formatterOptions, dialect: resolveClickHouseIdentifierSafeDialect(sqlFormatter) });
if (resolvedDialect) {
return formatDialect(input, { ...formatterOptions, dialect: resolvedDialect });
}
return format(input, { language, ...formatterOptions });
} catch (err) {
@@ -389,14 +391,35 @@ 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.
const layoutOptions: Partial<SqlLayoutOptions> = {
lineWidth: normalizedSettings.expressionWidth,
indentWidth: normalizedSettings.tabWidth,
useTabs: normalizedSettings.useTabs,
linesBetweenQueries: normalizedSettings.linesBetweenQueries,
fromClauseSourceOnSameLine: normalizedSettings.fromClauseLayout === "sameLine",
keywordCase: normalizedSettings.keywordCase,
logicalOperatorNewline: normalizedSettings.logicalOperatorNewline,
};
const usesDefaultStyle = 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;
return laidOut ?? formatWithFallback(input);
};
try {
return finalizeFormattedSql(formatWithFallback(protectedInput.sql));
return finalizeFormattedSql(await formatOnce(protectedInput.sql));
} catch (err) {
const trailingDot = dialect === "dameng" ? splitTrailingStandaloneDot(protectedInput.sql) : null;
if (!trailingDot) throw err;
try {
return finalizeFormattedSql(`${formatWithFallback(trailingDot.body)}${trailingDot.suffix}`);
return finalizeFormattedSql(`${await formatOnce(trailingDot.body)}${trailingDot.suffix}`);
} catch {
throw err;
}
@@ -640,6 +663,18 @@ function keepFromClauseAndFirstSourceOnSameLine(sql: string): string {
return lines.join("\n");
}
/**
* The text-level passes applied to whatever the formatter produced, whether the
* default style's layout printer or sql-formatter itself.
*
* The two settings-driven passes fix shapes the printer already gets right on
* its own — it keeps a `FROM` source on the keyword's line and joins operators
* for `none` — so they are no-ops on its output. They stay because a statement
* the printer declines falls back to sql-formatter's layout for the whole input,
* which does break those shapes; see `formatSqlText`. {@link
* normalizeLikeOperatorCase} is unrelated to either: it corrects a dialect quirk
* in both paths.
*/
function applySqlFormatterLayout(sql: string, settings: SqlFormatterSettings, dialect: SqlFormatDialect): string {
let formatted = normalizeLikeOperatorCase(sql, settings, dialect);
if (settings.logicalOperatorNewline === "none") formatted = keepLogicalOperatorsOnSameLine(formatted, dialect);
@@ -74,8 +74,8 @@ export const DEFAULT_SQL_FORMATTER_SETTINGS: SqlFormatterSettings = {
useTabs: false,
tabWidth: 2,
logicalOperatorNewline: "before",
fromClauseLayout: "newLine",
expressionWidth: 50,
fromClauseLayout: "sameLine",
expressionWidth: 120,
linesBetweenQueries: 1,
preserveEmptyLines: false,
denseOperators: false,
+2 -1
View File
@@ -19,7 +19,8 @@
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"paths": {
"@/*": ["./src/*"]
"@/*": ["./src/*"],
"sql-formatter/dist/*": ["../../node_modules/sql-formatter/dist/*"]
}
},
"include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue"],
+4
View File
@@ -71,6 +71,10 @@ export default defineConfig(async () => ({
"@": path.resolve(import.meta.dirname, "./src"),
// Prefer package source during app dev so shell parse changes need no rebuild.
"@dbx-app/mongo-shell": path.resolve(import.meta.dirname, "../../packages/mongo-shell/src/index.ts"),
// sql-formatter's `exports` map only declares ".", so deep imports into its
// bundled parser/tokenizer are rejected by Vite's exports handling. The
// The default layout engine needs that AST; see src/lib/sql/layout/internals.ts.
"sql-formatter/dist/": `${path.resolve(import.meta.dirname, "../../node_modules/sql-formatter/dist")}/`,
},
},
clearScreen: false,
@@ -40,7 +40,7 @@
"apps/desktop/src/lib/common/utils.ts": "76c4b2af1e03edeb8db389d9b2c1b56b72a57a1b5198d4e224671c1445c527da",
"apps/desktop/src/lib/diagram/erDiagram.ts": "e28aa2b5fbba001ae78bc0c4218b11f93b0e14ab9f54562f14254386b98bad11",
"apps/desktop/src/styles/tokens.css": "b8852ce50865ad98ebc992971f7a06670c5d830ca82efcee956f8d3eb7c2e9a3",
"apps/desktop/tsconfig.json": "5ad521941835ad8529e9b81ba79c11650e5f020f0e376b7a9f17e22a9306fa76",
"apps/desktop/tsconfig.json": "ee02ba8f2f257b86235cd3053e8d6fbfefd7c39e0807b4ea5e36e133a78e836c",
"apps/desktop/vite.docs-export.config.ts": "833bc2cbd99f9c0740cbc1584640458492bad75c9518db4d1ce0edd6958881ec"
},
"deps": {
+10 -2
View File
@@ -26,6 +26,10 @@ DBX 当前对以下方言使用专用格式化语法:
MongoDB 查询编辑器使用单独的 Mongo Shell 格式化器,不使用本页的 SQL 方言映射。
## 默认风格
默认排版规则:能放下的语句保持一行,放不下的按子句逐行展开,子句的后续元素与第一个元素对齐;条件的逻辑操作符放在续行行首;`CREATE TABLE` 的列定义按列对齐。下面的设置用于调整这些规则,**恢复默认值**会回到该风格。
## 配置格式化选项
前往 **设置 → SQL 格式化**。表单模式提供以下选项:
@@ -36,9 +40,11 @@ MongoDB 查询编辑器使用单独的 Mongo Shell 格式化器,不使用本
| 缩进样式 | `standard`、`tabularLeft`、`tabularRight` |
| 缩进字符 | 空格或 Tab |
| Tab 宽度 | `2` 或 `4` |
| 逻辑操作符换行 | 操作符之前或之后 |
| 逻辑操作符换行 | `before`、`after`,或 `none` 保持在同一行 |
| FROM 子句排版 | `sameLine` 让首个表留在 `FROM` 行,`newLine` 让它换到下一行 |
| 表达式宽度 | `50`、`80` 或 `120` |
| 查询间空行 | `0`、`1` 或 `2` |
| 保留空行 | 保留语句之间的空行 |
| 紧凑操作符 | 减少操作符周围的空格 |
| 分号前换行 | 把语句结尾分号放到新行 |
| 参数类型 | 识别位置参数、编号参数、命名参数、引号参数或自定义正则参数 |
@@ -62,8 +68,10 @@ JSON 模式适合精确编辑、复制或迁移格式化器配置。当前配置
"useTabs": false,
"tabWidth": 2,
"logicalOperatorNewline": "before",
"expressionWidth": 50,
"fromClauseLayout": "sameLine",
"expressionWidth": 120,
"linesBetweenQueries": 1,
"preserveEmptyLines": false,
"denseOperators": false,
"newlineBeforeSemicolon": false,
"paramTypes": null
+10 -2
View File
@@ -26,6 +26,10 @@ DBX currently uses dedicated formatter grammars for:
The MongoDB query editor uses a separate Mongo Shell formatter rather than this SQL dialect mapping.
## 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.
## Configure Formatting Options
Open **Settings → SQL Formatter**. Form mode exposes:
@@ -36,9 +40,11 @@ Open **Settings → SQL Formatter**. Form mode exposes:
| Indent style | `standard`, `tabularLeft`, or `tabularRight` |
| Indentation | Spaces or tabs |
| Tab width | `2` or `4` |
| Logical operator newline | Before or after the operator |
| Logical operator newline | `before`, `after`, or `none` to keep them on the same line |
| FROM clause layout | `sameLine` keeps the first table on the `FROM` line, `newLine` moves it below |
| Expression width | `50`, `80`, or `120` |
| Lines between queries | `0`, `1`, or `2` |
| Preserve empty lines | Keeps blank lines between statements |
| Dense operators | Reduces spaces around operators |
| Newline before semicolon | Places a statement-ending semicolon on a new line |
| Parameter types | Recognizes positional, numbered, named, quoted, or custom-regex parameters |
@@ -62,8 +68,10 @@ JSON mode is useful for exact editing, copying, or moving formatter settings. Th
"useTabs": false,
"tabWidth": 2,
"logicalOperatorNewline": "before",
"expressionWidth": 50,
"fromClauseLayout": "sameLine",
"expressionWidth": 120,
"linesBetweenQueries": 1,
"preserveEmptyLines": false,
"denseOperators": false,
"newlineBeforeSemicolon": false,
"paramTypes": null
+1 -1
View File
@@ -109,7 +109,7 @@
"shiki": "^4.2.0",
"smol-toml": "^1.8.0",
"splitpanes": "^4.1.2",
"sql-formatter": "^15.8.0",
"sql-formatter": "15.8.0",
"tailwind-merge": "^3.6.0",
"tw-animate-css": "^1.4.0",
"vue": "^3.5.35",
+29 -13
View File
@@ -18,7 +18,10 @@ test("rejects very large SQL before importing formatter", async () => {
});
test("formats SQL with uppercase keywords and readable line breaks by default", async () => {
const formatted = await formatSqlText("select id, name from users where active = 1 order by name", "postgres");
const formatted = await formatSqlText(
"select id, name, email, created_at, updated_at, deleted_at, tenant_id from users where active = 1 and verified = 1 order by name, created_at desc",
"postgres",
);
assert.match(formatted, /^SELECT\b/);
assert.match(formatted, /\nFROM\b/);
@@ -26,22 +29,35 @@ test("formats SQL with uppercase keywords and readable line breaks by default",
assert.match(formatted, /\nORDER BY\b/);
});
test("collapses a statement that fits on one line", async () => {
const formatted = await formatSqlText("select id, name from users where active = 1 order by name", "postgres");
assert.equal(formatted, "SELECT id, name FROM users WHERE active = 1 ORDER BY name");
});
test("formats SQL with custom keyword case and indentation settings", async () => {
const formatted = await formatSqlText("select id from users where active = 1", "postgres", {
keywordCase: "lower",
dataTypeCase: "preserve",
functionCase: "preserve",
useTabs: true,
tabWidth: 2,
logicalOperatorNewline: "before",
expressionWidth: 50,
linesBetweenQueries: 1,
denseOperators: false,
newlineBeforeSemicolon: false,
});
const formatted = await formatSqlText(
"select id, name, email, created_at, updated_at, deleted_at, tenant_id from users where active = 1 and verified = 1 order by name, created_at desc",
"postgres",
{
keywordCase: "lower",
dataTypeCase: "preserve",
functionCase: "preserve",
useTabs: true,
tabWidth: 2,
logicalOperatorNewline: "before",
expressionWidth: 50,
linesBetweenQueries: 1,
denseOperators: false,
newlineBeforeSemicolon: false,
},
);
assert.match(formatted, /^select\b/);
assert.match(formatted, /\nfrom\b/);
// Continuations align under the first element, and that padding is indentation
// at a column past the first tab stop, so it starts with tabs.
assert.match(formatted, /\n\t+ name,/);
assert.doesNotMatch(formatted, /^SELECT\b/);
});
+2 -2
View File
@@ -245,7 +245,7 @@ test("maps DBX formatter settings to sql-formatter options", () => {
useTabs: true,
tabWidth: 2,
logicalOperatorNewline: "before",
expressionWidth: 50,
expressionWidth: 120,
linesBetweenQueries: 1,
denseOperators: false,
newlineBeforeSemicolon: true,
@@ -271,7 +271,7 @@ test("maps DBX formatter settings to sql-formatter options", () => {
useTabs: false,
tabWidth: 2,
logicalOperatorNewline: "before",
expressionWidth: 50,
expressionWidth: 120,
linesBetweenQueries: 1,
denseOperators: false,
newlineBeforeSemicolon: false,
+1 -109
View File
@@ -189,7 +189,7 @@ importers:
specifier: ^4.1.2
version: 4.1.2(vue@3.5.35(typescript@6.0.3))
sql-formatter:
specifier: ^15.8.0
specifier: 15.8.0
version: 15.8.0
tailwind-merge:
specifier: ^3.6.0
@@ -604,78 +604,6 @@ packages:
'@codemirror/view@6.43.0':
resolution: {integrity: sha512-V7ZCLQO3Jus9hzh2jVCCPW3mO4IBMr43O37PqSUYautJSnnJF41YlgLw21x0fLJTYvJ+Vkm6Gp+qKGH9pltgXA==}
'@dbx-app/cli-darwin-arm64@0.4.58':
resolution: {integrity: sha512-wFOyHD/ZYLbZsCPHnZgiyf8l1YRtIAl6KwBckJJV4Mo1Gk4e7ihrznd3whwcDZP1TXe+bBa16VCaEKitTFZIkw==}
cpu: [arm64]
os: [darwin]
hasBin: true
'@dbx-app/cli-darwin-x64@0.4.58':
resolution: {integrity: sha512-+5ZffZtbsm9HimNkL8NcD30rtYxhyRsSuR+mvE6/0ztDM8sZq2XzgsVIx7ve32+o2CmiSBR3pWRAG7vyDvhkqw==}
cpu: [x64]
os: [darwin]
hasBin: true
'@dbx-app/cli-linux-arm64-gnu@0.4.58':
resolution: {integrity: sha512-3t7PghU8VAttSIjdBtH8VRTNZq2InKlc1i7LzZD48lv1N4lUQjL1jtgw6M7QWesjT7zlWUjmjlR1pBZej+7HCg==}
cpu: [arm64]
os: [linux]
hasBin: true
'@dbx-app/cli-linux-x64-gnu@0.4.58':
resolution: {integrity: sha512-9KKweZhj4hxjaHJBloc5OQw4/o+ouJTPucQGKz+82fIj2kHmIPPGGUAqGPYv2D3xj0k+sV2qCwd7cz6nga5GfQ==}
cpu: [x64]
os: [linux]
hasBin: true
'@dbx-app/cli-win32-arm64@0.4.58':
resolution: {integrity: sha512-bcqrVynvUqg+sCKc2pyN8s6tdZDZEjXGYO6y9V8SdY42K1iGpzCIcyvO+PNtHRgi5ICt6j9sw6AoGgPT8OoThA==}
cpu: [arm64]
os: [win32]
hasBin: true
'@dbx-app/cli-win32-x64@0.4.58':
resolution: {integrity: sha512-o3qlOay55BR4eiSku2oGVNc6+Q77fWV6I3Ch8pULvXIS836mmnZ1OSJYCvHOc5mqyHNQ91ddtrQixmCYpGQRiQ==}
cpu: [x64]
os: [win32]
hasBin: true
'@dbx-app/mcp-darwin-arm64@0.4.58':
resolution: {integrity: sha512-msvgntUBKclbi1VwDTq4WVnMuk58BUB9JfevIKSqcC20w4dl0BiOo+J3utu15aU6ZJBlvp/M4I4hc5OmrgDIPA==}
cpu: [arm64]
os: [darwin]
hasBin: true
'@dbx-app/mcp-darwin-x64@0.4.58':
resolution: {integrity: sha512-U1hWBUxejENDteENrrbs2QYcdr6UEeU42IJiiXHnERIR4nhdcXOiAG3eOMLoWN07+vUrMNzo3K4eZjX0xIeiwA==}
cpu: [x64]
os: [darwin]
hasBin: true
'@dbx-app/mcp-linux-arm64-gnu@0.4.58':
resolution: {integrity: sha512-IRPH7GzIR86Vy+LtvIEL/V/XjsSaybyOec7IbR7LXBhAxd8WyC8TXHOKtLwLKrkY8dTFI1/orJXD0ils9euxcg==}
cpu: [arm64]
os: [linux]
hasBin: true
'@dbx-app/mcp-linux-x64-gnu@0.4.58':
resolution: {integrity: sha512-G42Wkh1i7XZxwAJQD0+FPDx42GHOpLr4m8FC9SGoR4g8aa+JFUw0UVYrWzQDjKcKhGsgM0TRhTRgW45rp8tymA==}
cpu: [x64]
os: [linux]
hasBin: true
'@dbx-app/mcp-win32-arm64@0.4.58':
resolution: {integrity: sha512-5Aj8DwPuj9N7ui/tplFeoYWwpczw23g8kNF46K+OsLVCJVcuNeolmbH242UUpooIKDGrhz5uvS673MEeMBlUZg==}
cpu: [arm64]
os: [win32]
hasBin: true
'@dbx-app/mcp-win32-x64@0.4.58':
resolution: {integrity: sha512-bZonwNVxcePJYgpZo/X2uO+RqSIPVchIWKJ7HjEnNmd1AcLylAoHyIvh52LGXC2GvfGBVRrRC9OsHVOG/o/qSg==}
cpu: [x64]
os: [win32]
hasBin: true
'@dotenvx/dotenvx@1.75.1':
resolution: {integrity: sha512-/BITOC9dmS/edY2zQwZNicQ059O6RKabtQfyEafV0nGtfYRNHYy1DIPiYVcov40+tob9hfmBnbR963dS+EQ1DQ==}
hasBin: true
@@ -4459,42 +4387,6 @@ snapshots:
style-mod: 4.1.3
w3c-keyname: 2.2.8
'@dbx-app/cli-darwin-arm64@0.4.58':
optional: true
'@dbx-app/cli-darwin-x64@0.4.58':
optional: true
'@dbx-app/cli-linux-arm64-gnu@0.4.58':
optional: true
'@dbx-app/cli-linux-x64-gnu@0.4.58':
optional: true
'@dbx-app/cli-win32-arm64@0.4.58':
optional: true
'@dbx-app/cli-win32-x64@0.4.58':
optional: true
'@dbx-app/mcp-darwin-arm64@0.4.58':
optional: true
'@dbx-app/mcp-darwin-x64@0.4.58':
optional: true
'@dbx-app/mcp-linux-arm64-gnu@0.4.58':
optional: true
'@dbx-app/mcp-linux-x64-gnu@0.4.58':
optional: true
'@dbx-app/mcp-win32-arm64@0.4.58':
optional: true
'@dbx-app/mcp-win32-x64@0.4.58':
optional: true
'@dotenvx/dotenvx@1.75.1':
dependencies:
'@dotenvx/primitives': 0.8.0
+4
View File
@@ -8,6 +8,10 @@ export default defineConfig({
alias: {
"@": path.resolve(import.meta.dirname, "apps/desktop/src"),
"@dbx-app/mongo-shell": path.resolve(import.meta.dirname, "packages/mongo-shell/src/index.ts"),
// sql-formatter's `exports` map only declares ".", so deep imports into its
// bundled parser/tokenizer are rejected by Vite's exports handling. The
// The default layout engine needs that AST; see lib/sql/layout/internals.ts.
"sql-formatter/dist/": `${path.resolve(import.meta.dirname, "node_modules/sql-formatter/dist")}/`,
},
},
test: {