From 36c7e58875c4f160088be94ec184c1755658a169 Mon Sep 17 00:00:00 2001 From: Yarchik Date: Tue, 4 Aug 2026 01:46:28 +0100 Subject: [PATCH 1/2] fix: keep a space between an operator and a following sign with denseOperators A binary operator immediately followed by a unary + or - was glued to it with denseOperators, so 'SELECT 5 % -2' became '5%-2'. PostgreSQL lexes a run of operator characters greedily, so an operator containing one of ~!@#%^&|`? keeps a trailing sign: '%' and '-' merge into a single '%-' operator (which does not exist), '@>' and '-' into '@>-', and the jsonb '?' and '-' into '?-'. The query then errors or changes meaning. Generalize the existing '--' line-comment guard to also keep a space in these cases. --- src/formatter/Layout.ts | 25 +++++++++++++++++++++---- test/postgresql.test.ts | 19 +++++++++++++++++++ 2 files changed, 40 insertions(+), 4 deletions(-) diff --git a/src/formatter/Layout.ts b/src/formatter/Layout.ts index 39fd4071b7..067224edf2 100644 --- a/src/formatter/Layout.ts +++ b/src/formatter/Layout.ts @@ -57,10 +57,12 @@ export default class Layout { this.items.push(WS.SINGLE_INDENT); break; default: - // Don't glue a layout item starting with "-" directly onto one ending with - // "-": that forms "--", which re-parses as a line comment and - // swallows the rest of the line (e.g. densing "a - -b" into "a--b"). - if (item.startsWith('-') && this.lastItemEndsWith('-')) { + // Don't glue an item starting with "-"/"+" onto a preceding operator when + // the two would re-lex as one token: "-" onto "-" forms "--" (a line + // comment that swallows the rest of the line), and a sign onto an operator + // containing ~!@#%^&|`? forms a merged operator like "%-" or "@>-" that parses + // differently (e.g. densing "5 % -2" into "5%-2"). + if (this.wouldMergeIntoOperator(item)) { this.items.push(WS.SPACE); } this.items.push(item); @@ -73,6 +75,21 @@ export default class Layout { return typeof lastItem === 'string' && lastItem.endsWith(suffix); } + private wouldMergeIntoOperator(item: string): boolean { + if (!item.startsWith('-') && !item.startsWith('+')) { + return false; + } + const lastItem = last(this.items); + if (typeof lastItem !== 'string') { + return false; + } + const run = /[-+*/<>=~!@#%^&|`?]+$/u.exec(lastItem)?.[0]; + if (!run) { + return false; + } + return (item.startsWith('-') && run.endsWith('-')) || /[~!@#%^&|`?]/u.test(run); + } + private trimHorizontalWhitespace() { while (isHorizontalWhitespace(last(this.items))) { this.items.pop(); diff --git a/test/postgresql.test.ts b/test/postgresql.test.ts index 4542d364a2..d42206358f 100644 --- a/test/postgresql.test.ts +++ b/test/postgresql.test.ts @@ -234,6 +234,25 @@ describe('PostgreSqlFormatter', () => { `); }); + it('keeps a space between an operator and a following sign with denseOperators', () => { + expect(format('SELECT 5 % -2, 2 ^ -2, 8 # -1', { denseOperators: true })).toBe(dedent` + SELECT + 5% -2, + 2^ -2, + 8# -1 + `); + expect(format(`SELECT '[1,2]'::jsonb @> -1`, { denseOperators: true })).toBe(dedent` + SELECT + '[1,2]'::jsonb@> -1 + `); + expect(format(`SELECT data ? -1 FROM t`, { denseOperators: true })).toBe(dedent` + SELECT + data? -1 + FROM + t + `); + }); + // Issue #813 it('supports OR REPLACE in CREATE FUNCTION', () => { expect(format(`CREATE OR REPLACE FUNCTION foo ();`)).toBe(dedent` From d6926ffd951a8be1bdfb3d1769727fb3e13dfa5a Mon Sep 17 00:00:00 2001 From: Yarchik Date: Fri, 7 Aug 2026 11:25:12 +0100 Subject: [PATCH 2/2] fix: scope the operator-sign spacing to dialects that combine operators The guard that keeps a space between an operator and a following +/- sign only prevents a real bug where the target dialect lexes a run of operator characters as a single operator, so 5 % -2 densed to 5%-2 re-parses as the operator %-. That is PostgreSQL and Redshift; MySQL, standard SQL and the rest have fixed operator sets and re-parse 5%-2 as 5 % -2, so the extra space is not needed. Gate the operator-run branch behind a new operatorsCombine dialect option (true for postgresql/redshift). The -- line-comment guard is unchanged and stays universal, so a - -b keeps its space in every dialect. --- src/dialect.ts | 1 + src/formatter/ExpressionFormatter.ts | 5 +++++ src/formatter/Formatter.ts | 5 ++++- src/formatter/Layout.ts | 16 ++++++++++------ src/languages/postgresql/postgresql.formatter.ts | 1 + src/languages/redshift/redshift.formatter.ts | 1 + test/mysql.test.ts | 8 ++++++++ 7 files changed, 30 insertions(+), 7 deletions(-) diff --git a/src/dialect.ts b/src/dialect.ts index 5a4b40ec77..0c04d7598b 100644 --- a/src/dialect.ts +++ b/src/dialect.ts @@ -47,4 +47,5 @@ const processDialectFormatOptions = ({ (options.tabularOnelineClauses ?? options.onelineClauses).map(name => [name, true]) ), identifierDashes: Boolean(tokenizerOptions.identChars?.dashes), + operatorsCombine: Boolean(options.operatorsCombine), }); diff --git a/src/formatter/ExpressionFormatter.ts b/src/formatter/ExpressionFormatter.ts index 98d00d0b0e..5005dec6a8 100644 --- a/src/formatter/ExpressionFormatter.ts +++ b/src/formatter/ExpressionFormatter.ts @@ -52,6 +52,9 @@ export interface DialectFormatOptions { onelineClauses: string[]; // List of clauses that should be formatted on a single line in tabular style tabularOnelineClauses?: string[]; + // True in dialects that lex a run of operator characters as a single operator + // (PostgreSQL, Redshift), where two operators densed together re-parse as one. + operatorsCombine?: boolean; } // Contains the same data as DialectFormatOptions, @@ -64,6 +67,8 @@ export interface ProcessedDialectFormatOptions { // In such dialects the "-" operator must keep its surrounding spaces, // otherwise "a - b" densed to "a-b" would re-parse as a single identifier. identifierDashes: boolean; + // See DialectFormatOptions.operatorsCombine. + operatorsCombine: boolean; } /** Formats a generic SQL expression */ diff --git a/src/formatter/Formatter.ts b/src/formatter/Formatter.ts index 8f10f87791..48a723458e 100644 --- a/src/formatter/Formatter.ts +++ b/src/formatter/Formatter.ts @@ -48,7 +48,10 @@ export default class Formatter { cfg: this.cfg, dialectCfg: this.dialect.formatOptions, params: this.params, - layout: new Layout(new Indentation(indentString(this.cfg))), + layout: new Layout( + new Indentation(indentString(this.cfg)), + this.dialect.formatOptions.operatorsCombine + ), }).format(statement.children); if (!statement.hasSemicolon) { diff --git a/src/formatter/Layout.ts b/src/formatter/Layout.ts index 067224edf2..55bb27e044 100644 --- a/src/formatter/Layout.ts +++ b/src/formatter/Layout.ts @@ -25,7 +25,7 @@ export type LayoutItem = WS.SPACE | WS.SINGLE_INDENT | WS.NEWLINE | WS.MANDATORY export default class Layout { private items: LayoutItem[] = []; - constructor(public indentation: Indentation) {} + constructor(public indentation: Indentation, private operatorsCombine = false) {} /** * Appends token strings and whitespace modifications to SQL string. @@ -58,10 +58,11 @@ export default class Layout { break; default: // Don't glue an item starting with "-"/"+" onto a preceding operator when - // the two would re-lex as one token: "-" onto "-" forms "--" (a line - // comment that swallows the rest of the line), and a sign onto an operator - // containing ~!@#%^&|`? forms a merged operator like "%-" or "@>-" that parses - // differently (e.g. densing "5 % -2" into "5%-2"). + // the two would re-lex as one token. "-" onto "-" forms "--" (a line + // comment that swallows the rest of the line) in every dialect. In dialects + // that lex a run of operator characters as a single operator (PostgreSQL, + // Redshift), a sign onto an operator containing ~!@#%^&|`? also merges + // (e.g. densing "5 % -2" into "5%-2", which re-parses as the operator "%-"). if (this.wouldMergeIntoOperator(item)) { this.items.push(WS.SPACE); } @@ -87,7 +88,10 @@ export default class Layout { if (!run) { return false; } - return (item.startsWith('-') && run.endsWith('-')) || /[~!@#%^&|`?]/u.test(run); + if (item.startsWith('-') && run.endsWith('-')) { + return true; + } + return this.operatorsCombine && /[~!@#%^&|`?]/u.test(run); } private trimHorizontalWhitespace() { diff --git a/src/languages/postgresql/postgresql.formatter.ts b/src/languages/postgresql/postgresql.formatter.ts index 08697d088e..5eae3858cf 100644 --- a/src/languages/postgresql/postgresql.formatter.ts +++ b/src/languages/postgresql/postgresql.formatter.ts @@ -406,5 +406,6 @@ export const postgresql: DialectOptions = { alwaysDenseOperators: ['::', ':'], onelineClauses: [...standardOnelineClauses, ...tabularOnelineClauses], tabularOnelineClauses, + operatorsCombine: true, }, }; diff --git a/src/languages/redshift/redshift.formatter.ts b/src/languages/redshift/redshift.formatter.ts index ef4a8e2f9b..619a507cd6 100644 --- a/src/languages/redshift/redshift.formatter.ts +++ b/src/languages/redshift/redshift.formatter.ts @@ -182,5 +182,6 @@ export const redshift: DialectOptions = { alwaysDenseOperators: ['::'], onelineClauses: [...standardOnelineClauses, ...tabularOnelineClauses], tabularOnelineClauses, + operatorsCombine: true, }, }; diff --git a/test/mysql.test.ts b/test/mysql.test.ts index e6bcd37af9..c626de924e 100644 --- a/test/mysql.test.ts +++ b/test/mysql.test.ts @@ -114,4 +114,12 @@ describe('MySqlFormatter', () => { DROP DEFAULT; `); }); + + it('does not space a sign after an operator in dense mode', () => { + expect(format('SELECT 5 % -2, 5 & -2', { denseOperators: true })).toBe(dedent` + SELECT + 5%-2, + 5&-2 + `); + }); });