From 4ab21857d12ec236674293b7e75fac25bc93774f Mon Sep 17 00:00:00 2001 From: Akinfenwa Obaseyi Date: Sat, 24 Oct 2026 12:46:36 +0100 Subject: [PATCH 1/8] fix: format window frame clauses after ORDER BY --- src/formatter/ExpressionFormatter.ts | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/src/formatter/ExpressionFormatter.ts b/src/formatter/ExpressionFormatter.ts index 2c1fdc634c..4423bc459a 100644 --- a/src/formatter/ExpressionFormatter.ts +++ b/src/formatter/ExpressionFormatter.ts @@ -263,7 +263,9 @@ export default class ExpressionFormatter { } private formatClause(node: ClauseNode) { - if (this.isOnelineClause(node)) { + if (/^(ROWS|RANGE|GROUPS) BETWEEN$/.test(node.nameKw.text)) { + this.formatWindowFrameClause(node); + } else if (this.isOnelineClause(node)) { this.formatClauseInOnelineStyle(node); } else if (isTabularStyle(this.cfg)) { this.formatClauseInTabularStyle(node); @@ -293,6 +295,18 @@ export default class ExpressionFormatter { this.layout = this.formatSubExpression(node.children); } + private formatWindowFrameClause(node: ClauseNode) { + this.layout.add(WS.NEWLINE, WS.INDENT, this.showKw(node.nameKw), WS.SPACE); + // The AND in a window frame separates its bounds, not boolean conditions. + node.children.forEach(child => { + if (child.type === NodeType.keyword && child.tokenType === TokenType.AND) { + this.withComments(child, () => this.layout.add(this.showNonTabularKw(child), WS.SPACE)); + } else { + this.formatNode(child); + } + }); + } + private formatClauseInTabularStyle(node: ClauseNode) { this.layout.add(WS.NEWLINE, WS.INDENT, this.showKw(node.nameKw), WS.SPACE); this.layout.indentation.increaseTopLevel(); From f782e03255537edda4dfcaf7f4ad5cbd245f6402 Mon Sep 17 00:00:00 2001 From: Akinfenwa Obaseyi Date: Sat, 24 Oct 2026 12:48:45 +0100 Subject: [PATCH 2/8] fix: format window frame clauses after ORDER BY --- src/languages/duckdb/duckdb.formatter.ts | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/src/languages/duckdb/duckdb.formatter.ts b/src/languages/duckdb/duckdb.formatter.ts index 1d84a498a1..80468e3267 100644 --- a/src/languages/duckdb/duckdb.formatter.ts +++ b/src/languages/duckdb/duckdb.formatter.ts @@ -33,6 +33,7 @@ const reservedClauses = expandPhrases([ const standardOnelineClauses = expandPhrases([ 'CREATE [OR REPLACE] [TEMPORARY | TEMP] TABLE [IF NOT EXISTS]', + '{ROWS | RANGE | GROUPS} BETWEEN', ]); const tabularOnelineClauses = expandPhrases([ @@ -134,11 +135,7 @@ const reservedJoins = expandPhrases([ 'SEMI JOIN', ]); -const reservedKeywordPhrases = expandPhrases([ - '{ROWS | RANGE | GROUPS} BETWEEN', - 'SIMILAR TO', - 'IS [NOT] DISTINCT FROM', -]); +const reservedKeywordPhrases = expandPhrases(['SIMILAR TO', 'IS [NOT] DISTINCT FROM']); const reservedDataTypePhrases = expandPhrases(['TIMESTAMP WITH TIME ZONE']); From b2080915831c3ba4e0d618ae6d5eb5d008f83518 Mon Sep 17 00:00:00 2001 From: Akinfenwa Obaseyi Date: Sat, 24 Oct 2026 12:50:01 +0100 Subject: [PATCH 3/8] fix: format window frame clauses after ORDER BY --- src/languages/transactsql/transactsql.formatter.ts | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/src/languages/transactsql/transactsql.formatter.ts b/src/languages/transactsql/transactsql.formatter.ts index 01f6806cc7..73f7462582 100644 --- a/src/languages/transactsql/transactsql.formatter.ts +++ b/src/languages/transactsql/transactsql.formatter.ts @@ -32,7 +32,7 @@ const reservedClauses = expandPhrases([ 'UPDATE SET', ]); -const standardOnelineClauses = expandPhrases(['CREATE TABLE']); +const standardOnelineClauses = expandPhrases(['CREATE TABLE', '{ROWS | RANGE} BETWEEN']); const tabularOnelineClauses = expandPhrases([ // - create: @@ -226,10 +226,7 @@ const reservedJoins = expandPhrases([ '{CROSS | OUTER} APPLY', ]); -const reservedKeywordPhrases = expandPhrases([ - 'ON {UPDATE | DELETE} [SET NULL | SET DEFAULT]', - '{ROWS | RANGE} BETWEEN', -]); +const reservedKeywordPhrases = expandPhrases(['ON {UPDATE | DELETE} [SET NULL | SET DEFAULT]']); const reservedDataTypePhrases = expandPhrases([]); From 41bebce67b007634580ce9adfee18fb826d9bebb Mon Sep 17 00:00:00 2001 From: Akinfenwa Obaseyi Date: Sat, 24 Oct 2026 12:50:26 +0100 Subject: [PATCH 4/8] test: cover window frame layout in DuckDB and T-SQL --- test/behavesLikePostgresqlFormatter.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/test/behavesLikePostgresqlFormatter.ts b/test/behavesLikePostgresqlFormatter.ts index 819a71f61e..cf28d4e6e5 100644 --- a/test/behavesLikePostgresqlFormatter.ts +++ b/test/behavesLikePostgresqlFormatter.ts @@ -18,8 +18,11 @@ import supportsNumbers from './features/numbers.js'; /** * Shared tests for PostgreSQL and DuckDB */ -export default function behavesLikePostgresqlFormatter(format: FormatFn) { - behavesLikeSqlFormatter(format); +export default function behavesLikePostgresqlFormatter( + format: FormatFn, + frameOnSeparateLine = false +) { + behavesLikeSqlFormatter(format, frameOnSeparateLine); supportsNumbers(format, { underscore: true }); supportsComments(format, { nestedBlockComments: true }); supportsCommentOn(format); From e014a7a33a45976fffd022e92b14d6a3e67881da Mon Sep 17 00:00:00 2001 From: Akinfenwa Obaseyi Date: Sat, 24 Oct 2026 12:50:40 +0100 Subject: [PATCH 5/8] test: cover window frame layout in DuckDB and T-SQL --- test/behavesLikeSqlFormatter.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/test/behavesLikeSqlFormatter.ts b/test/behavesLikeSqlFormatter.ts index 1b444ca1ac..f39149c251 100644 --- a/test/behavesLikeSqlFormatter.ts +++ b/test/behavesLikeSqlFormatter.ts @@ -21,7 +21,7 @@ import supportsDisableComment from './features/disableComment.js'; /** * Core tests for all SQL formatters */ -export default function behavesLikeSqlFormatter(format: FormatFn) { +export default function behavesLikeSqlFormatter(format: FormatFn, frameOnSeparateLine = false) { supportsDisableComment(format); supportsCase(format); supportsWith(format); @@ -37,7 +37,7 @@ export default function behavesLikeSqlFormatter(format: FormatFn) { supportsNewlineBeforeSemicolon(format); supportsLogicalOperatorNewline(format); supportsParamTypes(format); - supportsWindowFunctions(format); + supportsWindowFunctions(format, frameOnSeparateLine); it('formats SELECT with asterisks', () => { const result = format('SELECT tbl.*, count(*), col1 * col2 FROM tbl;'); From 304229a3cf0177a8edc056cfc94af50828be2bd3 Mon Sep 17 00:00:00 2001 From: Akinfenwa Obaseyi Date: Sat, 24 Oct 2026 12:51:15 +0100 Subject: [PATCH 6/8] test: cover window frame layout in DuckDB and T-SQL --- test/duckdb.test.ts | 45 ++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 44 insertions(+), 1 deletion(-) diff --git a/test/duckdb.test.ts b/test/duckdb.test.ts index 9b806945df..3223fa34d8 100644 --- a/test/duckdb.test.ts +++ b/test/duckdb.test.ts @@ -21,7 +21,50 @@ describe('DuckDBFormatter', () => { const language = 'duckdb'; const format: FormatFn = (query, cfg = {}) => originalFormat(query, { ...cfg, language }); - behavesLikePostgresqlFormatter(format); + behavesLikePostgresqlFormatter(format, true); + + it('places the ROWS window frame after ORDER BY on a new line', () => { + expect( + format( + 'SELECT sum(size) OVER (ORDER BY time_stamp ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW) AS running_size FROM some_events;' + ) + ).toBe(dedent` + SELECT + sum(size) OVER ( + ORDER BY + time_stamp + ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW + ) AS running_size + FROM + some_events; + `); + }); + + it.each(['RANGE', 'GROUPS'])('places the %s window frame after ORDER BY', frame => { + const result = format( + `SELECT sum(size) OVER (ORDER BY time_stamp ${frame} BETWEEN 1 PRECEDING AND 2 FOLLOWING) FROM some_events;` + ); + expect(result).toBe(dedent` + SELECT + sum(size) OVER ( + ORDER BY + time_stamp + ${frame} BETWEEN 1 PRECEDING AND 2 FOLLOWING + ) + FROM + some_events; + `); + expect(format(result)).toBe(result); + }); + + it('keeps window frame bounds together in tabular style', () => { + const query = + 'SELECT sum(size) OVER (ORDER BY time_stamp ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW) FROM some_events;'; + const result = format(query, { indentStyle: 'tabularLeft' }); + expect(result).toMatch(/\n\s*ROWS[ \t]+BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW\n/); + expect(format(result, { indentStyle: 'tabularLeft' })).toBe(result); + }); + supportsCreateView(format, { orReplace: true, ifNotExists: true }); supportsCreateTable(format, { orReplace: true, ifNotExists: true }); supportsDropTable(format, { ifExists: true }); From 71d726739b2abc74d4a35ef320c145535a69f717 Mon Sep 17 00:00:00 2001 From: Akinfenwa Obaseyi Date: Sat, 24 Oct 2026 12:51:29 +0100 Subject: [PATCH 7/8] test: cover window frame layout in DuckDB and T-SQL --- test/features/windowFunctions.ts | 40 +++++++++++++++++++++----------- 1 file changed, 27 insertions(+), 13 deletions(-) diff --git a/test/features/windowFunctions.ts b/test/features/windowFunctions.ts index 1f77badbda..001d9926a5 100644 --- a/test/features/windowFunctions.ts +++ b/test/features/windowFunctions.ts @@ -2,19 +2,32 @@ import dedent from 'dedent-js'; import { FormatFn } from '../../src/sqlFormatter.js'; -export default function supportsWindowFunctions(format: FormatFn) { +export default function supportsWindowFunctions(format: FormatFn, frameOnSeparateLine = false) { it('supports ROWS BETWEEN in window functions', () => { - expect( - format(` - SELECT - RANK() OVER ( - PARTITION BY explosion - ORDER BY day ROWS BETWEEN 6 PRECEDING AND CURRENT ROW - ) AS amount - FROM - tbl - `) - ).toBe(dedent` + const result = format(` + SELECT + RANK() OVER ( + PARTITION BY explosion + ORDER BY day ROWS BETWEEN 6 PRECEDING AND CURRENT ROW + ) AS amount + FROM + tbl + `); + expect(result).toBe( + frameOnSeparateLine + ? dedent` + SELECT + RANK() OVER ( + PARTITION BY + explosion + ORDER BY + day + ROWS BETWEEN 6 PRECEDING AND CURRENT ROW + ) AS amount + FROM + tbl + ` + : dedent` SELECT RANK() OVER ( PARTITION BY @@ -25,6 +38,7 @@ export default function supportsWindowFunctions(format: FormatFn) { ) AS amount FROM tbl - `); + ` + ); }); } From 91dce4d45023fa2c3220da66dfd3a47ce4c4e74a Mon Sep 17 00:00:00 2001 From: Akinfenwa Obaseyi Date: Sat, 24 Oct 2026 12:51:42 +0100 Subject: [PATCH 8/8] test: cover window frame layout in DuckDB and T-SQL --- test/transactsql.test.ts | 45 +++++++++++++++++++++++++++++++++++++++- 1 file changed, 44 insertions(+), 1 deletion(-) diff --git a/test/transactsql.test.ts b/test/transactsql.test.ts index f3041c23fe..9de760e835 100644 --- a/test/transactsql.test.ts +++ b/test/transactsql.test.ts @@ -30,7 +30,7 @@ describe('TransactSqlFormatter', () => { const language = 'transactsql'; const format: FormatFn = (query, cfg = {}) => originalFormat(query, { ...cfg, language }); - behavesLikeSqlFormatter(format); + behavesLikeSqlFormatter(format, true); supportsNumbers(format); supportsComments(format, { nestedBlockComments: true }); supportsCreateView(format, { materialized: true }); @@ -75,6 +75,49 @@ describe('TransactSqlFormatter', () => { supportsSetOperations(format, ['UNION', 'UNION ALL', 'EXCEPT', 'INTERSECT']); supportsParams(format, { named: ['@'], quoted: ['@""', '@[]'] }); supportsWindow(format); + + it('places the ROWS window frame after ORDER BY on a new line', () => { + expect( + format( + 'SELECT sum(size) OVER (ORDER BY time_stamp ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW) AS running_size FROM some_events;' + ) + ).toBe(dedent` + SELECT + sum(size) OVER ( + ORDER BY + time_stamp + ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW + ) AS running_size + FROM + some_events; + `); + }); + + it('places the RANGE window frame after ORDER BY', () => { + const result = format( + 'SELECT sum(size) OVER (ORDER BY time_stamp RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW) FROM some_events;' + ); + expect(result).toBe(dedent` + SELECT + sum(size) OVER ( + ORDER BY + time_stamp + RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW + ) + FROM + some_events; + `); + expect(format(result)).toBe(result); + }); + + it('keeps window frame bounds together in tabular style', () => { + const query = + 'SELECT sum(size) OVER (ORDER BY time_stamp ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW) FROM some_events;'; + const result = format(query, { indentStyle: 'tabularLeft' }); + expect(result).toMatch(/\n\s*ROWS[ \t]+BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW\n/); + expect(format(result, { indentStyle: 'tabularLeft' })).toBe(result); + }); + supportsLimiting(format, { offset: true, fetchFirst: true, fetchNext: true }); supportsDataTypeCase(format);