diff --git a/apps/desktop/src/components/editor/__tests__/QueryEditor.formatCompressRequestReplay.spec.ts b/apps/desktop/src/components/editor/__tests__/QueryEditor.formatCompressRequestReplay.spec.ts index cc90b98191..6d8e6d8122 100644 --- a/apps/desktop/src/components/editor/__tests__/QueryEditor.formatCompressRequestReplay.spec.ts +++ b/apps/desktop/src/components/editor/__tests__/QueryEditor.formatCompressRequestReplay.spec.ts @@ -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); diff --git a/apps/desktop/src/lib/__tests__/sql/sqlFormatter.spec.ts b/apps/desktop/src/lib/__tests__/sql/sqlFormatter.spec.ts index c367d54f7c..b04edee1d6 100644 --- a/apps/desktop/src/lib/__tests__/sql/sqlFormatter.spec.ts +++ b/apps/desktop/src/lib/__tests__/sql/sqlFormatter.spec.ts @@ -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 AS `. - 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 () => { diff --git a/apps/desktop/src/lib/__tests__/sql/sqlLayout.spec.ts b/apps/desktop/src/lib/__tests__/sql/sqlLayout.spec.ts new file mode 100644 index 0000000000..1a24fd74c2 --- /dev/null +++ b/apps/desktop/src/lib/__tests__/sql/sqlLayout.spec.ts @@ -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[1]; + +async function format(sql: string, overrides: Partial = {}, 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;"); + }); +}); diff --git a/apps/desktop/src/lib/sql/layout/ddl.ts b/apps/desktop/src/lib/sql/layout/ddl.ts new file mode 100644 index 0000000000..3d6848f93d --- /dev/null +++ b/apps/desktop/src/lib/sql/layout/ddl.ts @@ -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()); +} diff --git a/apps/desktop/src/lib/sql/layout/index.ts b/apps/desktop/src/lib/sql/layout/index.ts new file mode 100644 index 0000000000..41104b9b43 --- /dev/null +++ b/apps/desktop/src/lib/sql/layout/index.ts @@ -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; + options?: Partial; +} + +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 { + 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 = { ...request.cfg, indentStyle: "standard", useTabs: false }; + + try { + const statements = engine.parse(request.sql, dialect, (cfg.paramTypes as Record) ?? {}); + 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; + } +} diff --git a/apps/desktop/src/lib/sql/layout/internals.ts b/apps/desktop/src/lib/sql/layout/internals.ts new file mode 100644 index 0000000000..f872a1cb22 --- /dev/null +++ b/apps/desktop/src/lib/sql/layout/internals.ts @@ -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[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; + readonly createDialect: (options: DialectOptions) => Dialect; + readonly parse: (sql: string, dialect: Dialect, paramTypes: Record) => StatementNode[]; + readonly ExpressionFormatter: ExpressionFormatterCtor; + readonly InlineLayout: InlineLayoutCtor; + readonly InlineLayoutError: InlineLayoutErrorCtor; + readonly Params: ParamsCtor; + readonly Layout: LayoutCtor; + readonly Indentation: IndentationCtor; +} + +let enginePromise: Promise | null = null; + +async function importEngine(): Promise { + 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, + 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 { + 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, params: InstanceType, 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, params: InstanceType, 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), + }; +} diff --git a/apps/desktop/src/lib/sql/layout/primitives.ts b/apps/desktop/src/lib/sql/layout/primitives.ts new file mode 100644 index 0000000000..eda899feca --- /dev/null +++ b/apps/desktop/src/lib/sql/layout/primitives.ts @@ -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; + /** + * 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) {} + + /** 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 ` 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()); + } +} diff --git a/apps/desktop/src/lib/sql/layout/printer.ts b/apps/desktop/src/lib/sql/layout/printer.ts new file mode 100644 index 0000000000..4bcbea6970 --- /dev/null +++ b/apps/desktop/src/lib/sql/layout/printer.ts @@ -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); +} diff --git a/apps/desktop/src/lib/sql/sqlFormatter.ts b/apps/desktop/src/lib/sql/sqlFormatter.ts index 8612a48baf..837fdd0604 100644 --- a/apps/desktop/src/lib/sql/sqlFormatter.ts +++ b/apps/desktop/src/lib/sql/sqlFormatter.ts @@ -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 = { + 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 => { + 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); diff --git a/apps/desktop/src/lib/sql/sqlFormatterConfig.ts b/apps/desktop/src/lib/sql/sqlFormatterConfig.ts index 6bcea73478..4a28a2fe4d 100644 --- a/apps/desktop/src/lib/sql/sqlFormatterConfig.ts +++ b/apps/desktop/src/lib/sql/sqlFormatterConfig.ts @@ -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, diff --git a/apps/desktop/tsconfig.json b/apps/desktop/tsconfig.json index 17cb3661ba..783e8a06c2 100644 --- a/apps/desktop/tsconfig.json +++ b/apps/desktop/tsconfig.json @@ -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"], diff --git a/apps/desktop/vite.config.ts b/apps/desktop/vite.config.ts index fea4337f6e..f972a63533 100644 --- a/apps/desktop/vite.config.ts +++ b/apps/desktop/vite.config.ts @@ -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, diff --git a/crates/dbx-core/assets/docs-export.manifest.json b/crates/dbx-core/assets/docs-export.manifest.json index c9f41fbebe..60cfafbae1 100644 --- a/crates/dbx-core/assets/docs-export.manifest.json +++ b/crates/dbx-core/assets/docs-export.manifest.json @@ -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": { diff --git a/docs/content/docs/sql-formatter.cn.mdx b/docs/content/docs/sql-formatter.cn.mdx index db3a1d1aa9..a75b62fda2 100644 --- a/docs/content/docs/sql-formatter.cn.mdx +++ b/docs/content/docs/sql-formatter.cn.mdx @@ -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 diff --git a/docs/content/docs/sql-formatter.mdx b/docs/content/docs/sql-formatter.mdx index 7254515ae1..9088617061 100644 --- a/docs/content/docs/sql-formatter.mdx +++ b/docs/content/docs/sql-formatter.mdx @@ -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 diff --git a/package.json b/package.json index fbed35f2be..1efb36529d 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/packages/app-tests/sqlFormatter.test.ts b/packages/app-tests/sqlFormatter.test.ts index de75faf3fb..f93beab288 100644 --- a/packages/app-tests/sqlFormatter.test.ts +++ b/packages/app-tests/sqlFormatter.test.ts @@ -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/); }); diff --git a/packages/app-tests/sqlFormatterConfig.test.ts b/packages/app-tests/sqlFormatterConfig.test.ts index f33f223755..2bf4d8fc39 100644 --- a/packages/app-tests/sqlFormatterConfig.test.ts +++ b/packages/app-tests/sqlFormatterConfig.test.ts @@ -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, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 98ea13d844..39b0dd5a28 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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 diff --git a/vitest.config.ts b/vitest.config.ts index 91bbb1089f..eb8f9738b7 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -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: {