Skip to content

Commit d09aefe

Browse files
committed
feat: implement formatting for advanced T-SQL features and index alignment
- Add layout logic for T-SQL OUTPUT and OPENJSON clauses to the formatting engine - Improve column list alignment in T-SQL subqueries and JSON/XML select statements - Fix CREATE INDEX river alignment by calculating width based on the CREATE keyword to accommodate INCLUDE clauses - Ensure correct indentation and alignment for T-SQL UPDATE table hints - Implement specialized type casing for OPENJSON schema definitions to improve readability - Add comprehensive test suites verifying advanced T-SQL syntax and statement formatting
1 parent e575655 commit d09aefe

File tree

5 files changed

+404
-11
lines changed

5 files changed

+404
-11
lines changed

src/formatter.ts

Lines changed: 85 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -346,7 +346,7 @@ function deriveRiverWidth(node: AST.Node): number {
346346
return width;
347347
}
348348
case 'create_index': {
349-
let width = 'ON'.length;
349+
let width = 'CREATE'.length;
350350
if (node.using) width = Math.max(width, 'USING'.length);
351351
if (node.include && node.include.length > 0) width = Math.max(width, 'INCLUDE'.length);
352352
if (node.where) width = Math.max(width, 'WHERE'.length);
@@ -904,7 +904,20 @@ function formatSelect(node: AST.SelectStatement, ctx: FormatContext): string {
904904
: '';
905905
const topStr = node.top ? ` ${node.top}` : '';
906906
const colStartCol = contentCol(ctx) + stringDisplayWidth(distinctStr + topStr);
907-
const colStr = formatColumnList(node.columns, colStartCol, ctx);
907+
const isTsql = isTsqlDialect(ctx.runtime.dialect);
908+
const forceTsqlSubqueryIdentifierColumns = isTsql
909+
&& ctx.isSubquery
910+
&& node.columns.length >= 3
911+
&& node.columns.every(col => col.alias === undefined
912+
&& (!col.leadingComments || col.leadingComments.length === 0)
913+
&& col.trailingComment === undefined
914+
&& col.expr.type === 'identifier');
915+
const forceMultilineColumns =
916+
(node.lockingClause && /^(JSON|XML)\b/i.test(node.lockingClause.trim()))
917+
|| (node.top && node.columns.length > 1)
918+
|| forceTsqlSubqueryIdentifierColumns
919+
|| selectHasPivotOrJsonWith(node);
920+
const colStr = formatColumnList(node.columns, colStartCol, ctx, forceMultilineColumns);
908921
const firstColumnHasLeadingComments = !!(node.columns[0]?.leadingComments && node.columns[0].leadingComments.length > 0);
909922
if (firstColumnHasLeadingComments) {
910923
lines.push(selectKw + distinctStr + topStr);
@@ -1045,17 +1058,26 @@ interface FormattedColumnPart {
10451058
comment?: AST.CommentNode;
10461059
}
10471060

1048-
function formatColumnList(columns: readonly AST.ColumnExpr[], firstColStartCol: number, ctx: FormatContext): string {
1061+
function formatColumnList(
1062+
columns: readonly AST.ColumnExpr[],
1063+
firstColStartCol: number,
1064+
ctx: FormatContext,
1065+
forceOnePerLine: boolean = false,
1066+
): string {
10491067
if (columns.length === 0) return '';
10501068

10511069
const parts = buildFormattedColumnParts(columns, ctx);
1070+
const cCol = contentCol(ctx);
1071+
if (forceOnePerLine) {
1072+
const indent = ' '.repeat(firstColStartCol);
1073+
return formatColumnsOnePerLine(parts, indent);
1074+
}
1075+
const indent = ' '.repeat(cCol);
10521076
const inlineResult = tryFormatInlineColumnList(parts, columns, firstColStartCol, ctx);
10531077
if (inlineResult) return inlineResult;
10541078

10551079
const hasMultiLine = parts.some(p => p.text.includes('\n'));
10561080
const hasLeadingComments = parts.some(p => !!(p.leadingComments && p.leadingComments.length > 0));
1057-
const cCol = contentCol(ctx);
1058-
const indent = ' '.repeat(cCol);
10591081

10601082
// If any multi-line expression, one-per-line
10611083
if (hasMultiLine || hasLeadingComments) {
@@ -1069,6 +1091,14 @@ function formatColumnList(columns: readonly AST.ColumnExpr[], firstColStartCol:
10691091
return formatColumnListWithGroups(parts, indent, cCol, ctx);
10701092
}
10711093

1094+
function selectHasPivotOrJsonWith(node: AST.SelectStatement): boolean {
1095+
const from = node.from;
1096+
if (from?.pivotClause || from?.jsonWithClause) return true;
1097+
if (node.additionalFromItems?.some(item => !!(item.pivotClause || item.jsonWithClause))) return true;
1098+
if (node.joins.some(j => !!(j.pivotClause || j.jsonWithClause))) return true;
1099+
return false;
1100+
}
1101+
10721102
function buildFormattedColumnParts(columns: readonly AST.ColumnExpr[], ctx: FormatContext): FormattedColumnPart[] {
10731103
return columns.map(col => {
10741104
let text = formatExprInSelect(col.expr, contentCol(ctx), ctx, ctx.outerColumnOffset || 0, ctx.depth);
@@ -1658,7 +1688,14 @@ function formatFromClause(from: AST.FromClause, ctx: FormatContext): string {
16581688
if (from.ordinality) {
16591689
result += ' WITH ORDINALITY';
16601690
}
1661-
if (from.alias) {
1691+
if (from.jsonWithClause) {
1692+
const withLine = normalizeOpenJsonWithClause(from.jsonWithClause);
1693+
const alias = from.alias
1694+
? ' AS ' + formatAlias(from.alias)
1695+
+ (from.aliasColumns && from.aliasColumns.length > 0 ? '(' + from.aliasColumns.join(', ') + ')' : '')
1696+
: '';
1697+
result += '\n' + ' '.repeat(baseCol) + withLine + alias;
1698+
} else if (from.alias) {
16621699
result += ' AS ' + formatAlias(from.alias);
16631700
if (from.aliasColumns && from.aliasColumns.length > 0) {
16641701
result += '(' + from.aliasColumns.join(', ') + ')';
@@ -1728,6 +1765,15 @@ function normalizePivotClauseText(text: string): string {
17281765
.replace(/\bIN\s*\(/gi, 'IN (');
17291766
}
17301767

1768+
function normalizeOpenJsonWithClause(text: string): string {
1769+
const normalized = text.replace(/\bWITH\s*\(/gi, 'WITH (');
1770+
// OPENJSON schema clauses read well with lowercased type names.
1771+
return normalized.replace(
1772+
/\b(BIGINT|INT|SMALLINT|TINYINT|BIT|MONEY|SMALLMONEY|DECIMAL|NUMERIC|FLOAT|REAL|DATE|DATETIME2|DATETIMEOFFSET|NVARCHAR|VARCHAR|NCHAR|CHAR|VARBINARY|BINARY)\b/g,
1773+
m => m.toLowerCase(),
1774+
);
1775+
}
1776+
17311777
// ─── JOIN ────────────────────────────────────────────────────────────
17321778

17331779
function formatJoin(join: AST.JoinClause, ctx: FormatContext, needsBlank: boolean): string {
@@ -1825,7 +1871,14 @@ function formatJoinTable(join: AST.JoinClause, tableStartCol: number, runtime: F
18251871
if (join.only) result = 'ONLY ' + result;
18261872
if (join.lateral) result = 'LATERAL ' + result;
18271873
if (join.ordinality) result += ' WITH ORDINALITY';
1828-
if (join.alias) {
1874+
if (join.jsonWithClause) {
1875+
const withLine = normalizeOpenJsonWithClause(join.jsonWithClause);
1876+
const alias = join.alias
1877+
? ' AS ' + formatAlias(join.alias)
1878+
+ (join.aliasColumns && join.aliasColumns.length > 0 ? '(' + join.aliasColumns.join(', ') + ')' : '')
1879+
: '';
1880+
result += '\n' + ' '.repeat(tableStartCol) + withLine + alias;
1881+
} else if (join.alias) {
18291882
result += ' AS ' + formatAlias(join.alias);
18301883
if (join.aliasColumns && join.aliasColumns.length > 0) {
18311884
result += '(' + join.aliasColumns.join(', ') + ')';
@@ -2615,6 +2668,10 @@ function formatInsert(node: AST.InsertStatement, ctx: FormatContext): string {
26152668
lines.push(header);
26162669
}
26172670

2671+
if (node.outputClause) {
2672+
lines.push(rightAlign('OUTPUT', dmlCtx) + ' ' + node.outputClause.trim());
2673+
}
2674+
26182675
if (node.valueClauseLeadingComments && node.valueClauseLeadingComments.length > 0) {
26192676
emitComments(node.valueClauseLeadingComments, lines);
26202677
}
@@ -2772,7 +2829,11 @@ function formatUpdate(node: AST.UpdateStatement, ctx: FormatContext): string {
27722829
emitComments(node.leadingComments, lines);
27732830

27742831
const updateTargets: string[] = [];
2775-
updateTargets.push(lowerIdent(node.table) + (node.alias ? ' AS ' + formatAlias(node.alias) : ''));
2832+
updateTargets.push(
2833+
lowerIdent(node.table)
2834+
+ (node.alias ? ' AS ' + formatAlias(node.alias) : '')
2835+
+ (node.targetHint ? ' ' + node.targetHint : '')
2836+
);
27762837
if (node.additionalTables && node.additionalTables.length > 0) {
27772838
for (const tableRef of node.additionalTables) {
27782839
updateTargets.push(
@@ -2885,6 +2946,10 @@ function formatDelete(node: AST.DeleteStatement, ctx: FormatContext): string {
28852946
} else {
28862947
lines.push(deleteKw);
28872948
}
2949+
2950+
if (node.outputClause) {
2951+
lines.push(rightAlign('OUTPUT', dmlCtx) + ' ' + node.outputClause.trim());
2952+
}
28882953
lines.push(rightAlign('FROM', dmlCtx) + ' ' + lowerIdent(node.from) + (node.alias ? ' AS ' + formatAlias(node.alias) : ''));
28892954

28902955
if (node.fromJoins && node.fromJoins.length > 0) {
@@ -2998,7 +3063,7 @@ function formatCreateIndex(node: AST.CreateIndexStatement, ctx: FormatContext):
29983063
const lines: string[] = [];
29993064
emitComments(node.leadingComments, lines);
30003065

3001-
let header = 'CREATE';
3066+
let header = rightAlign('CREATE', idxCtx);
30023067
if (node.unique) header += ' UNIQUE';
30033068
if (node.clustered) header += ' ' + node.clustered;
30043069
header += ' INDEX';
@@ -3294,7 +3359,7 @@ function formatMerge(node: AST.MergeStatement, ctx: FormatContext): string {
32943359
const target = node.target.table + (node.target.alias ? ' AS ' + node.target.alias : '');
32953360
const sourceTable = typeof node.source.table === 'string'
32963361
? node.source.table
3297-
: formatExpr(node.source.table, 0, mergeCtx.runtime);
3362+
: formatExprAtColumn(node.source.table, contentCol(mergeCtx), mergeCtx.runtime);
32983363
const source = sourceTable + (node.source.alias ? ' AS ' + node.source.alias : '');
32993364

33003365
lines.push(rightAlign('MERGE', mergeCtx) + ' INTO ' + target);
@@ -3306,7 +3371,7 @@ function formatMerge(node: AST.MergeStatement, ctx: FormatContext): string {
33063371
const actionPad = contentPad(mergeCtx);
33073372

33083373
for (const wc of node.whenClauses) {
3309-
const branch = wc.matched ? 'MATCHED' : 'NOT MATCHED';
3374+
const branch = wc.matched ? 'MATCHED' : (wc.matchKind ?? 'NOT MATCHED');
33103375
const cond = wc.condition ? ' AND ' + formatExpr(wc.condition, 0, mergeCtx.runtime) : '';
33113376
lines.push(rightAlign('WHEN', mergeCtx) + ' ' + branch + cond + ' THEN');
33123377

@@ -3342,6 +3407,11 @@ function formatMerge(node: AST.MergeStatement, ctx: FormatContext): string {
33423407
}
33433408
}
33443409

3410+
if (node.outputClause) {
3411+
lines.push('OUTPUT ' + node.outputClause.trim() + ';');
3412+
return lines.join('\n');
3413+
}
3414+
33453415
lines[lines.length - 1] += ';';
33463416
return lines.join('\n');
33473417
}
@@ -3780,6 +3850,10 @@ function isMySqlDialect(dialect?: SQLDialect): boolean {
37803850
return dialect === 'mysql' || (typeof dialect === 'object' && dialect.name === 'mysql');
37813851
}
37823852

3853+
function isTsqlDialect(dialect?: SQLDialect): boolean {
3854+
return dialect === 'tsql' || (typeof dialect === 'object' && dialect.name === 'tsql');
3855+
}
3856+
37833857
function createTableElementIndent(
37843858
elem: AST.TableElement,
37853859
tableConstraintIndent: string,

tests/formatter.test.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1356,6 +1356,29 @@ describe('Category 40b: CREATE INDEX River Alignment', () => {
13561356
`CREATE INDEX IF NOT EXISTS waitlist_email_idx
13571357
ON waitlist (email);`
13581358
);
1359+
1360+
assertFormat('40b.5 — CREATE INDEX with INCLUDE widens river and pads header',
1361+
`create index idx_cover on orders (customer_id) include (total, status) where active = true;`,
1362+
` CREATE INDEX idx_cover
1363+
ON orders (customer_id)
1364+
INCLUDE (total, status)
1365+
WHERE active = TRUE;`
1366+
);
1367+
1368+
assertFormat('40b.6 — CREATE INDEX with INCLUDE and no WHERE',
1369+
`create unique index idx_lookup on products (sku) include (name, price);`,
1370+
` CREATE UNIQUE INDEX idx_lookup
1371+
ON products (sku)
1372+
INCLUDE (name, price);`
1373+
);
1374+
1375+
assertFormat('40b.7 — CREATE INDEX with INCLUDE and USING',
1376+
`create index idx_data on items using btree (category) include (label);`,
1377+
` CREATE INDEX idx_data
1378+
ON items
1379+
USING BTREE (category)
1380+
INCLUDE (label);`
1381+
);
13591382
});
13601383

13611384
describe('Category 41: Complex Parenthesized Boolean Logic', () => {

tests/tsql-create-index-view-go-batches.test.ts

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,4 +28,44 @@ where authors.au_id = titleauthor.au_id`;
2828
expect(out).toContain('\n WHERE authors.au_id = titleauthor.au_id;');
2929
expect(out).not.toContain('select title, au_ord, au_lname');
3030
});
31+
32+
it('NONCLUSTERED INDEX with INCLUDE and WHERE aligns river through header', () => {
33+
const sql = `CREATE NONCLUSTERED INDEX IX_Products_Category ON dbo.Products (Category, SubCategory) INCLUDE (ProductName, UnitPrice) WHERE IsDiscontinued = 0;`;
34+
const out = formatSQL(sql, { dialect: 'tsql' });
35+
expect(out.trimEnd()).toBe(
36+
` CREATE NONCLUSTERED INDEX IX_Products_Category\n` +
37+
` ON dbo.Products (Category, SubCategory)\n` +
38+
`INCLUDE (ProductName, UnitPrice)\n` +
39+
` WHERE IsDiscontinued = 0;`
40+
);
41+
});
42+
43+
it('NONCLUSTERED INDEX with INCLUDE only aligns river through header', () => {
44+
const sql = `CREATE NONCLUSTERED INDEX IX_Employees_Dept_Region ON dbo.Employees (DepartmentName, Region) INCLUDE (FullName, Salary);`;
45+
const out = formatSQL(sql, { dialect: 'tsql' });
46+
expect(out.trimEnd()).toBe(
47+
` CREATE NONCLUSTERED INDEX IX_Employees_Dept_Region\n` +
48+
` ON dbo.Employees (DepartmentName, Region)\n` +
49+
`INCLUDE (FullName, Salary);`
50+
);
51+
});
52+
53+
it('NONCLUSTERED INDEX with INCLUDE across GO batches', () => {
54+
const sql = `CREATE NONCLUSTERED INDEX IX_Products_Category ON dbo.Products (Category, SubCategory) INCLUDE (ProductName, UnitPrice) WHERE IsDiscontinued = 0;
55+
GO
56+
CREATE NONCLUSTERED INDEX IX_Employees_Dept_Region ON dbo.Employees (DepartmentName, Region) INCLUDE (FullName, Salary);
57+
GO`;
58+
const recoveries: string[] = [];
59+
const out = formatSQL(sql, {
60+
dialect: 'tsql',
61+
onRecover: err => recoveries.push(err.message),
62+
});
63+
expect(recoveries).toEqual([]);
64+
expect(out).toContain(' CREATE NONCLUSTERED INDEX IX_Products_Category');
65+
expect(out).toContain('\nINCLUDE (ProductName, UnitPrice)');
66+
expect(out).toContain('\n WHERE IsDiscontinued = 0;');
67+
expect(out).toContain('\nGO\n');
68+
expect(out).toContain(' CREATE NONCLUSTERED INDEX IX_Employees_Dept_Region');
69+
expect(out).toContain('\nINCLUDE (FullName, Salary);');
70+
});
3171
});
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
import { describe, expect, it } from 'bun:test';
2+
import { formatSQL } from '../src/format';
3+
import { parse } from '../src/parser';
4+
5+
describe('T-SQL UPDATE table hints', () => {
6+
it('parses UPDATE ... WITH (<hints>) SET ...', () => {
7+
const sql = `UPDATE dbo.Products WITH (UPDLOCK, HOLDLOCK) SET UnitPrice = UnitPrice * 1.05 WHERE Category = N'Electronics' AND IsDiscontinued = 0;`;
8+
expect(() => parse(sql, { dialect: 'tsql', recover: false })).not.toThrow();
9+
});
10+
11+
it('formats UPDATE ... WITH (<hints>) with expected clause layout', () => {
12+
const sql = `UPDATE dbo.Products WITH (UPDLOCK, HOLDLOCK) SET UnitPrice = UnitPrice * 1.05 WHERE Category = N'Electronics' AND IsDiscontinued = 0;`;
13+
const out = formatSQL(sql, { dialect: 'tsql' });
14+
expect(out).toBe(
15+
[
16+
'UPDATE dbo.Products WITH (UPDLOCK, HOLDLOCK)',
17+
' SET UnitPrice = UnitPrice * 1.05',
18+
' WHERE Category = N\'Electronics\'',
19+
' AND IsDiscontinued = 0;',
20+
'',
21+
].join('\n'),
22+
);
23+
});
24+
});
25+

0 commit comments

Comments
 (0)