mirror of
https://github.com/t8y2/dbx.git
synced 2026-10-21 06:00:39 +00:00
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:
Vendored
+5
-1
@@ -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
@@ -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 () => {
|
||||
|
||||
@@ -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;");
|
||||
});
|
||||
});
|
||||
@@ -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());
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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"],
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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,
|
||||
|
||||
Generated
+1
-109
@@ -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
|
||||
|
||||
@@ -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: {
|
||||
|
||||
Reference in New Issue
Block a user