Repository: paustint/soql-parser-js (ts)

Issue:
Add code formatting
<!--
  Please include the following classification.
  If your classification is not added, you can modify the list below
-->
- [ ] Feature
- [ ] Bug Request

<!--If this is a bug, please include the steps to reproduce the bug-->
## Description
For `composedQuery` there should be an option to format the query
1. basic formatting (e.x. subquery always on new line, clauses always on new lines)
1. options - wrap fields after certain line length

Pull request:
bugfixes for code formatter
added missing files
added subquery indentation for WHERE clause subqueries
resolves #36

Patch:
diff --git a/docs/package-lock.json b/docs/package-lock.json
index 80da8fd..5e8e9ca 100644
--- a/docs/package-lock.json
+++ b/docs/package-lock.json
@@ -9572,9 +9572,9 @@
       }
     },
     "soql-parser-js": {
-      "version": "0.3.3",
-      "resolved": "https://registry.npmjs.org/soql-parser-js/-/soql-parser-js-0.3.3.tgz",
-      "integrity": "sha512-yeC3xP/evGx/8np8tNPWBDVTQqikaOw6pBIx7nsqC7enNGCHx8hvaHYl7ajdFdU7bXmqo2YUYPM9/oOIvYH75g==",
+      "version": "0.4.0",
+      "resolved": "https://registry.npmjs.org/soql-parser-js/-/soql-parser-js-0.4.0.tgz",
+      "integrity": "sha512-QORHmiTNr9n44bXP+7+joMjR+AiXMFLXmtYWnHUzp8/T7H9WNoZHQp7ImwxvHiL8dgF5FGir65O5x5j5xvJgog==",
       "requires": {
         "antlr4ts": "0.4.1-alpha.0",
         "minimist": "^1.2.0"
diff --git a/docs/package.json b/docs/package.json
index 1d51711..75f8993 100644
--- a/docs/package.json
+++ b/docs/package.json
@@ -13,7 +13,7 @@
     "react-dom": "^16.5.2",
     "react-scripts-ts": "3.1.0",
     "react-syntax-highlighter": "^9.0.0",
-    "soql-parser-js": "^0.3.3"
+    "soql-parser-js": "^0.4.0"
   },
   "scripts": {
     "start": "react-scripts-ts start",
diff --git a/docs/src/components/parse-soql.tsx b/docs/src/components/parse-soql.tsx
index cab1667..8df4980 100644
--- a/docs/src/components/parse-soql.tsx
+++ b/docs/src/components/parse-soql.tsx
@@ -1,5 +1,6 @@
 import { Button, DefaultButton } from 'office-ui-fabric-react/lib/Button';
 import { TextField } from 'office-ui-fabric-react/lib/TextField';
+import { Checkbox } from 'office-ui-fabric-react/lib/Checkbox';
 import * as React from 'react';
 import * as CopyToClipboard from 'react-copy-to-clipboard';
 import { parseQuery, Query, composeQuery } from 'soql-parser-js';
@@ -17,6 +18,7 @@ interface IParseSoqlState {
   parsedSoql: string;
   composedQuery?: string;
   soql: string;
+  format: boolean;
 }
 
 export class ParseSoql extends React.Component<IParseSoqlProps, IParseSoqlState> {
@@ -31,6 +33,7 @@ export class ParseSoql extends React.Component<IParseSoqlProps, IParseSoqlState>
       parsedSoql: JSON.stringify(parsedSoql || '', null, 4),
       composedQuery,
       soql: props.soql || '',
+      format: true,
     };
   }
 
@@ -63,10 +66,11 @@ export class ParseSoql extends React.Component<IParseSoqlProps, IParseSoqlState>
     }
   };
 
