Repository: sql-formatter-org/sql-formatter
Pull request #259 (2022-06-28 13:46:49)

8.0: Detect function calls inside Parser
The function calls are still detected based on whether there's empty space between an identifier and `(`. That's far from correct, but that's how the current code works.

Also dropping some incorrect handing of `[..]`, `{..}`, `$`-prefix and backquotes syntax from operator formatting code. All these are handled elsewhere:

- `{..}` and `[..]` get detected as Parenthesis blocks.
- `$`-prefixes are part of identifiers in dialects that support them.
- backquoted identifiers get detected as identifiers in dialects that support them.


Patch:
diff --git a/src/core/ExpressionFormatter.ts b/src/core/ExpressionFormatter.ts
index 063c78e9..e52761de 100644
--- a/src/core/ExpressionFormatter.ts
+++ b/src/core/ExpressionFormatter.ts
@@ -4,12 +4,12 @@ import { equalizeWhitespace } from 'src/utils';
 import Indentation from './Indentation';
 import InlineBlock from './InlineBlock';
 import Params from './Params';
-import { isReserved, type Token, TokenType, EOF_TOKEN } from './token';
+import { isReserved, type Token, TokenType } from './token';
 import {
   AllColumnsAsterisk,
   AstNode,
   BetweenPredicate,
-  isTokenNode,
+  FunctionCall,
   LimitClause,
   Parenthesis,
 } from './ast';
