From acdfe8a244d9edb4d71102d3a39074306d114d8a Mon Sep 17 00:00:00 2001 From: YuMingZhuo <1969214619@qq.com> Date: Fri, 16 Oct 2026 17:20:05 +0800 Subject: [PATCH] add the 'createTableColumnAlignment' configuration --- .gitignore | 1 + README.md | 2 + docs/createTableColumnAlignment.md | 131 ++++++++ schema.json | 10 + src/FormatOptions.ts | 40 +++ src/formatter/ExpressionFormatter.ts | 430 ++++++++++++++++++++++++++- src/sqlFormatter.ts | 2 + src/validateConfig.ts | 9 + test/db2.test.ts | 2 +- test/features/createTable.ts | 228 ++++++++++++++ test/mariadb.test.ts | 1 + test/mysql.test.ts | 7 +- test/plsql.test.ts | 2 +- test/postgresql.test.ts | 2 +- test/singlestoredb.test.ts | 7 +- test/snowflake.test.ts | 1 + test/sql.test.ts | 2 +- test/sqlite.test.ts | 2 +- test/tidb.test.ts | 7 +- test/transactsql.test.ts | 2 +- 20 files changed, 873 insertions(+), 15 deletions(-) create mode 100644 docs/createTableColumnAlignment.md diff --git a/.gitignore b/.gitignore index cc91c4010a..170361ff83 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,4 @@ node_modules coverage .eslintcache src/parser/grammar.ts +**/*.Identifier \ No newline at end of file diff --git a/README.md b/README.md index 41ee505c99..aff67cafda 100644 --- a/README.md +++ b/README.md @@ -181,6 +181,8 @@ All fields are optional and all fields that are not specified will be filled wit - [**`linesBetweenQueries`**](docs/linesBetweenQueries.md) how many newlines to insert between queries. - [**`denseOperators`**](docs/denseOperators.md) packs operators densely without spaces. - [**`newlineBeforeSemicolon`**](docs/newlineBeforeSemicolon.md) places semicolon on separate line. +- [**`createTableColumnAlignment`**](docs/createTableColumnAlignment.md) aligns CREATE TABLE column definitions into three left-aligned columns (column name, column definition, other). (**experimental!**) +- [**`createTableColumnAlignment_breakwidth`**](docs/createTableColumnAlignment.md#createtablecolumnalignment_breakwidth) max width of the third column before wrapping to a new line. (**experimental!**) - [**`params`**](docs/params.md) collection of values for placeholder replacement. - [**`paramTypes`**](docs/paramTypes.md) specifies parameter placeholders types to support. diff --git a/docs/createTableColumnAlignment.md b/docs/createTableColumnAlignment.md new file mode 100644 index 0000000000..73efac89f6 --- /dev/null +++ b/docs/createTableColumnAlignment.md @@ -0,0 +1,131 @@ +# createTableColumnAlignment + +When `true`, formats `CREATE TABLE` column definitions in a three-column +aligned layout: + +- **Column 1** – the column name (`col_name`), left-aligned. +- **Column 2** – the column definition / data type (`column_definition`), + e.g. `INT`, `VARCHAR(100)`, `DECIMAL(10, 2)`, left-aligned. +- **Column 3** – everything else (`PRIMARY KEY`, `NOT NULL`, `DEFAULT`, + `COMMENT`, `UNIQUE`, ...), left-aligned. + +The width of each column is sized to the widest entry in that column across +all column definitions in the same `CREATE TABLE` statement, so the starts +of columns 2 and 3 line up vertically across rows. + +Table-level constraints (`CONSTRAINT name UNIQUE (...)`, +`PRIMARY KEY (...)`, `FOREIGN KEY (...)`, `CHECK (...)`, ...) do not have +the `col_name / column_definition / other` shape, so they are emitted on +their own line without padding. + +Defaults to `false` (preserves the legacy single-space layout). + +## Options + +- `true` – enable three-column alignment for `CREATE TABLE` column lists. +- `false` (default) – use the legacy single-space layout. + +## Example + +### false (default) + +```sql +CREATE TABLE users ( + id INT PRIMARY KEY NOT NULL, + name VARCHAR(100) NOT NULL, + age INT, + email VARCHAR(255) UNIQUE +); +``` + +### true + +```sql +CREATE TABLE users ( + id INT PRIMARY KEY NOT NULL, + name VARCHAR(100) NOT NULL, + age INT, + email VARCHAR(255) UNIQUE +); +``` + +## Scope and limitations + +- The alignment only applies to the column-list parenthesis that directly + follows a `CREATE TABLE` clause keyword (e.g. `CREATE TABLE`, + `CREATE OR REPLACE TABLE`, `CREATE TEMP TABLE`, `CREATE TABLE IF NOT EXISTS`, + `CREATE EXTERNAL TABLE`, ...). It does **not** apply to nested parentheses + such as the size list of `VARCHAR(100)` or the column list of a + `CHECK (...)` constraint. +- A short `CREATE TABLE` whose column list fits on a single line (under + [`expressionWidth`](./expressionWidth.md)) stays on a single line — the + alignment only kicks in when the column list is split across multiple + lines. +- If a particular column entry can't be formatted as a single line (for + example because it contains a multi-line comment, or because the + "other" part contains a sub-clause like `ON UPDATE` in dialects that + reserve `UPDATE` as a clause keyword), the whole `CREATE TABLE` + column list falls back to the legacy multi-line layout for that + statement. + +## createTableColumnAlignment_breakwidth + +Maximum character width of the third column (constraints, comments, +defaults, ...) in a `CREATE TABLE` column definition. When the third +column's content exceeds this width, it is wrapped onto a new continuation +line indented to align with the start of the third column. + +Only takes effect when [`createTableColumnAlignment`](#createtablecolumnalignment) +is `true`. Ignored otherwise. + +### Options + +- a positive number (e.g. `30`) — wrap the "other" part to a new line when + its length exceeds this many characters. +- `0` — always wrap the "other" part to a new line, even when it's short. +- `Infinity` (default) — never wrap; the "other" part is always kept on the + same line as the column name and data type, regardless of length. + +### Example + +Given the following input: + +```sql +CREATE TABLE tbl (a INT NOT NULL DEFAULT 1 COMMENT "a very long comment that exceeds the default expression width", b INT NOT NULL DEFAULT 1); +``` + +#### `createTableColumnAlignment_breakwidth: Infinity` (default) + +```sql +CREATE TABLE tbl ( + a INT NOT NULL DEFAULT 1 COMMENT "a very long comment that exceeds the default expression width", + b INT NOT NULL DEFAULT 1 +); +``` + +#### `createTableColumnAlignment_breakwidth: 30` + +The long "other" part of column `a` is wrapped to a continuation line +indented to align with the start of the third column: + +```sql +CREATE TABLE tbl ( + a INT + NOT NULL DEFAULT 1 COMMENT "a very long comment that exceeds the default expression width", + b INT NOT NULL DEFAULT 1 +); +``` + +#### `createTableColumnAlignment_breakwidth: 0` + +Every non-empty "other" part is wrapped to a continuation line: + +```sql +CREATE TABLE tbl ( + a INT + NOT NULL DEFAULT 1, + b INT + NOT NULL DEFAULT 1 +); +``` + diff --git a/schema.json b/schema.json index 17a3afa9ac..7c30121f01 100644 --- a/schema.json +++ b/schema.json @@ -18,6 +18,16 @@ "type": "boolean", "default": false }, + "createTableColumnAlignment": { + "description": "[Experimental] When true, aligns CREATE TABLE column definitions into three left-aligned columns: column name, column definition (data type), and the rest (constraints, comments, defaults, ...).", + "type": "boolean", + "default": false + }, + "createTableColumnAlignment_breakwidth": { + "description": "[Experimental] Maximum character width of the third column (constraints, comments, defaults, ...) in a CREATE TABLE column definition. When the third column exceeds this width, it is wrapped to a new continuation line aligned with the start of the third column. Only takes effect when createTableColumnAlignment is true. Use 0 to always wrap. Defaults to Infinity (never wrap).", + "type": "number", + "minimum": 0 + }, "expressionWidth": { "description": "Determines maximum length of parenthesized expressions.", "type": "number", diff --git a/src/FormatOptions.ts b/src/FormatOptions.ts index 4af41184b0..d63e716558 100644 --- a/src/FormatOptions.ts +++ b/src/FormatOptions.ts @@ -26,4 +26,44 @@ export interface FormatOptions { newlineBeforeSemicolon: boolean; params?: ParamItems | string[]; paramTypes?: ParamTypes; + /** + * When true, formats CREATE TABLE column definitions in a column-aligned + * layout: column name in the first column, column definition (data type) + * in the second column, and the remaining clauses (constraints, comments, + * defaults, ...) in the third column. All three columns are left-aligned + * and padded so the start of each column lines up across rows. + * + * Defaults to false (preserves the legacy single-space layout). + * + * Table-level constraints (e.g. PRIMARY KEY (...), FOREIGN KEY (...), + * CONSTRAINT ...) are not column definitions, so they are emitted on their + * own line without column padding. + */ + createTableColumnAlignment?: boolean; + /** + * Maximum character length of the "other" (third) column in a CREATE TABLE + * column-definition entry when `createTableColumnAlignment` is enabled. + * + * When the "other" part of a column (constraints, comments, defaults, + * ...) is shorter than or equal to this width, it is placed on the same + * line as the column name and data type: + * + * col_name data_type NOT NULL DEFAULT 1 COMMENT 'short' + * + * When it exceeds this width, it is wrapped to a new line, indented to + * align with the start of the "other" column (i.e. after the padded + * col_name + col_def + two separator spaces): + * + * col_name data_type + * NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE + * CURRENT_TIMESTAMP COMMENT 'a very long comment' + * + * Defaults to `Infinity`, meaning the "other" part is always kept on the + * same line regardless of length (matches the v1 behavior of the option). + * Set to `0` to always wrap the "other" part to a new line. + * + * Only takes effect when `createTableColumnAlignment: true`. Ignored + * otherwise. + */ + createTableColumnAlignment_breakwidth?: number; } diff --git a/src/formatter/ExpressionFormatter.ts b/src/formatter/ExpressionFormatter.ts index 2c1fdc634c..788a8465d7 100644 --- a/src/formatter/ExpressionFormatter.ts +++ b/src/formatter/ExpressionFormatter.ts @@ -37,6 +37,146 @@ import Layout, { WS } from './Layout.js'; import toTabularFormat, { isTabularToken } from './tabularStyle.js'; import InlineLayout, { InlineLayoutError } from './InlineLayout.js'; +/** + * Matches the canonical (upper-cased, whitespace-equalized) text of a + * CREATE TABLE clause keyword phrase. + * + * Matches: + * CREATE TABLE + * CREATE OR REPLACE TABLE + * CREATE TEMP TABLE / CREATE TEMPORARY TABLE + * CREATE EXTERNAL TABLE / CREATE FOREIGN TABLE / CREATE SNAPSHOT TABLE + * CREATE TABLE IF NOT EXISTS (and the "OR REPLACE / TEMP / ..." variants) + * + * Does NOT match: + * CREATE TABLESPACE (no column list) + * CREATE VIEW (uses formatCreateView, different layout) + * CREATE FUNCTION / CREATE INDEX / CREATE SCHEMA / CREATE DATABASE + */ +const CREATE_TABLE_CLAUSE_REGEX = /^CREATE\b.*\bTABLE(?:\s+IF\s+NOT\s+EXISTS)?$/; + +/** + * Keywords that introduce a table-level constraint inside a CREATE TABLE + * column list (e.g. `PRIMARY KEY (a)`, `CONSTRAINT uc UNIQUE (b)`, ...). + * + * Used by isCreateTableColumnDefinition() to avoid misclassifying such + * constraints as column definitions when the first token happens to be + * a keyword (some SQL dialects don't reserve common words like NAME, so + * we have to accept keyword tokens as candidate column names too). + */ +const TABLE_CONSTRAINT_START_KEYWORDS = new Set([ + 'CONSTRAINT', + 'PRIMARY', + 'FOREIGN', + 'UNIQUE', + 'CHECK', + 'INDEX', + 'KEY', + 'FULLTEXT', + 'SPATIAL', + 'LIKE', + 'EXCLUDE', // PostgreSQL EXCLUDE USING ... + 'CLUSTER', // some dialects +]); + +/** + * Keywords that introduce a column-level constraint or modifier inside a + * CREATE TABLE column list (e.g. `col_name DEFAULT 0`, + * `col_name REFERENCES other(col)`, `col_name PRIMARY KEY`). + * + * Used by isCreateTableColumnDefinition() to avoid misclassifying the + * constraint keyword as the column's data type. Note that some of these + * words (e.g. PRIMARY, FOREIGN, UNIQUE, CHECK, CONSTRAINT) also appear in + * TABLE_CONSTRAINT_START_KEYWORDS, but we list them here too because a + * column entry could in principle start with `col_name ` + * (some dialects allow omitting the type). + */ +const COLUMN_CONSTRAINT_KEYWORDS = new Set([ + 'DEFAULT', + 'NULL', + 'NOT', + 'PRIMARY', + 'FOREIGN', + 'UNIQUE', + 'CHECK', + 'REFERENCES', + 'AUTO_INCREMENT', + 'COMMENT', + 'COLLATE', + 'GENERATED', + 'AS', + 'KEY', +]); + +/** + * Identifies a "column definition" entry inside a CREATE TABLE column list. + * + * A column definition has the shape: + * + * [other...] + * + * where the first node is the column name and the second node is the + * column's data type. We accept any of the following as the column name: + * + * - identifier (the common case: id, name, email, ...) + * - keyword (some dialects reserve common words like + * NAME, DATE, NUMBER, ... and the parser + * emits them as keyword nodes; we still + * want to treat them as column names) + * + * ...and any of the following as the data type: + * + * - data_type (e.g. INT, DATE, VARCHAR) + * - parameterized_data_type (e.g. VARCHAR(100), DECIMAL(10,2)) + * - identifier (e.g. TEXT, JSON, UUID, custom types - + * many SQL dialects don't reserve these + * as data type keywords, so the parser + * sees them as plain identifiers) + * - keyword (some dialects reserve type names like + * TEXT, STRING, ... as keywords rather + * than as data types; we still want to + * treat them as data types here, as long + * as they're not column-level constraint + * keywords like DEFAULT, NULL, ...) + * + * Table-level constraints (CONSTRAINT, PRIMARY KEY, FOREIGN KEY, UNIQUE, + * CHECK, INDEX, ...) start with a known constraint keyword, so we exclude + * those even when the first node is a keyword token. + */ +function isCreateTableColumnDefinition(entry: AstNode[]): boolean { + if (entry.length < 2) { + return false; + } + const colNameNode = entry[0]; + // First node must be a candidate column name: identifier or keyword. + if (colNameNode.type !== NodeType.identifier && colNameNode.type !== NodeType.keyword) { + return false; + } + // ...but not a known table-constraint starter. We check the text on both + // identifier and keyword nodes because some dialects (e.g. PL/SQL) don't + // reserve words like CONSTRAINT, so the parser emits them as identifiers. + const colNameText = (colNameNode as { text: string }).text.toUpperCase(); + if (TABLE_CONSTRAINT_START_KEYWORDS.has(colNameText)) { + return false; + } + // The second node must look like a data type. + const colDefNode = entry[1]; + if ( + colDefNode.type === NodeType.data_type || + colDefNode.type === NodeType.parameterized_data_type || + colDefNode.type === NodeType.identifier + ) { + return true; + } + // A keyword is also acceptable as a data type, as long as it's not a + // known column-level constraint keyword (DEFAULT, NULL, REFERENCES, ...). + if (colDefNode.type === NodeType.keyword) { + const colDefText = (colDefNode as KeywordNode).text.toUpperCase(); + return !COLUMN_CONSTRAINT_KEYWORDS.has(colDefText); + } + return false; +} + interface ExpressionFormatterParams { cfg: FormatOptions; dialectCfg: ProcessedDialectFormatOptions; @@ -45,6 +185,12 @@ interface ExpressionFormatterParams { inline?: boolean; // openParen of the nearest enclosing bracket ("(", "[" or "{"), if any enclosingParenthesis?: string; + // Canonical (upper-cased) text of the enclosing SQL clause (e.g. "CREATE TABLE", + // "SELECT", "WHERE"). Used by formatParenthesis() to detect when a parenthesis + // is the column-list of a CREATE TABLE statement, so column-alignment can be + // applied. Cleared (undefined) for nested expressions so the alignment only + // triggers on the direct column-list paren of the CREATE TABLE clause. + enclosingClauseName?: string; } export interface DialectFormatOptions { @@ -79,6 +225,7 @@ export default class ExpressionFormatter { private nodes: AstNode[] = []; private index = -1; private enclosingParenthesis?: string; + private enclosingClauseName?: string; constructor({ cfg, @@ -87,6 +234,7 @@ export default class ExpressionFormatter { layout, inline = false, enclosingParenthesis, + enclosingClauseName, }: ExpressionFormatterParams) { this.cfg = cfg; this.dialectCfg = dialectCfg; @@ -94,6 +242,7 @@ export default class ExpressionFormatter { this.params = params; this.layout = layout; this.enclosingParenthesis = enclosingParenthesis; + this.enclosingClauseName = enclosingClauseName; } public format(nodes: AstNode[]): Layout { @@ -211,6 +360,8 @@ export default class ExpressionFormatter { this.layout.add(node.openParen); this.layout.add(...inlineLayout.getLayoutItems()); this.layout.add(WS.NO_SPACE, node.closeParen, WS.SPACE); + } else if (this.shouldAlignCreateTableColumns()) { + this.formatCreateTableColumns(node); } else { this.layout.add(node.openParen, WS.NEWLINE); @@ -228,6 +379,260 @@ export default class ExpressionFormatter { } } + /** + * True when the current parenthesis is the direct column-list of a CREATE TABLE + * clause and the createTableColumnAlignment option is enabled. + * + * We only align the *direct* column-list paren of a CREATE TABLE clause, not + * any nested parens. The enclosingClauseName is cleared when recursing into a + * parenthesis (see formatSubExpression), so this returns false for inner + * parens such as the (100) in VARCHAR(100). + */ + private shouldAlignCreateTableColumns(): boolean { + return ( + Boolean(this.cfg.createTableColumnAlignment) && + Boolean(this.enclosingClauseName) && + CREATE_TABLE_CLAUSE_REGEX.test(this.enclosingClauseName as string) + ); + } + + /** + * Formats the column-list parenthesis of a CREATE TABLE statement with + * three-column alignment: + * + * + * + * where col_name is the column identifier, col_def is the data type (e.g. + * INT or VARCHAR(100)), and "other" is everything else (constraints, comments, + * defaults, ...). All three columns are left-aligned and padded to the + * maximum width of their respective column across all column-definition + * entries. Table-level constraints (PRIMARY KEY (...), FOREIGN KEY (...), + * CONSTRAINT ...) do not follow the col_name/col_def/other pattern, so they + * are emitted on their own line without padding. + * + * If any entry cannot be formatted as a single line (e.g. it contains + * comments that force a newline, or it exceeds expressionWidth), the whole + * parenthesis is formatted using the legacy multi-line layout instead. + */ + private formatCreateTableColumns(node: ParenthesisNode): void { + // Split children by commas into separate column entries. + const entries = this.splitByComma(node.children); + + interface FormattedEntry { + isColumn: boolean; + colName?: string; + colDef?: string; + other?: string; + raw?: string; + } + + const formattedEntries: FormattedEntry[] = []; + + for (const entry of entries) { + const isColumnDef = isCreateTableColumnDefinition(entry); + + if (isColumnDef) { + const colName = this.formatInlineString([entry[0]]); + // The data type may span one or two AST nodes: + // - one node: data_type, parameterized_data_type, or a bare + // identifier (e.g. TEXT, JSON, custom types) + // - two nodes: an identifier (the type name) immediately followed + // by a parenthesis (the parameter list), e.g. the + // BigQuery-style VARCHAR(100), which the BigQuery + // tokenizer doesn't recognize as a reserved data type + // and therefore parses as identifier + parenthesis + // rather than as a single parameterized_data_type. + const colDefNodes = [entry[1]]; + if (entry.length >= 3 && entry[2].type === NodeType.parenthesis) { + colDefNodes.push(entry[2]); + } + const colDef = this.formatColDef(colDefNodes); + const otherNodes = entry.slice(1 + colDefNodes.length); + // For the "other" part (constraints, comments, defaults, ...), we use + // a very large expression width so that long constraint lists like + // "NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '...'" + // don't trigger a fallback to the legacy layout. The "other" part is + // meant to be on a single line by design (that's the whole point of + // the column-alignment feature), so we only fall back when it truly + // can't be inlined (e.g. it contains a multi-line comment). + const other = otherNodes.length > 0 ? this.formatInlineString(otherNodes, true) : ''; + if (colName === undefined || colDef === undefined || other === undefined) { + this.formatCreateTableFallback(node); + return; + } + formattedEntries.push({ isColumn: true, colName, colDef, other }); + } else { + // Table-level constraint or other non-column entry. + // Same as above: use a large expression width to avoid spurious + // fallbacks for long constraint expressions. + const raw = this.formatInlineString(entry, true); + if (raw === undefined) { + this.formatCreateTableFallback(node); + return; + } + formattedEntries.push({ isColumn: false, raw }); + } + } + + // Compute max widths only across column-definition entries. + const columnEntries = formattedEntries.filter(e => e.isColumn); + const maxColName = columnEntries.reduce((m, e) => Math.max(m, (e.colName as string).length), 0); + const maxColDef = columnEntries.reduce((m, e) => Math.max(m, (e.colDef as string).length), 0); + + // Output the open paren and indent. + this.layout.add(node.openParen, WS.NEWLINE); + + if (isTabularStyle(this.cfg)) { + this.layout.add(WS.INDENT); + } else { + this.layout.indentation.increaseBlockLevel(); + this.layout.add(WS.INDENT); + } + + // Output each entry on its own line, comma-separated. + // When the "other" part of a column entry exceeds the configured + // createTableColumnAlignment_breakwidth, it is wrapped onto a new + // continuation line. The continuation line is indented by the current + // block-level indentation (which WS.INDENT produces) plus an extra + // inter-column padding so the "other" content aligns with the start + // of the third column on the previous line. + const breakWidth = this.cfg.createTableColumnAlignment_breakwidth ?? Infinity; + const thirdColPad = maxColName + 1 + maxColDef + 1; + + formattedEntries.forEach((entry, i) => { + if (entry.isColumn) { + const colName = (entry.colName as string).padEnd(maxColName); + const colDef = (entry.colDef as string).padEnd(maxColDef); + const other = entry.other as string; + if (other) { + // Wrap "other" to a continuation line when it exceeds breakWidth. + // Note: breakWidth === Infinity keeps everything on one line + // (matches the v1 behavior). breakWidth === 0 forces every + // non-empty "other" onto its own line. + if (other.length > breakWidth) { + // First line: colName + colDef (no third column) + this.layout.add(`${colName} ${colDef}`.trimEnd()); + // Continuation line: WS.INDENT produces the block-level + // indentation, then we add the inter-column padding so the + // "other" content visually aligns under the third column. + this.layout.add(WS.MANDATORY_NEWLINE, WS.INDENT); + this.layout.add(' '.repeat(thirdColPad) + other); + } else { + this.layout.add(`${colName} ${colDef} ${other}`); + } + } else { + // No third-column content: trim trailing whitespace so we don't + // leak spaces at end of line. + this.layout.add(`${colName} ${colDef}`.trimEnd()); + } + } else { + this.layout.add(entry.raw as string); + } + + if (i < formattedEntries.length - 1) { + this.layout.add(WS.NO_SPACE, ',', WS.NEWLINE, WS.INDENT); + } + }); + + if (!isTabularStyle(this.cfg)) { + this.layout.indentation.decreaseBlockLevel(); + } + + this.layout.add(WS.NEWLINE, WS.INDENT, node.closeParen, WS.SPACE); + } + + /** + * Legacy multi-line formatting of a parenthesis, used as a fallback when + * the column-alignment code can't apply (e.g. because some entry contains + * a comment that forces a newline). + */ + private formatCreateTableFallback(node: ParenthesisNode): void { + this.layout.add(node.openParen, WS.NEWLINE); + + if (isTabularStyle(this.cfg)) { + this.layout.add(WS.INDENT); + this.layout = this.formatSubExpression(node.children, node.openParen); + } else { + this.layout.indentation.increaseBlockLevel(); + this.layout.add(WS.INDENT); + this.layout = this.formatSubExpression(node.children, node.openParen); + this.layout.indentation.decreaseBlockLevel(); + } + + this.layout.add(WS.NEWLINE, WS.INDENT, node.closeParen, WS.SPACE); + } + + /** + * Splits a flat list of AST nodes (the children of a parenthesis) into + * groups separated by comma nodes. The comma nodes themselves are not + * included in the returned groups. + * + * Example: [id, INT, PRIMARY, KEY, COMMA, name, VARCHAR_PAREN, NOT, NULL] + * becomes [[id, INT, PRIMARY, KEY], [name, VARCHAR_PAREN, NOT, NULL]] + */ + private splitByComma(nodes: AstNode[]): AstNode[][] { + const entries: AstNode[][] = []; + let current: AstNode[] = []; + for (const node of nodes) { + if (node.type === NodeType.comma) { + entries.push(current); + current = []; + } else { + current.push(node); + } + } + if (current.length > 0) { + entries.push(current); + } + return entries; + } + + /** + * Formats the given nodes as a single-line expression and returns the + * resulting string. Returns undefined when the nodes cannot be formatted + * inline (e.g. because they contain a newline or, when `unlimited` is + * false, exceed expressionWidth). + * + * When `unlimited` is true, the expressionWidth limit is lifted — the + * only thing that can cause undefined to be returned is encountering an + * actual newline (e.g. from a multi-line comment). This is used by the + * CREATE TABLE column-alignment code for the "other" part of column + * definitions, which is meant to be on a single line by design even + * when it's long (e.g. "NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE + * CURRENT_TIMESTAMP COMMENT '...'"). + */ + private formatInlineString(nodes: AstNode[], unlimited = false): string | undefined { + const inlineLayout = this.formatInlineExpression(nodes, undefined, unlimited); + return inlineLayout?.toString().trim(); + } + + /** + * Formats the column-definition (data type) part of a CREATE TABLE column + * entry. The data type can span one or two AST nodes (see the comment in + * formatCreateTableColumns for details). + * + * When the data type is a single node (the common case: data_type or + * parameterized_data_type), we format it inline as-is. + * + * When the data type spans two nodes (identifier + parenthesis, as produced + * by dialects that don't reserve the type name as a data type keyword, + * e.g. BigQuery's VARCHAR(100)), we format each part separately and join + * them without whitespace. This avoids the spurious space that would + * otherwise appear between the type name and its parameter list (e.g. + * "VARCHAR (100)" instead of "VARCHAR(100)"), keeping the column-alignment + * layout consistent across dialects. + */ + private formatColDef(nodes: AstNode[]): string | undefined { + if (nodes.length === 1) { + return this.formatInlineString(nodes); + } + const parts = nodes.map(n => this.formatInlineString([n])); + if (parts.some(p => p === undefined)) { + return undefined; + } + return parts.join('').trim(); + } + private formatBetweenPredicate(node: BetweenPredicateNode) { this.layout.add(this.showKw(node.betweenKw), WS.SPACE); this.layout = this.formatSubExpression(node.expr1); @@ -284,19 +689,19 @@ export default class ExpressionFormatter { this.layout.add(WS.NEWLINE, WS.INDENT, this.showKw(node.nameKw), WS.NEWLINE); this.layout.indentation.increaseTopLevel(); this.layout.add(WS.INDENT); - this.layout = this.formatSubExpression(node.children); + this.layout = this.formatSubExpression(node.children, undefined, node.nameKw.text); this.layout.indentation.decreaseTopLevel(); } private formatClauseInOnelineStyle(node: ClauseNode) { this.layout.add(WS.NEWLINE, WS.INDENT, this.showKw(node.nameKw), WS.SPACE); - this.layout = this.formatSubExpression(node.children); + this.layout = this.formatSubExpression(node.children, undefined, node.nameKw.text); } private formatClauseInTabularStyle(node: ClauseNode) { this.layout.add(WS.NEWLINE, WS.INDENT, this.showKw(node.nameKw), WS.SPACE); this.layout.indentation.increaseTopLevel(); - this.layout = this.formatSubExpression(node.children); + this.layout = this.formatSubExpression(node.children, undefined, node.nameKw.text); this.layout.indentation.decreaseTopLevel(); } @@ -487,7 +892,13 @@ export default class ExpressionFormatter { private formatSubExpression( nodes: AstNode[], - enclosingParenthesis = this.enclosingParenthesis + enclosingParenthesis = this.enclosingParenthesis, + // When passing a clause name here, the new sub-formatter will treat a + // direct child parenthesis as the column-list of that clause (used to + // trigger CREATE TABLE column-alignment). Pass undefined to clear the + // context for nested expressions so alignment does not leak into + // inner parens. + enclosingClauseName: string | undefined = undefined ): Layout { return new ExpressionFormatter({ cfg: this.cfg, @@ -496,12 +907,19 @@ export default class ExpressionFormatter { layout: this.layout, inline: this.inline, enclosingParenthesis, + enclosingClauseName, }).format(nodes); } private formatInlineExpression( nodes: AstNode[], - enclosingParenthesis = this.enclosingParenthesis + enclosingParenthesis = this.enclosingParenthesis, + // When true, lift the expressionWidth limit so that long single-line + // expressions (e.g. the "other" part of a CREATE TABLE column + // definition) don't cause an InlineLayoutError. The only thing that + // can still cause undefined to be returned is encountering an actual + // newline (e.g. from a multi-line comment). + unlimited = false ): Layout | undefined { const oldParamIndex = this.params.getPositionalParameterIndex(); try { @@ -509,7 +927,7 @@ export default class ExpressionFormatter { cfg: this.cfg, dialectCfg: this.dialectCfg, params: this.params, - layout: new InlineLayout(this.cfg.expressionWidth), + layout: new InlineLayout(unlimited ? Number.MAX_SAFE_INTEGER : this.cfg.expressionWidth), inline: true, enclosingParenthesis, }).format(nodes); diff --git a/src/sqlFormatter.ts b/src/sqlFormatter.ts index d65159b6f6..10f40c637c 100644 --- a/src/sqlFormatter.ts +++ b/src/sqlFormatter.ts @@ -53,6 +53,8 @@ const defaultOptions: FormatOptions = { linesBetweenQueries: 1, denseOperators: false, newlineBeforeSemicolon: false, + createTableColumnAlignment: false, + createTableColumnAlignment_breakwidth: Infinity, }; /** diff --git a/src/validateConfig.ts b/src/validateConfig.ts index 8789fe63d7..a4d8e6b767 100644 --- a/src/validateConfig.ts +++ b/src/validateConfig.ts @@ -25,6 +25,15 @@ export function validateConfig(cfg: FormatOptions): FormatOptions { ); } + if ( + cfg.createTableColumnAlignment_breakwidth !== undefined && + cfg.createTableColumnAlignment_breakwidth < 0 + ) { + throw new ConfigError( + `createTableColumnAlignment_breakwidth config must be non-negative number. Received ${cfg.createTableColumnAlignment_breakwidth} instead.` + ); + } + if (cfg.params && !validateParams(cfg.params)) { // eslint-disable-next-line no-console console.warn('WARNING: All "params" option values should be strings.'); diff --git a/test/db2.test.ts b/test/db2.test.ts index 1833a38b0c..8dd800d7d4 100644 --- a/test/db2.test.ts +++ b/test/db2.test.ts @@ -21,7 +21,7 @@ describe('Db2Formatter', () => { supportsComments(format); supportsLimiting(format, { limit: true, fetchNext: true, offset: true }); - supportsCreateTable(format); + supportsCreateTable(format, { tableConstraints: true }); supportsAlterTable(format, { addColumn: true, dropColumn: true, diff --git a/test/features/createTable.ts b/test/features/createTable.ts index f0749eeca3..e3d3095e82 100644 --- a/test/features/createTable.ts +++ b/test/features/createTable.ts @@ -7,6 +7,16 @@ interface CreateTableConfig { ifNotExists?: boolean; columnComment?: boolean; tableComment?: boolean; + /** + * True when the dialect parses table-level constraints (CONSTRAINT name + * UNIQUE (...), PRIMARY KEY (...), FOREIGN KEY (...) REFERENCES ..., + * CHECK (...)) as keyword-led expressions. When false, the dialect may + * parse these as identifier-led function calls (e.g. BigQuery parses + * UNIQUE (col) as a function call to a UNIQUE function), which produces + * different spacing in the output. We only run the constraint-related + * alignment test when this is true. + */ + tableConstraints?: boolean; } export default function supportsCreateTable(format: FormatFn, cfg: CreateTableConfig = {}) { @@ -84,4 +94,222 @@ export default function supportsCreateTable(format: FormatFn, cfg: CreateTableCo ); `); }); + + // ---------- createTableColumnAlignment option ---------- + // + // When createTableColumnAlignment is enabled, each column definition inside + // a CREATE TABLE column list is laid out in three left-aligned columns: + // + // + // + // where col_name is the column identifier, col_def is the data type (which + // may be parameterized, e.g. VARCHAR(100)), and "other" is everything else + // (NOT NULL, PRIMARY KEY, DEFAULT, COMMENT, ...). All three columns are + // padded to the maximum width of their respective column across all + // column-definition entries, so the starts of the columns line up across + // rows. + // + // Table-level constraints (PRIMARY KEY (...), FOREIGN KEY (...), + // CONSTRAINT ...) do not have a column name / data type pattern, so they + // are emitted on their own line without padding. + describe('createTableColumnAlignment', () => { + it('aligns columns when option is enabled', () => { + expect( + format( + 'CREATE TABLE tbl (a INT PRIMARY KEY, b TEXT, c INT NOT NULL, doggie INT NOT NULL);', + { createTableColumnAlignment: true } + ) + ).toBe(dedent` + CREATE TABLE tbl ( + a INT PRIMARY KEY, + b TEXT, + c INT NOT NULL, + doggie INT NOT NULL + ); + `); + }); + + it('is a no-op when option is disabled (default behavior preserved)', () => { + expect( + format('CREATE TABLE tbl (a INT PRIMARY KEY, b TEXT, c INT NOT NULL, doggie INT NOT NULL);') + ).toBe(dedent` + CREATE TABLE tbl ( + a INT PRIMARY KEY, + b TEXT, + c INT NOT NULL, + doggie INT NOT NULL + ); + `); + }); + + it('aligns parameterized data types like VARCHAR(100)', () => { + expect( + format( + `CREATE TABLE users ( + id INT PRIMARY KEY NOT NULL, + name VARCHAR(100) NOT NULL, + age INT, + email VARCHAR(255) UNIQUE + );`, + { createTableColumnAlignment: true } + ) + ).toBe(dedent` + CREATE TABLE users ( + id INT PRIMARY KEY NOT NULL, + name VARCHAR(100) NOT NULL, + age INT, + email VARCHAR(255) UNIQUE + ); + `); + }); + + if (cfg.tableConstraints) { + it('leaves table-level constraints on their own line without padding', () => { + expect( + format( + `CREATE TABLE users ( + id INT PRIMARY KEY, + name VARCHAR(100), + CONSTRAINT uc_email UNIQUE (name) + );`, + { createTableColumnAlignment: true } + ) + ).toBe(dedent` + CREATE TABLE users ( + id INT PRIMARY KEY, + name VARCHAR(100), + CONSTRAINT uc_email UNIQUE (name) + ); + `); + }); + } + + it('keeps short CREATE TABLE on a single line', () => { + expect( + format('CREATE TABLE tbl (a INT PRIMARY KEY, b TEXT);', { + createTableColumnAlignment: true, + }) + ).toBe(dedent` + CREATE TABLE tbl (a INT PRIMARY KEY, b TEXT); + `); + }); + + if (cfg.orReplace) { + it('aligns CREATE OR REPLACE TABLE', () => { + expect( + format( + 'CREATE OR REPLACE TABLE tbl (a INT PRIMARY KEY, b VARCHAR(20) NOT NULL, c INT NOT NULL);', + { createTableColumnAlignment: true } + ) + ).toBe(dedent` + CREATE OR REPLACE TABLE tbl ( + a INT PRIMARY KEY, + b VARCHAR(20) NOT NULL, + c INT NOT NULL + ); + `); + }); + } + + if (cfg.ifNotExists) { + it('aligns CREATE TABLE IF NOT EXISTS', () => { + expect( + format( + 'CREATE TABLE IF NOT EXISTS tbl (a INT PRIMARY KEY, b VARCHAR(20) NOT NULL, c INT NOT NULL);', + { createTableColumnAlignment: true } + ) + ).toBe(dedent` + CREATE TABLE IF NOT EXISTS tbl ( + a INT PRIMARY KEY, + b VARCHAR(20) NOT NULL, + c INT NOT NULL + ); + `); + }); + } + + // ---------- createTableColumnAlignment_breakwidth option ---------- + // + // When the "other" part of a column entry (constraints, comments, + // defaults, ...) exceeds this width, it is wrapped onto a new + // continuation line indented to align with the start of the third + // column. Defaults to Infinity (never wrap). + describe('createTableColumnAlignment_breakwidth', () => { + it('defaults to Infinity (never wraps)', () => { + // Even a very long "other" part stays on one line. + expect( + format( + 'CREATE TABLE tbl (a INT NOT NULL DEFAULT 1 COMMENT "a very long comment that exceeds the default expression width", b INT NOT NULL DEFAULT 1);', + { createTableColumnAlignment: true } + ) + ).toBe(dedent` + CREATE TABLE tbl ( + a INT NOT NULL DEFAULT 1 COMMENT "a very long comment that exceeds the default expression width", + b INT NOT NULL DEFAULT 1 + ); + `); + }); + + it('wraps "other" to a new line when it exceeds the configured width', () => { + // breakwidth=30 -> the long "other" of column `a` wraps; the short + // "other" of column `b` does not. + // + // The continuation line is indented to align with the start of the + // third column. With maxColName=1 ("a"), maxColDef=3 ("INT") and a + // 2-space block indent, that's 2 + 1 + 1 + 3 + 1 = 8 spaces. + expect( + format( + 'CREATE TABLE tbl (a INT NOT NULL DEFAULT 1 COMMENT "a very long comment that exceeds the default expression width", b INT NOT NULL DEFAULT 1);', + { createTableColumnAlignment: true, createTableColumnAlignment_breakwidth: 30 } + ) + ).toBe(dedent` + CREATE TABLE tbl ( + a INT + NOT NULL DEFAULT 1 COMMENT "a very long comment that exceeds the default expression width", + b INT NOT NULL DEFAULT 1 + ); + `); + }); + + it('set to 0 to always wrap "other" to a new line', () => { + // Use a SQL long enough to force the column list onto multiple lines + // even without breakwidth, so we can see the wrap behavior. + expect( + format( + 'CREATE TABLE tbl (a INT NOT NULL DEFAULT 1, b INT NOT NULL DEFAULT 2, c INT NOT NULL DEFAULT 3);', + { createTableColumnAlignment: true, createTableColumnAlignment_breakwidth: 0 } + ) + ).toBe(dedent` + CREATE TABLE tbl ( + a INT + NOT NULL DEFAULT 1, + b INT + NOT NULL DEFAULT 2, + c INT + NOT NULL DEFAULT 3 + ); + `); + }); + + it('is ignored when createTableColumnAlignment is disabled', () => { + // breakwidth has no effect when alignment is off. + expect( + format( + 'CREATE TABLE tbl (a INT PRIMARY KEY, b TEXT, c INT NOT NULL, doggie INT NOT NULL);', + { + createTableColumnAlignment: false, + createTableColumnAlignment_breakwidth: 0, + } + ) + ).toBe(dedent` + CREATE TABLE tbl ( + a INT PRIMARY KEY, + b TEXT, + c INT NOT NULL, + doggie INT NOT NULL + ); + `); + }); + }); + }); } diff --git a/test/mariadb.test.ts b/test/mariadb.test.ts index a2dc9b993a..44849437ad 100644 --- a/test/mariadb.test.ts +++ b/test/mariadb.test.ts @@ -45,6 +45,7 @@ describe('MariaDbFormatter', () => { ifNotExists: true, columnComment: true, tableComment: true, + tableConstraints: true, }); supportsConstraints(format, ['RESTRICT', 'CASCADE', 'SET NULL', 'NO ACTION', 'SET DEFAULT']); supportsParams(format, { positional: true }); diff --git a/test/mysql.test.ts b/test/mysql.test.ts index e6bcd37af9..8a30a3b446 100644 --- a/test/mysql.test.ts +++ b/test/mysql.test.ts @@ -56,7 +56,12 @@ describe('MySqlFormatter', () => { ); supportsWindow(format); supportsLimiting(format, { limit: true, offset: true }); - supportsCreateTable(format, { ifNotExists: true, columnComment: true, tableComment: true }); + supportsCreateTable(format, { + ifNotExists: true, + columnComment: true, + tableComment: true, + tableConstraints: true, + }); supportsConstraints(format, [ 'RESTRICT', 'CASCADE', diff --git a/test/plsql.test.ts b/test/plsql.test.ts index 73413a2e1f..5358be02c6 100644 --- a/test/plsql.test.ts +++ b/test/plsql.test.ts @@ -37,7 +37,7 @@ describe('PlSqlFormatter', () => { supportsComments(format); supportsCommentOn(format); supportsCreateView(format, { orReplace: true, materialized: true }); - supportsCreateTable(format); + supportsCreateTable(format, { tableConstraints: true }); supportsDropTable(format); // http://dba-oracle.com/bk_on_delete_restrict_on_delete_no_action_tips.htm supportsConstraints(format, ['SET NULL', 'CASCADE', 'NO ACTION']); diff --git a/test/postgresql.test.ts b/test/postgresql.test.ts index e8e99adfea..e403702e3b 100644 --- a/test/postgresql.test.ts +++ b/test/postgresql.test.ts @@ -27,7 +27,7 @@ describe('PostgreSqlFormatter', () => { behavesLikePostgresqlFormatter(format); supportsCreateView(format, { orReplace: true, materialized: true, ifNotExists: true }); - supportsCreateTable(format, { ifNotExists: true }); + supportsCreateTable(format, { ifNotExists: true, tableConstraints: true }); supportsDropTable(format, { ifExists: true }); supportsConstraints(format, ['NO ACTION', 'RESTRICT', 'CASCADE', 'SET NULL', 'SET DEFAULT']); supportsArrayLiterals(format, { withArrayPrefix: true }); diff --git a/test/singlestoredb.test.ts b/test/singlestoredb.test.ts index ce587bfb3e..c429d6bfca 100644 --- a/test/singlestoredb.test.ts +++ b/test/singlestoredb.test.ts @@ -54,7 +54,12 @@ describe('SingleStoreDbFormatter', () => { { any: true } ); supportsLimiting(format, { limit: true, offset: true }); - supportsCreateTable(format, { ifNotExists: true, columnComment: true, tableComment: true }); + supportsCreateTable(format, { + ifNotExists: true, + columnComment: true, + tableComment: true, + tableConstraints: true, + }); supportsCreateView(format); supportsAlterTable(format, { addColumn: true, diff --git a/test/snowflake.test.ts b/test/snowflake.test.ts index a4c23913fd..c108df7b11 100644 --- a/test/snowflake.test.ts +++ b/test/snowflake.test.ts @@ -37,6 +37,7 @@ describe('SnowflakeFormatter', () => { ifNotExists: true, columnComment: true, tableComment: true, + tableConstraints: true, }); supportsConstraints(format, ['CASCADE', 'SET NULL', 'SET DEFAULT', 'RESTRICT', 'NO ACTION']); supportsDropTable(format, { ifExists: true }); diff --git a/test/sql.test.ts b/test/sql.test.ts index c9f6bea8cd..5c8ab212f7 100644 --- a/test/sql.test.ts +++ b/test/sql.test.ts @@ -34,7 +34,7 @@ describe('SqlFormatter', () => { supportsNumbers(format); supportsComments(format); supportsCreateView(format); - supportsCreateTable(format); + supportsCreateTable(format, { tableConstraints: true }); supportsDropTable(format); supportsConstraints(format, ['CASCADE', 'SET NULL', 'SET DEFAULT', 'RESTRICT', 'NO ACTION']); supportsAlterTable(format, { diff --git a/test/sqlite.test.ts b/test/sqlite.test.ts index 1b4efd51bd..90eeb77cc7 100644 --- a/test/sqlite.test.ts +++ b/test/sqlite.test.ts @@ -34,7 +34,7 @@ describe('SqliteFormatter', () => { supportsNumbers(format); supportsComments(format); supportsCreateView(format, { ifNotExists: true }); - supportsCreateTable(format, { ifNotExists: true }); + supportsCreateTable(format, { ifNotExists: true, tableConstraints: true }); supportsDropTable(format, { ifExists: true }); supportsConstraints(format, ['SET NULL', 'SET DEFAULT', 'CASCADE', 'RESTRICT', 'NO ACTION']); supportsAlterTable(format, { diff --git a/test/tidb.test.ts b/test/tidb.test.ts index 9ff997122a..8ac18978b6 100644 --- a/test/tidb.test.ts +++ b/test/tidb.test.ts @@ -57,7 +57,12 @@ describe('TiDBFormatter', () => { ); supportsWindow(format); supportsLimiting(format, { limit: true, offset: true }); - supportsCreateTable(format, { ifNotExists: true, columnComment: true, tableComment: true }); + supportsCreateTable(format, { + ifNotExists: true, + columnComment: true, + tableComment: true, + tableConstraints: true, + }); supportsConstraints(format, [ 'RESTRICT', 'CASCADE', diff --git a/test/transactsql.test.ts b/test/transactsql.test.ts index f3041c23fe..b9ec9f05b3 100644 --- a/test/transactsql.test.ts +++ b/test/transactsql.test.ts @@ -34,7 +34,7 @@ describe('TransactSqlFormatter', () => { supportsNumbers(format); supportsComments(format, { nestedBlockComments: true }); supportsCreateView(format, { materialized: true }); - supportsCreateTable(format); + supportsCreateTable(format, { tableConstraints: true }); supportsDropTable(format, { ifExists: true }); supportsConstraints(format, ['SET NULL', 'SET DEFAULT', 'CASCADE', 'NO ACTION']); supportsAlterTable(format, {