Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions src/dialect.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,4 +47,5 @@ const processDialectFormatOptions = ({
(options.tabularOnelineClauses ?? options.onelineClauses).map(name => [name, true])
),
identifierDashes: Boolean(tokenizerOptions.identChars?.dashes),
operatorsCombine: Boolean(options.operatorsCombine),
});
5 changes: 5 additions & 0 deletions src/formatter/ExpressionFormatter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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 */
Expand Down
5 changes: 4 additions & 1 deletion src/formatter/Formatter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
36 changes: 29 additions & 7 deletions src/formatter/Layout.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -57,20 +57,42 @@ 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('-')) {
if (!this.isItemSafeToAppend(item)) {
this.items.push(WS.SPACE);
}
this.items.push(item);
}
}
}

private lastItemEndsWith(suffix: string): boolean {
/**
* Whether `item` can be written directly after the preceding item without the
* two re-lexing as a single token.
*
* Only an item starting with "-" or "+" is at risk, and only when the preceding
* item ends in operator characters. "-" after a trailing "-" forms "--", a line
* comment that swallows the rest of the line, in every dialect. In a dialect that
* lexes a run of operator characters as one operator, a sign after an operator
* containing any of ~!@#%^&|`? merges too: "5 % -2" written densely as "5%-2"
* re-parses as the operator "%-".
*/
private isItemSafeToAppend(item: string): boolean {
if (!item.startsWith('-') && !item.startsWith('+')) {
return true;
}
const lastItem = last(this.items);
return typeof lastItem === 'string' && lastItem.endsWith(suffix);
if (typeof lastItem !== 'string') {
return true;
}
// The operator characters the new item would be written against.
const precedingOperatorChars = /[-+*/<>=~!@#%^&|`?]+$/u.exec(lastItem)?.[0];
if (!precedingOperatorChars) {
return true;
}
if (item.startsWith('-') && precedingOperatorChars.endsWith('-')) {
return false;
}
return !(this.operatorsCombine && /[~!@#%^&|`?]/u.test(precedingOperatorChars));
}

private trimHorizontalWhitespace() {
Expand Down
1 change: 1 addition & 0 deletions src/languages/postgresql/postgresql.formatter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -406,5 +406,6 @@ export const postgresql: DialectOptions = {
alwaysDenseOperators: ['::', ':'],
onelineClauses: [...standardOnelineClauses, ...tabularOnelineClauses],
tabularOnelineClauses,
operatorsCombine: true,
},
};
1 change: 1 addition & 0 deletions src/languages/redshift/redshift.formatter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -182,5 +182,6 @@ export const redshift: DialectOptions = {
alwaysDenseOperators: ['::'],
onelineClauses: [...standardOnelineClauses, ...tabularOnelineClauses],
tabularOnelineClauses,
operatorsCombine: true,
},
};
8 changes: 8 additions & 0 deletions test/mysql.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
`);
});
});
33 changes: 33 additions & 0 deletions test/postgresql.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -234,6 +234,39 @@ describe('PostgreSqlFormatter', () => {
`);
});

// Every character that lets PostgreSQL lex a run as a single operator can swallow a
// following sign, so each one is checked rather than a sample. The tenth such
// character, a backtick, is legal in CREATE OPERATOR but the lexer never yields it
// as an operator, so it is not reachable from here.
it.each(['~', '!~', '@>', '#', '%', '^', '&', '|', '?'])(
'keeps a space between the operator %s and a following sign with denseOperators',
operator => {
expect(format(`SELECT a ${operator} -1`, { denseOperators: true })).toBe(dedent`
SELECT
a${operator} -1
`);
}
);

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
Comment on lines +251 to +266

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I counted 17 special characters in the regular expression. In this test we're only checking a few of them.

`);
});

// Issue #813
it('supports OR REPLACE in CREATE FUNCTION', () => {
expect(format(`CREATE OR REPLACE FUNCTION foo ();`)).toBe(dedent`
Expand Down