@@ -43,6 +43,9 @@ export default class ExpressionFormatter {
     for (this.index = 0; this.index < this.nodes.length; this.index++) {
       const node = this.nodes[this.index];
       switch (node.type) {
+        case 'function_call':
+          this.formatFunctionCall(node);
+          break;
         case 'parenthesis':
           this.formatParenthesis(node);
           break;
@@ -63,6 +66,11 @@ export default class ExpressionFormatter {
     return this.query.toString();
   }
 
+  private formatFunctionCall(node: FunctionCall) {
+    this.query.add(this.show(node.nameToken));
+    this.formatParenthesis(node.parenthesis);
+  }
+
   private formatParenthesis(node: Parenthesis) {
     const inline = this.inlineBlock.isInlineBlock(node);
 
@@ -73,14 +81,8 @@ export default class ExpressionFormatter {
       .trimEnd();
 
     if (inline) {
-      if (!this.isSpaceBeforeParenthesis(node)) {
-        this.query.add(WS.NO_SPACE);
-      }
       this.query.add(node.openParen, formattedSql, node.closeParen, WS.SPACE);
     } else {
-      if (!this.isSpaceBeforeParenthesis(node)) {
-        this.query.add(WS.NO_SPACE);
-      }
       this.query.add(node.openParen);
 
       formattedSql.split(/\n/).forEach(line => {
@@ -91,16 +93,6 @@ export default class ExpressionFormatter {
     }
   }
 
-  // We add space before parenthesis when:
-  // - there's space in original SQL or
-  // - there's operator or line comment before the parenthesis
-  private isSpaceBeforeParenthesis(node: Parenthesis): boolean {
-    return (
-      node.hasWhitespaceBefore ||
-      [TokenType.LINE_COMMENT, TokenType.OPERATOR].includes(this.tokenLookBehind().type)
-    );
-  }
-
   private formatBetweenPredicate(node: BetweenPredicate) {
     this.query.add(
       this.show(node.betweenToken),
@@ -251,13 +243,10 @@ export default class ExpressionFormatter {
     } else if (token.value === ';') {
       this.formatQuerySeparator(token);
       return;
-    } else if (['$', '['].includes(token.value)) {
-      this.query.add(this.show(token));
-      return;
-    } else if ([':', ']'].includes(token.value)) {
+    } else if (token.value === ':') {
       this.query.add(WS.NO_SPACE, this.show(token), WS.SPACE);
       return;
-    } else if (['.', '{', '}', '`'].includes(token.value)) {
+    } else if (token.value === '.') {
       this.query.add(WS.NO_SPACE, this.show(token));
       return;
     }
@@ -341,19 +330,4 @@ export default class ExpressionFormatter {
       return token.value;
     }
   }
-
-  /** Fetches nth previous token from the token stream */
-  private tokenLookBehind(n = 1): Token {
-    return this.tokenLookAhead(-n);
-  }
-
-  /** Fetches nth next token from the token stream */
-  private tokenLookAhead(n = 1): Token {
-    const node: AstNode | undefined = this.nodes[this.index + n];
-    if (node && isTokenNode(node)) {
-      return node.token;
-    } else {
-      return EOF_TOKEN;
-    }
-  }
 }
diff --git a/src/core/Parser.ts b/src/core/Parser.ts
index f823cfa4..409cee00 100644
--- a/src/core/Parser.ts
+++ b/src/core/Parser.ts
@@ -3,6 +3,7 @@ import {
   AllColumnsAsterisk,
   AstNode,
   BetweenPredicate,
+  FunctionCall,
   LimitClause,
   Parenthesis,
   Statement,
@@ -48,6 +49,7 @@ export default class Parser {
 
   private expression(): AstNode {
     return (
+      this.functionCall() ||
       this.parenthesis() ||
       this.betweenPredicate() ||
       this.limitClause() ||
@@ -56,12 +58,26 @@ export default class Parser {
     );
   }
 
+  private functionCall(): FunctionCall | undefined {
+    if (
+      (this.look().type === TokenType.RESERVED_KEYWORD || this.look().type === TokenType.IDENT) &&
+      this.look(1).value === '(' &&
+      !this.look(1).whitespaceBefore
+    ) {
+      return {
+        type: 'function_call',
+        nameToken: this.next(),
+        parenthesis: this.parenthesis() as Parenthesis,
+      };
+    }
+    return undefined;
+  }
+
   private parenthesis(): Parenthesis | undefined {
     if (this.look().type === TokenType.OPEN_PAREN) {
       const children: AstNode[] = [];
       const token = this.next();
       const openParen = token.value;
-      const hasWhitespaceBefore = Boolean(token.whitespaceBefore);
       let closeParen = '';
       while (this.look().type !== TokenType.CLOSE_PAREN && this.look().type !== TokenType.EOF) {
         children.push(this.expression());
@@ -69,7 +85,7 @@ export default class Parser {
       if (this.look().type === TokenType.CLOSE_PAREN) {
         closeParen = this.next().value;
       }
-      return { type: 'parenthesis', children, openParen, closeParen, hasWhitespaceBefore };
+      return { type: 'parenthesis', children, openParen, closeParen };
     }
     return undefined;
   }
diff --git a/src/core/ast.ts b/src/core/ast.ts
index b9b93bea..021b0a1b 100644
--- a/src/core/ast.ts
+++ b/src/core/ast.ts
@@ -11,12 +11,17 @@ export type TokenNode = {
   token: Token;
 };
 
+export type FunctionCall = {
+  type: 'function_call';
+  nameToken: Token;
+  parenthesis: Parenthesis;
+};
+
 export type Parenthesis = {
   type: 'parenthesis';
   children: AstNode[];
   openParen: string;
   closeParen: string;
-  hasWhitespaceBefore: boolean;
 };
 
 // BETWEEN <expr1> AND <expr2>
@@ -42,6 +47,12 @@ export type AllColumnsAsterisk = {
   type: 'all_columns_asterisk';
 };
 
-export type AstNode = Parenthesis | BetweenPredicate | LimitClause | AllColumnsAsterisk | TokenNode;
+export type AstNode =
+  | FunctionCall
+  | Parenthesis
+  | BetweenPredicate
+  | LimitClause
+  | AllColumnsAsterisk
+  | TokenNode;
 
 export const isTokenNode = (node: AstNode): node is TokenNode => node.type === 'token';


Test patch:
diff --git a/test/behavesLikeSqlFormatter.ts b/test/behavesLikeSqlFormatter.ts
index 97cdf449..9b19ac51 100644
--- a/test/behavesLikeSqlFormatter.ts
+++ b/test/behavesLikeSqlFormatter.ts
@@ -49,11 +49,11 @@ export default function behavesLikeSqlFormatter(format: FormatFn) {
 
   it('formats complex SELECT', () => {
     const result = format(
-      "SELECT DISTINCT [name], ROUND(age/7) field1, 18 + 20 AS field2, 'some string' FROM foo;"
+      "SELECT DISTINCT name, ROUND(age/7) field1, 18 + 20 AS field2, 'some string' FROM foo;"
     );
     expect(result).toBe(dedent`
       SELECT
-        DISTINCT [name],
+        DISTINCT name,
         ROUND(age / 7) field1,
         18 + 20 AS field2,
         'some string'
diff --git a/test/features/operators.ts b/test/features/operators.ts
index 4199b58e..a693571e 100644
--- a/test/features/operators.ts
+++ b/test/features/operators.ts
@@ -30,16 +30,6 @@ export default function supportsOperators(
     });
   });
 
-  it('supports braces', () => {
-    const result = format(`SELECT $\{a} FROM $\{b};`);
-    expect(result).toBe(dedent`
-      SELECT
-        $\{a}
-      FROM
-        $\{b};
-    `);
-  });
-
   it('supports set operators', () => {
     expect(format('foo ALL bar')).toBe('foo ALL bar');
     expect(format('foo = ANY (1, 2, 3)')).toBe('foo = ANY (1, 2, 3)');
diff --git a/test/plsql.test.ts b/test/plsql.test.ts
index d335f0e9..551ec886 100644
--- a/test/plsql.test.ts
+++ b/test/plsql.test.ts
@@ -70,7 +70,7 @@ describe('PlSqlFormatter', () => {
   it('does not support #, $ in named parameters', () => {
     expect(format('SELECT :col$foo')).toBe(dedent`
       SELECT
-        :col $foo
+        :col $ foo
     `);
 
     expect(() => format('SELECT :col#foo')).toThrowError('Parse error: Unexpected "#foo"');
diff --git a/test/sql.test.ts b/test/sql.test.ts
index 94b30863..ab591e07 100644
--- a/test/sql.test.ts
+++ b/test/sql.test.ts
@@ -56,7 +56,7 @@ describe('SqlFormatter', () => {
         @ name,
       : bar
       FROM
-      {foo};
+        { foo };
     `);
   });
 });
diff --git a/test/unit/Parser.test.ts b/test/unit/Parser.test.ts
index 42b30c0a..9be3338e 100644
--- a/test/unit/Parser.test.ts
+++ b/test/unit/Parser.test.ts
@@ -8,7 +8,7 @@ describe('Parser', () => {
       reservedDependentClauses: ['WHEN', 'ELSE'],
       reservedBinaryCommands: ['UNION', 'JOIN'],
       reservedJoinConditions: ['ON', 'USING'],
-      reservedKeywords: ['BETWEEN', 'LIKE'],
+      reservedKeywords: ['BETWEEN', 'LIKE', 'SQRT'],
       stringTypes: ["''"],
       identTypes: ['""'],
     }).tokenize(sql);
@@ -63,8 +63,8 @@ describe('Parser', () => {
     `);
   });
 
-  it('parses parenthesized expressions', () => {
-    expect(parse('SELECT abs(birth_year - year(CURRENT_DATE))')).toMatchInlineSnapshot(`
+  it('parses function call', () => {
+    expect(parse('SELECT SQRT(2)')).toMatchInlineSnapshot(`
       Array [
         Object {
           "children": Array [
@@ -78,12 +78,49 @@ describe('Parser', () => {
               "type": "token",
             },
             Object {
-              "token": Object {
-                "text": "abs",
-                "type": "IDENT",
-                "value": "abs",
+              "nameToken": Object {
+                "text": "SQRT",
+                "type": "RESERVED_KEYWORD",
+                "value": "SQRT",
                 "whitespaceBefore": " ",
               },
+              "parenthesis": Object {
+                "children": Array [
+                  Object {
+                    "token": Object {
+                      "text": "2",
+                      "type": "NUMBER",
+                      "value": "2",
+                      "whitespaceBefore": "",
+                    },
+                    "type": "token",
+                  },
+                ],
+                "closeParen": ")",
+                "openParen": "(",
+                "type": "parenthesis",
+              },
+              "type": "function_call",
+            },
+          ],
+          "type": "statement",
+        },
+      ]
+    `);
+  });
+
+  it('parses parenthesized expressions', () => {
+    expect(parse('SELECT (birth_year - (CURRENT_DATE + 1))')).toMatchInlineSnapshot(`
+      Array [
+        Object {
+          "children": Array [
+            Object {
+              "token": Object {
+                "text": "SELECT",
+                "type": "RESERVED_COMMAND",
+                "value": "SELECT",
+                "whitespaceBefore": "",
+              },
               "type": "token",
             },
             Object {
@@ -106,15 +143,6 @@ describe('Parser', () => {
                   },
                   "type": "token",
                 },
-                Object {
-                  "token": Object {
-                    "text": "year",
-                    "type": "IDENT",
-                    "value": "year",
-                    "whitespaceBefore": " ",
-                  },
-                  "type": "token",
-                },
                 Object {
                   "children": Array [
                     Object {
@@ -126,15 +154,31 @@ describe('Parser', () => {
                       },
                       "type": "token",
                     },
+                    Object {
+                      "token": Object {
+                        "text": "+",
+                        "type": "OPERATOR",
+                        "value": "+",
+                        "whitespaceBefore": " ",
+                      },
+                      "type": "token",
+                    },
+                    Object {
+                      "token": Object {
+                        "text": "1",
+                        "type": "NUMBER",
+                        "value": "1",
+                        "whitespaceBefore": " ",
+                      },
+                      "type": "token",
+                    },
                   ],
                   "closeParen": ")",
-                  "hasWhitespaceBefore": false,
                   "openParen": "(",
                   "type": "parenthesis",
                 },
               ],
               "closeParen": ")",
-              "hasWhitespaceBefore": false,
               "openParen": "(",
               "type": "parenthesis",
             },