Skip to content
16 changes: 15 additions & 1 deletion src/formatter/ExpressionFormatter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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();
Expand Down
7 changes: 2 additions & 5 deletions src/languages/duckdb/duckdb.formatter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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([
Expand Down Expand Up @@ -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']);

Expand Down
7 changes: 2 additions & 5 deletions src/languages/transactsql/transactsql.formatter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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([]);

Expand Down
7 changes: 5 additions & 2 deletions test/behavesLikePostgresqlFormatter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
4 changes: 2 additions & 2 deletions test/behavesLikeSqlFormatter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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;');
Expand Down
45 changes: 44 additions & 1 deletion test/duckdb.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 });
Expand Down
40 changes: 27 additions & 13 deletions test/features/windowFunctions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -25,6 +38,7 @@ export default function supportsWindowFunctions(format: FormatFn) {
) AS amount
FROM
tbl
`);
`
);
});
}
45 changes: 44 additions & 1 deletion test/transactsql.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 });
Expand Down Expand Up @@ -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);

Expand Down