-  public parseQuery = (query?: string) => {
+  public parseQuery = (query?: string, format?: boolean) => {
     try {
+      format = typeof format === 'boolean' ? format : this.state.format;
       const parsedSoql: Query = parseQuery(query || this.state.soql);
-      const composedQuery: string = composeQuery(parsedSoql);
+      const composedQuery: string = composeQuery(parsedSoql, { format });
       this.setState({
         parsedSoql: JSON.stringify(parsedSoql, null, 4),
         composedQuery,
@@ -84,6 +88,11 @@ export class ParseSoql extends React.Component<IParseSoqlProps, IParseSoqlState>
     }
   };
 
+  public toggleFormat = () => {
+    this.setState({ format: !this.state.format });
+    this.parseQuery(this.state.soql, !this.state.format);
+  };
+
   public render() {
     return (
       <div className="ms-Fabric" dir="ltr">
@@ -118,7 +127,11 @@ export class ParseSoql extends React.Component<IParseSoqlProps, IParseSoqlState>
           <div className="ms-Grid-row">
             <div className="ms-Grid-col ms-sm12">
               <div className="ms-font-l">Parsed Query</div>
-              <SyntaxHighlighter language="json" style={xonokai} customStyle={{ maxHeight: 400, minHeight: 400, marginTop: 0, marginBottom: 5 }}>
+              <SyntaxHighlighter
+                language="json"
+                style={xonokai}
+                customStyle={{ maxHeight: 400, minHeight: 400, marginTop: 0, marginBottom: 5 }}
+              >
                 {this.state.parsedSoql}
               </SyntaxHighlighter>
               <div>
@@ -146,6 +159,9 @@ export class ParseSoql extends React.Component<IParseSoqlProps, IParseSoqlState>
                 <SyntaxHighlighter language="sql" style={xonokai} customStyle={{ marginTop: 0, marginBottom: 5 }}>
                   {this.state.composedQuery}
                 </SyntaxHighlighter>
+                <div style={{ margin: 5 }}>
+                  <Checkbox label="Format Output" checked={this.state.format} onChange={this.toggleFormat} />
+                </div>
                 <CopyToClipboard text={this.state.composedQuery}>
                   <Button
                     primary={true}
diff --git a/lib/SoqlComposer.ts b/lib/SoqlComposer.ts
index 65775f1..aaea537 100644
--- a/lib/SoqlComposer.ts
+++ b/lib/SoqlComposer.ts
@@ -222,10 +222,9 @@ export class Compose {
           : '';
     }
     if (where.right) {
-      return `${output}${this.formatter.formatAddNewLine()}${utils.get(where.operator)} ${this.parseWhereClause(
-        where.right,
-        isSubquery
-      )}`.trim();
+      return `${output}${this.formatter.formatAddNewLine(' ', isSubquery)}${utils.get(
+        where.operator
+      )} ${this.parseWhereClause(where.right, isSubquery)}`.trim();
     } else {
       return output.trim();
     }
diff --git a/lib/SoqlFormatter.ts b/lib/SoqlFormatter.ts
new file mode 100644
index 0000000..841f5e5
--- /dev/null
+++ b/lib/SoqlFormatter.ts
@@ -0,0 +1,101 @@
+import { composeQuery } from './SoqlComposer';
+import { parseQuery } from './SoqlParser';
+
+export interface FieldData {
+  fields: {
+    text: string;
+    isSubquery: boolean;
+    prefix: string;
+    suffix: string;
+  }[];
+  isSubquery: boolean;
+  lineBreaks: number[];
+}
+
+export interface FormatOptions {
+  active?: boolean;
+  numIndent?: number;
+  fieldMaxLineLen?: number;
+  logging?: boolean;
+}
+
+export function formatQuery(soql: string) {
+  return composeQuery(parseQuery(soql), { format: true });
+}
+
+export class Formatter {
+  options: FormatOptions;
+
+  constructor(options: FormatOptions) {
+    this.options = {
+      active: false,
+      numIndent: 1,
+      fieldMaxLineLen: 60,
+      logging: false,
+      ...options,
+    };
+  }
+
+  private log(data: any) {
+    if (this.options.logging) {
+      console.log(data);
+    }
+  }
+
+  private getIndent() {
+    return new Array(this.options.numIndent).fill('\t').join('');
+  }
+
+  formatFields(fieldData: FieldData) {
+    function trimPrevSuffix(currIdx: number) {
+      if (fieldData.fields[currIdx - 1]) {
+        fieldData.fields[currIdx - 1].suffix = fieldData.fields[currIdx - 1].suffix.trim();
+      }
+    }
+
+    fieldData.fields.forEach((field, i) => {
+      field.suffix = fieldData.fields.length - 1 === i ? '' : ', ';
+    });
+
+    if (this.options.active) {
+      let lineLen = 0;
+      let newLineAndIndentNext = false;
+      fieldData.fields.forEach((field, i) => {
+        if (field.isSubquery) {
+          // Subquery should always be on a stand-alone line
+          trimPrevSuffix(i);
+          field.prefix = `\n${this.getIndent()}`;
+          field.suffix = fieldData.fields.length - 1 === i ? '' : ', ';
+          lineLen = 0;
+          newLineAndIndentNext = true;
+        } else if (this.options.fieldMaxLineLen) {
+          // If max line length is specified, create a new line when needed
+          lineLen += field.text.length;
+          if (lineLen > this.options.fieldMaxLineLen || newLineAndIndentNext) {
+            trimPrevSuffix(i);
+            field.prefix += '\n\t';
+            lineLen = 0;
+            newLineAndIndentNext = false;
+          }
+        }
+
+        this.log(field);
+      });
+    }
+  }
+
+  formatClause(clause: string, isSubquery: boolean = false) {
+    if (isSubquery) {
+      return this.options.active ? `\n${this.getIndent()}${clause}` : ` ${clause}`;
+    } else {
+      return this.options.active ? `\n${clause}` : ` ${clause}`;
+    }
+  }
+  formatAddNewLine(alt: string = ' ', isSubquery: boolean = false) {
+    if (isSubquery) {
+      return this.options.active ? `\n${this.getIndent()}` : alt;
+    } else {
+      return this.options.active ? `\n` : alt;
+    }
+  }
+}


Test patch:
diff --git a/test/TestCasesForFormat.ts b/test/TestCasesForFormat.ts
new file mode 100644
index 0000000..f3a3b6b
--- /dev/null
+++ b/test/TestCasesForFormat.ts
@@ -0,0 +1,67 @@
+export interface TestCaseForFormat {
+  testCase: number;
+  soql: string;
+  formattedSoql: string;
+}
+
+export const testCases: TestCaseForFormat[] = [
+  {
+    testCase: 1,
+    soql: 'SELECT Id, Name, (SELECT Id, Name FROM Contacts), Foo, Bar, BillingCity FROM Account',
+    formattedSoql: `SELECT Id, Name,
+\t(SELECT Id, Name
+\tFROM Contacts),
+\tFoo, Bar, BillingCity
+FROM Account
+`.trim(),
+  },
+  {
+    testCase: 2,
+    soql: `SELECT Id, Name, Foo, Bar, Baz, Bee, Boo, Bam, Moo, Maz, Man, Name, Id, Name, Foo, Bar, Baz, Bee, Boo, Bam, Moo, Maz, Man, Name,Id, Name, Foo, Bar, Baz, Bee, Boo, Bam, Moo, Maz, Man, Name,Id, Name, Foo, Bar, Baz, Bee, Boo, Bam, Moo, Maz, Man, Name,(SELECT Name FROM Line_Items__r) FROM Merchandise__c WHERE Name LIKE 'Acme%'`,
+    formattedSoql: `SELECT Id, Name, Foo, Bar, Baz, Bee, Boo, Bam, Moo, Maz, Man, Name, Id, Name, Foo, Bar, Baz, Bee, Boo,
+\tBam, Moo, Maz, Man, Name, Id, Name, Foo, Bar, Baz, Bee, Boo, Bam, Moo, Maz, Man, Name, Id, Name, Foo,
+\tBar, Baz, Bee, Boo, Bam, Moo, Maz, Man, Name,
+\t(SELECT Name
+\tFROM Line_Items__r)
+FROM Merchandise__c
+WHERE Name LIKE 'Acme%'
+`.trim(),
+  },
+  {
+    testCase: 3,
+    soql: `SELECT UserId, COUNT(Id) from LoginHistory WHERE LoginTime > 2010-09-20T22:16:30.000Z AND LoginTime < 2010-09-21T22:16:30.000Z GROUP BY UserId`,
+    formattedSoql: `SELECT UserId, COUNT(Id)
+FROM LoginHistory
+WHERE LoginTime > 2010-09-20T22:16:30.000Z
+AND LoginTime < 2010-09-21T22:16:30.000Z
+GROUP BY UserId
+`.trim(),
+  },
+  {
+    testCase: 2,
+    soql: `SELECT Id FROM Account WHERE (Id IN ('1', '2', '3') OR (NOT Id = '2') OR (Name LIKE '%FOO%' OR (Name LIKE '%ARM%' AND FOO = 'bar')))`,
+    formattedSoql: `SELECT Id
+FROM Account
+WHERE (Id IN ('1', '2', '3')
+OR (NOT Id = '2')
+OR (Name LIKE '%FOO%'
+OR (Name LIKE '%ARM%'
+AND FOO = 'bar')))
+`.trim(),
+  },
+  {
+    testCase: 2,
+    soql: `SELECT Id, Name FROM Account WHERE Id IN (SELECT AccountId FROM Contact WHERE LastName LIKE 'apple%' AND foo = 'bar') AND Id IN (SELECT AccountId FROM Opportunity WHERE isClosed = false)`,
+    formattedSoql: `SELECT Id, Name
+FROM Account
+WHERE Id IN (SELECT AccountId
+\tFROM Contact
+\tWHERE LastName LIKE 'apple%'
+\tAND foo = 'bar')
+AND Id IN (SELECT AccountId
+\tFROM Opportunity
+\tWHERE isClosed = false)
+`.trim(),
+  },
+];
+export default testCases;