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),
});
7 changes: 6 additions & 1 deletion src/formatter/ExpressionFormatter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,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,
// where an operator and a following sign densed together re-parse as one operator.
operatorsCombine?: boolean;
}

// Contains the same data as DialectFormatOptions,
Expand All @@ -66,6 +69,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 Expand Up @@ -509,7 +514,7 @@ export default class ExpressionFormatter {
cfg: this.cfg,
dialectCfg: this.dialectCfg,
params: this.params,
layout: new InlineLayout(this.cfg.expressionWidth),
layout: new InlineLayout(this.cfg.expressionWidth, this.dialectCfg.operatorsCombine),
inline: true,
enclosingParenthesis,
}).format(nodes);
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
4 changes: 2 additions & 2 deletions src/formatter/InlineLayout.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,8 @@ export default class InlineLayout extends Layout {
// but only when there actually is a space to remove.
private trailingSpace = false;

constructor(private expressionWidth: number) {
super(new Indentation('')); // no indentation in inline layout
constructor(private expressionWidth: number, operatorsCombine: boolean) {
super(new Indentation(''), operatorsCombine); // no indentation in inline layout
}

public add(...items: (WS | string)[]) {
Expand Down
36 changes: 29 additions & 7 deletions src/formatter/Layout.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,15 @@ import { last } from '../utils.js';

import Indentation from './Indentation.js';

const STARTS_WITH_SIGN = /^[-+]/u;

// An operator that would take a following sign into its own name.
// PostgreSQL lexes a run of operator characters as a single operator, and such a name
// may only end in "+" or "-" when it also contains one of ~!@#%^&|`? -- so "@-" is an
// operator name, while "*-" is not.
// https://www.postgresql.org/docs/current/sql-syntax-lexical.html#SQL-SYNTAX-OPERATORS
const ENDS_WITH_SIGN_ABSORBING_OPERATOR = /[~!@#%^&|`?][-+*/<>=~!@#%^&|`?]*$/u;

/** Whitespace modifiers to be used with add() method */
export enum WS {
SPACE, // Adds single space
Expand All @@ -25,7 +34,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: boolean) {}

/**
* Appends token strings and whitespace modifications to SQL string.
Expand Down Expand Up @@ -57,20 +66,33 @@ 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 right after the preceding item without the two re-lexing as one. */
private isItemSafeToAppend(item: string): boolean {
const lastItem = last(this.items);
return typeof lastItem === 'string' && lastItem.endsWith(suffix);
if (typeof lastItem !== 'string') {
return true;
}
// "a - -b" densed to "a--b" would re-parse as a line comment.
if (lastItem.endsWith('-') && item.startsWith('-')) {
return false;
}
// "5 % -2" densed to "5%-2" would re-parse as the single operator "%-".
if (
this.operatorsCombine &&
ENDS_WITH_SIGN_ABSORBING_OPERATOR.test(lastItem) &&
STARTS_WITH_SIGN.test(item)
) {
return false;
}
return true;
}
Comment on lines +78 to 96

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 have trouble understanding the logic in this function. The function has a long comment, which might be a sign that a lot of explanation is needed to explain what this code does.

Some observations:

  • it starts with check item.startsWith('+') that wasn't part of the original code and gets applied to all dialects. When I comment this out, no tests break.
  • the logic dealing with operatorsCombine === true case is not clearly separated from the logic for the base case.
  • There are lots of conditional statements, and the later ones depend on the earlier ones. So to understand what happens on the last line, one really needs to grasp what happened before on the preceding lines.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Rewritten as three independent checks, with each hazard explained next to the regex that describes it. No condition depends on the one before it, and operatorsCombine is its own branch.

On item.startsWith('+'): it only ever fired under operatorsCombine, but you had to read down to the last line to see that, which is a fair complaint. It is reachable rather than dead, though: %+ is as valid an operator name as %-, so 5 % +2 densed to 5%+2 re-parses as the operator %+. It was simply untested. It is now -- narrowing the regex to /^[-]/u fails 15 tests.


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 @@ -408,5 +408,6 @@ export const postgresql: DialectOptions = {
alwaysDenseOperators: ['::', ':'],
onelineClauses: [...standardOnelineClauses, ...tabularOnelineClauses],
tabularOnelineClauses,
operatorsCombine: true,
},
};
14 changes: 14 additions & 0 deletions test/features/operators.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,10 @@ export const standardOperators = ['+', '-', '*', '/', '>', '<', '=', '<>', '<=',
type OperatorsConfig = {
logicalOperators?: string[];
any?: boolean;
// Set in dialects where an operator can take a following sign into its own name,
// so that "1 % -2" must not be densed to "1%-2".
// Such dialects test the spacing of each of their operators separately.
operatorsAbsorbSign?: boolean;
};

export default function supportsOperators(
Expand Down Expand Up @@ -44,6 +48,16 @@ export default function supportsOperators(
});
}

if (!cfg.operatorsAbsorbSign) {
operators
.filter(op => !op.endsWith('-'))
.forEach(op => {
it(`denses a sign after ${op} operator in dense mode`, () => {
expect(format(`foo ${op} -2`, { denseOperators: true })).toBe(`foo${op}-2`);
});
});
}

(cfg.logicalOperators || ['AND', 'OR']).forEach(op => {
it(`supports ${op} operator`, () => {
const result = format(`SELECT true ${op} false AS foo;`);
Expand Down
24 changes: 23 additions & 1 deletion test/postgresql.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -139,7 +139,7 @@ describe('PostgreSqlFormatter', () => {
'&&&',
'|=|',
],
{ any: true }
{ any: true, operatorsAbsorbSign: true }
);
supportsIsDistinctFrom(format);
supportsJoin(format);
Expand Down Expand Up @@ -249,6 +249,28 @@ describe('PostgreSqlFormatter', () => {
`);
});

// An operator containing one of the ten characters ~!@#%^&|`? may end in "-" or "+",
// so densing a sign onto it would extend the operator name instead. Nine of the ten are
// covered here, together with the operators that end in "-" themselves. The tenth, the
// backtick, is never yielded as an operator by the lexer and can't be reached from here.
it.each(['~', '!~', '@', '@>', '#', '#>>', '%', '^', '&', '|', '||', '?', '@-@', '?-', '-|-'])(
'keeps a space after %s operator and before a sign in dense mode',
op => {
expect(format(`foo ${op} -1`, { denseOperators: true })).toBe(`foo${op} -1`);
expect(format(`foo ${op} +1`, { denseOperators: true })).toBe(`foo${op} +1`);
}
);

// The remaining operators are built only from -+*/<>= and can't take a sign into their
// name, so a sign denses onto them just like in any other dialect.
it.each(['*', '/', '<', '>=', '<<', '->', '->>', '<>', '<->'])(
'denses a sign after %s operator in dense mode',
op => {
expect(format(`foo ${op} -1`, { denseOperators: true })).toBe(`foo${op}-1`);
expect(format(`foo ${op} +1`, { denseOperators: true })).toBe(`foo${op}+1`);
}
);

// Issue #813
it('supports OR REPLACE in CREATE FUNCTION', () => {
expect(format(`CREATE OR REPLACE FUNCTION foo ();`)).toBe(dedent`
Expand Down
3 changes: 3 additions & 0 deletions test/redshift.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,9 @@ describe('RedshiftFormatter', () => {
[...standardOperators, '^', '%', '@', '|/', '||/', '&', '|', '~', '<<', '>>', '||'],
{
any: true,
// Redshift inherits the operator syntax of PostgreSQL,
// but the formatter doesn't take that into account yet.
operatorsAbsorbSign: true,
}
);
supportsJoin(format);
Expand Down
2 changes: 1 addition & 1 deletion test/unit/Layout.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ describe('Layout', () => {
indentation.increaseTopLevel();
indentation.increaseTopLevel();

const layout = new Layout(indentation);
const layout = new Layout(indentation, false);
layout.add(...items);
return layout.toString();
}
Expand Down