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

Issue:
Formatting: TYPEOF query should provide better formatting
- [ ] Feature
- [ ] Bug

<!--If this is a bug, please include the steps to reproduce the bug-->
## Description

```sql
SELECT Id, Name, Address, AnnualRevenue, ConvertedAccount.Id, ConvertedAccount.Name,
	ConvertedContact.Id, ConvertedContact.Name, ConvertedOpportunity.Id, ConvertedOpportunity.Name,
	TYPEOF Owner WHEN User THEN Id, Name, Account.Id, Account.Name, Account.GL_Treatment__c, Account.GL_Treatment__r.Id, Account.GL_Treatment__r.Name, Account.auto_number_test_1__c ELSE Id END
FROM Lead
```

Not sure the best structure, but something like this might be nice
```sql
SELECT Id, Name, Address, AnnualRevenue, ConvertedAccount.Id, ConvertedAccount.Name,
	ConvertedContact.Id, ConvertedContact.Name, ConvertedOpportunity.Id, ConvertedOpportunity.Name,
        TYPEOF Owner
            WHEN User
                THEN Id, Name, Account.Id, Account.Name, Account.GL_Treatment__c, Account.GL_Treatment__r.Id,
                    Account.GL_Treatment__r.Name, Account.auto_number_test_1__c
            ELSE Id
        END
FROM Lead
```
How can I achieve this Identation
- [X ] Feature
- [ ] Bug

<!--If this is a bug, please include the steps to reproduce the bug-->
## Description
I can't achieve the identation shown below.

````
SELECT
  AccountNumber,
  AccountSource,
  Active__c,
  AnnualRevenue,
  BillingAddress,
  BillingCity,
  BillingCountry,
  BillingGeocodeAccuracy,
  BillingLatitude,
  BillingLongitude,
  BillingPostalCode,
  BillingState,
  BillingStreet,
  CleanStatus,
  CreatedById,
  CreatedDate,
  CustomerPriority__c,
  DandbCompanyId,
  Description,
  DunsNumber,
  Fax,
  Id,
  Industry,
  IsDeleted,
  Jigsaw,
  JigsawCompanyId,
  LastActivityDate,
  LastModifiedById,
  LastModifiedDate,
  LastReferencedDate,
  LastViewedDate,
  MasterRecordId,
  NaicsCode,
  NaicsDesc,
  Name,
  NumberOfEmployees,
  NumberofLocations__c,
  OwnerId,
  Ownership,
  ParentId,
  Phone,
  PhotoUrl,
  Rating,
  ShippingAddress,
  ShippingCity,
  ShippingCountry,
  ShippingGeocodeAccuracy,
  ShippingLatitude,
  ShippingLongitude,
  ShippingPostalCode,
  ShippingState,
  ShippingStreet,
  Sic,
  SicDesc,
  Site,
  SLA__c,
  SLAExpirationDate__c,
  SLASerialNumber__c,
  SystemModstamp,
  TickerSymbol,
  Tradestyle,
  Type,
  UpsellOpportunity__c,
  Website,
  YearStarted,
  (
    SELECT
      AccountNumber,
      AccountSource,
      Active__c,
      AnnualRevenue,
      BillingAddress,
      BillingCity,
      BillingCountry,
      BillingGeocodeAccuracy,
      BillingLatitude,
      BillingLongitude,
      BillingPostalCode,
      BillingState,
      BillingStreet,
      CleanStatus,
      CreatedById,
      CreatedDate,
      CustomerPriority__c,
      DandbCompanyId,
      Description,
      DunsNumber,
      Fax,
      Id,
      Industry,
      IsDeleted,
      Jigsaw,
      JigsawCompanyId,
      LastActivityDate,
      LastModifiedById,
      LastModifiedDate,
      LastReferencedDate,
      LastViewedDate,
      MasterRecordId,
      NaicsCode,
      NaicsDesc,
      Name,
      NumberOfEmployees,
      NumberofLocations__c,
      OwnerId,
      Ownership,
      ParentId,
      Phone,
      PhotoUrl,
      Rating,
      ShippingAddress,
      ShippingCity,
      ShippingCountry,
      ShippingGeocodeAccuracy,
      ShippingLatitude,
      ShippingLongitude,
      ShippingPostalCode,
      ShippingState,
      ShippingStreet,
      Sic,
      SicDesc,
      Site,
      SLA__c,
      SLAExpirationDate__c,
      SLASerialNumber__c,
      SystemModstamp,
      TickerSymbol,
      Tradestyle,
      Type,
      UpsellOpportunity__c,
      Website,
      YearStarted
    FROM
      ChildAccounts
    WHERE
      CreatedDate = 2017-04-05T10:41:42.000+0000
  )
FROM
  Account
WHERE
  CreatedDate = 2017-04-05T10:41:42.000+0000
````


Pull request:
Formatter improvements
- The formatter option `whereClauseOperatorsIndented` has been deprecated and will always be applied.
- A new boolean formatter option named `newLineAfterKeywords` has been added and will ensure that there is always a new line after any keyword. (#137)
- `TYPEOF` fields will now always be included on their own line be default, or will span multiple lines, split by keywords if `newLineAfterKeywords` is set to true. (#135)

resolves #137
resolves #135

Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 16b6097..c934573 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,41 @@
 # Changelog
 
+## 3.2.0
+
+March 27, 2021
+
+A number of improvements to the formatter have been made with this release.
+
+- The formatter option `whereClauseOperatorsIndented` has been deprecated and will always be applied.
+- A new boolean formatter option named `newLineAfterKeywords` has been added and will ensure that there is always a new line after any keyword. (#137)
+- `TYPEOF` fields will now always be included on their own line be default, or will span multiple lines, split by keywords if `newLineAfterKeywords` is set to true. (#135)
+
+## Example
+
+`SELECT Id, TYPEOF What WHEN Account THEN Phone, NumberOfEmployees WHEN Opportunity THEN Amount, CloseDate ELSE Name, Email END, Name FROM Event`
+
+`formatOptions: { newLineAfterKeywords: true, fieldMaxLineLength: 1 },`
+
+```sql
+SELECT
+  Id,
+  TYPEOF What
+    WHEN
+      Account
+    THEN
+      Phone, NumberOfEmployees
+    WHEN
+      Opportunity
+    THEN
+      Amount, CloseDate
+    ELSE
+      Name, Email
+  END,
+  Name
+FROM
+  Event
+```
+
 ## 3.1.0
 
 March 27, 2021
diff --git a/README.md b/README.md
index be010ab..bacb075 100644
--- a/README.md
+++ b/README.md
@@ -96,13 +96,14 @@ Many of hte utility functions are provided to easily determine the shape of spec
 
 **FormatOptions**
 
-| Property                     | Type    | Description                                                                                                                          | required | default |
-| ---------------------------- | ------- | ------------------------------------------------------------------------------------------------------------------------------------ | -------- | ------- |
-| numIndent                    | number  | The number of tab characters to indent.                                                                                              | FALSE    | 1       |
-| fieldMaxLineLength           | number  | The number of characters that the fields should take up before making a new line. Set this to 1 to have every field on its own line. | FALSE    | 60      |
-| fieldSubqueryParensOnOwnLine | boolean | If true, the opening and closing parentheses will be on their own line for subqueries.                                               | FALSE    | TRUE    |
-| whereClauseOperatorsIndented | boolean | If true, indents the where clause operators.                                                                                         | FALSE    | FALSE   |
-| logging                      | boolean | Print out logging statements to the console about the format operation.                                                              | FALSE    | FALSE   |
+| Property                         | Type    | Description                                                                                                                                                                                 | required | default |
+| -------------------------------- | ------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------- | ------- |
+| numIndent                        | number  | The number of tab characters to indent.                                                                                                                                                     | FALSE    | 1       |
+| fieldMaxLineLength               | number  | The number of characters that the fields should take up before making a new line. Set this to 1 to have every field on its own line.                                                        | FALSE    | 60      |
+| fieldSubqueryParensOnOwnLine     | boolean | If true, the opening and closing parentheses will be on their own line for subqueries.                                                                                                      | FALSE    | TRUE    |
+| newLineAfterKeywords             | boolean | Adds a new line and indent after all keywords (such as SELECT, FROM, WHERE, ORDER BY, etc..) Setting this to true will add new lines in other places as well, such as complex WHERE clauses | FALSE    | FALSE   |
+| ~~whereClauseOperatorsIndented~~ | boolean | **Deprecated** If true, indents the where clause operators.                                                                                                                                 | FALSE    | FALSE   |
+| logging                          | boolean | Print out logging statements to the console about the format operation.                                                                                                                     | FALSE    | FALSE   |
 
 ## Examples
 
diff --git a/docs/src/modules/my/queryComposer/queryComposer.css b/docs/src/modules/my/queryComposer/queryComposer.css
index e69de29..9679e2e 100644
--- a/docs/src/modules/my/queryComposer/queryComposer.css
+++ b/docs/src/modules/my/queryComposer/queryComposer.css
@@ -0,0 +1,6 @@
+textarea,
+pre {
+  -moz-tab-size: 2;
+  -o-tab-size: 2;
+  tab-size: 2;
+}
diff --git a/docs/src/modules/my/queryComposer/queryComposer.html b/docs/src/modules/my/queryComposer/queryComposer.html
index 10ed74a..7bac644 100644
--- a/docs/src/modules/my/queryComposer/queryComposer.html
+++ b/docs/src/modules/my/queryComposer/queryComposer.html
@@ -7,8 +7,8 @@
       <ui-checkbox name="fieldSubqueryParensOnOwnLine" value={fieldSubqueryParensOnOwnLine} disabled={formDisabled} onchange={handleChange}>
         Subquery parenthesis on own line - <code class="font-semibold">fieldSubqueryParensOnOwnLine</code>
       </ui-checkbox>
-      <ui-checkbox name="whereClauseOperatorsIndented" value={whereClauseOperatorsIndented} disabled={formDisabled} onchange={handleChange}>
-        Indent items in WHERE clause - <code class="font-semibold">whereClauseOperatorsIndented</code>
+      <ui-checkbox name="newLineAfterKeywords" value={newLineAfterKeywords} disabled={formDisabled} onchange={handleChange}>
+        Add newline and indent after keywords - <code class="font-semibold">newLineAfterKeywords</code>
       </ui-checkbox>
       <ui-input
         name="fieldMaxLineLength"
diff --git a/docs/src/modules/my/queryComposer/queryComposer.ts b/docs/src/modules/my/queryComposer/queryComposer.ts
index d4e6a14..3508d01 100644
--- a/docs/src/modules/my/queryComposer/queryComposer.ts
+++ b/docs/src/modules/my/queryComposer/queryComposer.ts
@@ -3,7 +3,7 @@ import { Query, composeQuery } from 'soql-parser-js';
 import * as hljs from 'highlight.js/lib/highlight.js';
 hljs.registerLanguage('sql', require('highlight.js/lib/languages/sql'));
 
-const DEFAULT_LINE_LEN = 60;
+const DEFAULT_LINE_LEN = 1;
 const NOT_DIGIT_RGX = /[^\d]/g;
 
 export default class QueryComposer extends LightningElement {
@@ -21,7 +21,7 @@ export default class QueryComposer extends LightningElement {
   @track composedQuery: string;
   @track formatOutput = true;
   @track fieldSubqueryParensOnOwnLine = true;
-  @track whereClauseOperatorsIndented = false;
+  @track newLineAfterKeywords = true;
   @track fieldMaxLineLength = DEFAULT_LINE_LEN;
   hasRendered = false;
   fieldMaxLineLengthTransformFn = (value: string): string => {
@@ -40,7 +40,7 @@ export default class QueryComposer extends LightningElement {
       `//   format: ${this.formatOutput},\n` +
       `//   formatOptions: {\n` +
       `//     fieldSubqueryParensOnOwnLine: ${this.fieldSubqueryParensOnOwnLine},\n` +
-      `//     whereClauseOperatorsIndented: ${this.whereClauseOperatorsIndented},\n` +
+      `//     newLineAfterKeywords: ${this.newLineAfterKeywords},\n` +
       `//     fieldMaxLineLength: ${this.fieldMaxLineLength},\n` +
       `//   }\n` +
       `// });\n`;
@@ -48,7 +48,7 @@ export default class QueryComposer extends LightningElement {
     element.innerText =
       `// composeQuery(parsedQuery), {\n` +
       `//   format: ${this.formatOutput},\n` +
-      `//   formatOptions: { fieldSubqueryParensOnOwnLine: ${this.fieldSubqueryParensOnOwnLine}, whereClauseOperatorsIndented: ${this.whereClauseOperatorsIndented}, fieldMaxLineLength: ${this.fieldMaxLineLength} }\n` +
+      `//   formatOptions: { fieldSubqueryParensOnOwnLine: ${this.fieldSubqueryParensOnOwnLine}, newLineAfterKeywords: ${this.newLineAfterKeywords}, fieldMaxLineLength: ${this.fieldMaxLineLength} }\n` +
       `// });\n`;
 
     // element.innerText = `// composeQuery(parsedQuery, { format: ${this.formatOutput}, formatOptions: { fieldSubqueryParensOnOwnLine, whereClauseOperatorsIndented, fieldMaxLineLength } });`;
@@ -59,10 +59,10 @@ export default class QueryComposer extends LightningElement {
   composeQuery() {
     try {
       if (this.parsedQuery) {
-        const { fieldSubqueryParensOnOwnLine, whereClauseOperatorsIndented, fieldMaxLineLength } = this;
+        const { fieldSubqueryParensOnOwnLine, newLineAfterKeywords, fieldMaxLineLength } = this;
         this.composedQuery = composeQuery(JSON.parse(JSON.stringify(this.parsedQuery)), {
           format: this.formatOutput,
-          formatOptions: { fieldSubqueryParensOnOwnLine, whereClauseOperatorsIndented, fieldMaxLineLength }
+          formatOptions: { fieldSubqueryParensOnOwnLine, newLineAfterKeywords, fieldMaxLineLength }
         });
         this.highlight();
       }
@@ -82,8 +82,8 @@ export default class QueryComposer extends LightningElement {
         this.fieldSubqueryParensOnOwnLine = value;
         break;
       }
-      case 'whereClauseOperatorsIndented': {
-        this.whereClauseOperatorsIndented = value;
+      case 'newLineAfterKeywords': {
+        this.newLineAfterKeywords = value;
         break;
       }
       case 'fieldMaxLineLength': {
diff --git a/src/composer/composer.ts b/src/composer/composer.ts
index ce2d8e3..fa169a0 100644
--- a/src/composer/composer.ts
+++ b/src/composer/composer.ts
@@ -138,8 +138,9 @@ export class Compose {
   public parseQuery(query: Query | Subquery): string {
     const fieldData: FieldData = {
       fields: this.parseFields(query.fields).map(field => ({
-        text: field,
-        isSubquery: field.startsWith('('),
+        text: field.text,
+        typeOfClause: field.typeOfClause,
+        isSubquery: field.text.startsWith('('),
         prefix: '',
         suffix: '',
       })),
@@ -147,70 +148,76 @@ export class Compose {
       lineBreaks: [],
     };
 
-    let output = `SELECT `;
+    let output = this.formatter.formatClause('SELECT').trimStart();
 
     // Format fields based on configuration
     this.formatter.formatFields(fieldData);
 
+    let fieldsOutput = '';
     fieldData.fields.forEach(field => {
-      output += `${field.prefix}${field.text}${field.suffix}`;
+      if (Array.isArray(field.typeOfClause)) {
+        fieldsOutput += `${field.prefix}${this.formatter.formatTyeOfField(field.text, field.typeOfClause)}${field.suffix}`;
+      } else {
+        fieldsOutput += `${field.prefix}${field.text}${field.suffix}`;
+      }
     });
+    output += this.formatter.formatText(fieldsOutput);
 
     output += this.formatter.formatClause('FROM');
 
     if (utils.isSubquery(query)) {
       const sObjectPrefix = query.sObjectPrefix || [];
       sObjectPrefix.push(query.relationshipName);
-      output += ` ${sObjectPrefix.join('.')}${utils.get(query.sObjectAlias, '', ' ')}`;
+      output += this.formatter.formatText(`${sObjectPrefix.join('.')}${utils.get(query.sObjectAlias, '', ' ')}`);
     } else {
-      output += ` ${query.sObject}${utils.get(query.sObjectAlias, '', ' ')}`;
+      output += this.formatter.formatText(`${query.sObject}${utils.get(query.sObjectAlias, '', ' ')}`);
     }
     this.log(output);
 
     if (query.usingScope) {
       output += this.formatter.formatClause('USING SCOPE');
-      output += ` ${query.usingScope}`;
+      output += this.formatter.formatText(query.usingScope);
       this.log(output);
     }
 
     if (query.where) {
       output += this.formatter.formatClause('WHERE');
-      output += ` ${this.parseWhereOrHavingClause(query.where)}`;
+      output += this.formatter.formatText(this.parseWhereOrHavingClause(query.where));
       this.log(output);
     }
 
     if (query.groupBy) {
       output += this.formatter.formatClause('GROUP BY');
-      output += ` ${this.parseGroupByClause(query.groupBy)}`;
+      output += this.formatter.formatText(this.parseGroupByClause(query.groupBy));
       this.log(output);
       if (query.groupBy.having) {
         output += this.formatter.formatClause('HAVING');
-        output += ` ${this.parseWhereOrHavingClause(query.groupBy.having)}`;
+        output += this.formatter.formatText(this.parseWhereOrHavingClause(query.groupBy.having));
         this.log(output);
       }
     }
 
     if (query.orderBy && (!Array.isArray(query.orderBy) || query.orderBy.length > 0)) {
       output += this.formatter.formatClause('ORDER BY');
-      output += ` ${this.parseOrderBy(query.orderBy)}`;
+      output += this.formatter.formatText(this.parseOrderBy(query.orderBy));
       this.log(output);
     }
 
     if (utils.isNumber(query.limit)) {
       output += this.formatter.formatClause('LIMIT');
-      output += ` ${query.limit}`;
+      output += this.formatter.formatText(`${query.limit}`);
       this.log(output);
     }
 
     if (utils.isNumber(query.offset)) {
       output += this.formatter.formatClause('OFFSET');
-      output += ` ${query.offset}`;
+      output += this.formatter.formatText(`${query.offset}`);
       this.log(output);
     }
 
     if (query.withDataCategory) {
       output += this.formatter.formatClause('WITH DATA CATEGORY');
-      output += ` ${this.parseWithDataCategory(query.withDataCategory)}`;
+      output += this.formatter.formatText(this.parseWithDataCategory(query.withDataCategory));
       this.log(output);
     }
 
@@ -221,13 +228,13 @@ export class Compose {
 
     if (query.for) {
       output += this.formatter.formatClause('FOR');
-      output += ` ${query.for}`;
+      output += this.formatter.formatText(query.for);
       this.log(output);
     }
 
     if (query.update) {
       output += this.formatter.formatClause('UPDATE');
-      output += ` ${query.update}`;
+      output += this.formatter.formatText(query.update);
       this.log(output);
     }
 
@@ -240,34 +247,44 @@ export class Compose {
    * @param fields
    * @returns fields
    */
-  public parseFields(fields: FieldType[]): string[] {
+  public parseFields(fields: FieldType[]): { text: string; typeOfClause?: string[] }[] {
     return fields.map(field => {
+      let text = '';
+      let typeOfClause: string[];
+
       const objPrefix = (field as any).objectPrefix ? `${(field as any).objectPrefix}.` : '';
       switch (field.type) {
         case 'Field': {
-          return `${objPrefix}${field.field}${field.alias ? ` ${field.alias}` : ''}`;
+          text = `${objPrefix}${field.field}${field.alias ? ` ${field.alias}` : ''}`;
+          break;
         }
         case 'FieldFunctionExpression': {
           let params = '';
           if (field.parameters) {
             params = field.parameters
-              .map(param => (utils.isString(param) ? param : this.parseFields([param as FieldFunctionExpression])))
+              .map(param => (utils.isString(param) ? param : this.parseFields([param as FieldFunctionExpression]).map(param => param.text)))
               .join(', ');
           }
-          return `${field.functionName}(${params})${field.alias ? ` ${field.alias}` : ''}`;
+          text = `${field.functionName}(${params})${field.alias ? ` ${field.alias}` : ''}`;
+          break;
         }
         case 'FieldRelationship': {
-          return `${objPrefix}${field.relationships.join('.')}.${field.field}${utils.hasAlias(field) ? ` ${field.alias}` : ''}`;
+          text = `${objPrefix}${field.relationships.join('.')}.${field.field}${utils.hasAlias(field) ? ` ${field.alias}` : ''}`;
+          break;
         }
         case 'FieldSubquery': {
-          return this.formatter.formatSubquery(this.parseQuery(field.subquery));
+          text = this.formatter.formatSubquery(this.parseQuery(field.subquery));
+          break;
         }
         case 'FieldTypeof': {
-          return this.parseTypeOfField(field);
+          typeOfClause = this.parseTypeOfField(field);
+          text = typeOfClause.join(' ');
+          break;
         }
         default:
           break;
       }
+      return { text, typeOfClause };
     });
   }
 
@@ -277,14 +294,11 @@ export class Compose {
    * @param typeOfField
    * @returns type of field
    */
-  public parseTypeOfField(typeOfField: FieldTypeOf): string {
-    let output = `TYPEOF ${typeOfField.field} `;
-    output += typeOfField.conditions
-      .map(cond => {
-        return `${cond.type} ${utils.get(cond.objectType, ' THEN ')}${cond.fieldList.join(', ')}`;
-      })
-      .join(' ');
-    output += ` END`;
+  public parseTypeOfField(typeOfField: FieldTypeOf): string[] {
+    const output = [`TYPEOF ${typeOfField.field}`].concat(
+      typeOfField.conditions.map(condition => this.formatter.formatTypeofFieldCondition(condition)),
+    );
+    output.push(`END`);
     return output;
   }
 
@@ -296,29 +310,39 @@ export class Compose {
    * @param priorIsNegationOperator - do not set this when calling manually. Recursive call will set this to ensure proper formatting.
    * @returns where clause
    */
-  public parseWhereOrHavingClause(whereOrHaving: WhereClause | HavingClause): string {
+  public parseWhereOrHavingClause(whereOrHaving: WhereClause | HavingClause, tabOffset = 0, priorConditionIsNegation = false): string {
     let output = '';
     const left = whereOrHaving.left;
+    let trimPrecedingOutput = false;
     if (left) {
-      output += utils.generateParens(left.openParen, '(');
+      output += this.formatter.formatParens(left.openParen, '(', utils.isNegationCondition(left));
       if (!utils.isNegationCondition(left)) {
-        output += utils.isValueFunctionCondition(left) ? this.parseFn(left.fn) : left.field;
-        output += ` ${left.operator} `;
+        tabOffset = tabOffset + (left.openParen || 0) - (left.closeParen || 0);
+        if (priorConditionIsNegation) {
+          tabOffset++;
+        }
+        let expression = '';
+        expression += utils.isValueFunctionCondition(left) ? this.parseFn(left.fn) : left.field;
+        expression += ` ${left.operator} `;
 
         if (utils.isValueQueryCondition(left)) {
-          output += this.formatter.formatSubquery(this.parseQuery(left.valueQuery), 1, true);
+          expression += this.formatter.formatSubquery(this.parseQuery(left.valueQuery), 1, true);
         } else {
-          output += utils.getAsArrayStr(utils.getWhereValue(left.value, left.literalType));
+          expression += utils.getAsArrayStr(utils.getWhereValue(left.value, left.literalType));
         }
-        output += utils.generateParens(left.closeParen, ')');
+        output += this.formatter.formatWithIndent(expression);
+        output += this.formatter.formatParens(left.closeParen, ')', priorConditionIsNegation);
       }
     }
     if (utils.isWhereOrHavingClauseWithRightCondition(whereOrHaving)) {
+      const operator = utils.get(whereOrHaving.operator);
+      trimPrecedingOutput = operator === 'NOT';
       const formattedData = this.formatter.formatWhereClauseOperators(
-        utils.get(whereOrHaving.operator),
-        this.parseWhereOrHavingClause(whereOrHaving.right),
+        operator,
+        this.parseWhereOrHavingClause(whereOrHaving.right, tabOffset, utils.isNegationCondition(left)),
+        tabOffset,
       );
-      return `${output}${formattedData}`.trim();
+      return `${trimPrecedingOutput ? output.trimRight() : output}${formattedData}`.trim();
     } else {
       return output.trim();
     }
diff --git a/src/formatter/formatter.ts b/src/formatter/formatter.ts
index 40af361..73c71ac 100644
--- a/src/formatter/formatter.ts
+++ b/src/formatter/formatter.ts
@@ -1,8 +1,10 @@
-import { isNumber } from '../utils';
+import { FieldTypeOfCondition } from '../api/api-models';
+import { isNumber, generateParens } from '../utils';
 
 export interface FieldData {
   fields: {
     text: string;
+    typeOfClause?: string[];
     isSubquery: boolean;
     prefix: string;
     suffix: string;
@@ -12,10 +14,34 @@ export interface FieldData {
 }
 
 export interface FormatOptions {
+  /**
+   * @default 1
+   * Number of tabs to indent by
+   * These defaults to one
+   */
   numIndent?: number;
+  /**
+   * @default 60
+   * Number of characters before wrapping to a new line.
+   * Set to 0 or 1 to force every field on a new line.
+   * TYPEOF fields do not honor this setting, they will always be on one line unless `newLineAfterKeywords` is true,
+   * in which case it will span multiple lines.
+   */
   fieldMaxLineLength?: number;
+  /**
+   * @default true
+   * Set to true to have a subquery parentheses start and end on a new line.
+   * This will be set to true if `newLineAfterKeywords` is true, in which case this property can be omitted
+   */
   fieldSubqueryParensOnOwnLine?: boolean;
+  /** @deprecated as of 3.3.0 - this is always true and will be removed in future version */
   whereClauseOperatorsIndented?: boolean;
+  /**
+   * @default false
+   * Adds a new line and indent after all keywords (such as SELECT, FROM, WHERE, ORDER BY, etc..)
+   * Setting this to true will add new lines in other places as well, such as complex WHERE clauses
+   */
+  newLineAfterKeywords?: boolean;
   logging?: boolean;
 }
 
@@ -35,10 +61,13 @@ export class Formatter {
       numIndent: 1,
       fieldMaxLineLength: 60,
       fieldSubqueryParensOnOwnLine: true,
-      whereClauseOperatorsIndented: false,
+      newLineAfterKeywords: false,
       logging: false,
       ...options,
     };
+    if (this.options.newLineAfterKeywords) {
+      this.options.fieldSubqueryParensOnOwnLine = true;
+    }
   }
 
   private log(data: any) {
@@ -47,8 +76,8 @@ export class Formatter {
     }
   }
 
-  private getIndent() {
-    return this.repeatChar(this.currIndent * this.options.numIndent, '\t');
+  private getIndent(additionalIndent = 0) {
+    return this.repeatChar((this.currIndent + additionalIndent) * this.options.numIndent, '\t');
   }
 
   private repeatChar(numTimes: number, char: string) {
@@ -89,13 +118,20 @@ export class Formatter {
           field.suffix = fieldData.fields.length - 1 === i ? '' : ', ';
           lineLen = 0;
           newLineAndIndentNext = true;
+        } else if (Array.isArray(field.typeOfClause)) {
+          trimPrevSuffix(i);
+          // always show on a new line
+          field.prefix = `\n${this.getIndent()}`;
+          newLineAndIndentNext = true;
         } else if (isNumber(this.options.fieldMaxLineLength)) {
           // If max line length is specified, create a new line when needed
           // Add two to account for ", "
           lineLen += field.text.length + field.suffix.length;
           if (lineLen > this.options.fieldMaxLineLength || newLineAndIndentNext) {
             trimPrevSuffix(i);
-            field.prefix += `\n${this.getIndent()}`;
+            if (!this.options.newLineAfterKeywords || i > 0) {
+              field.prefix += `\n${this.getIndent()}`;
+            }
             lineLen = 0;
             newLineAndIndentNext = false;
           }
@@ -106,17 +142,56 @@ export class Formatter {
     }
   }
 
+  formatTyeOfField(text: string, typeOfClause: string[]) {
+    if (this.enabled && this.options.newLineAfterKeywords) {
+      return typeOfClause
+        .map((part, i) => {
+          if (i === 0) {
+            return part;
+          } else if (i === typeOfClause.length - 1) {
+            return `${this.getIndent()}${part}`;
+          } else {
+            return `${this.getIndent()}\t${part}`;
+          }
+        })
+        .join('\n');
+    }
+    return text;
+  }
+
+  formatTypeofFieldCondition(condition: FieldTypeOfCondition) {
+    let output = '';
+    const fields = condition.fieldList.join(', ');
+    if (this.enabled && this.options.newLineAfterKeywords) {
+      const indent = this.getIndent();
+      output = `${condition.type}`;
+      if (condition.objectType) {
+        output += `\n${indent}\t\t${condition.objectType}\n${indent}\tTHEN\n${indent}\t\t${fields}`;
+      } else {
+        output += `\n${indent}\t\t${fields}`;
+      }
+    } else {
+      output = condition.type;
+      if (condition.objectType) {
+        output += ` ${condition.objectType} THEN ${fields}`;
+      } else {
+        output += ` ${fields}`;
+      }
+    }
+    return output;
+  }
+
   /**
    * Formats subquery with additional indents
    */
   formatSubquery(queryStr: string, numTabs = 2, incrementTabsWhereClauseOpIndent: boolean = false): string {
-    if (incrementTabsWhereClauseOpIndent && this.options.whereClauseOperatorsIndented) {
+    if (incrementTabsWhereClauseOpIndent) {
       numTabs++;
     }
     let leftParen = '(';
     let rightParen = ')';
     if (this.enabled) {
-      if (this.options.fieldSubqueryParensOnOwnLine) {
+      if (this.options.fieldSubqueryParensOnOwnLine || this.options.newLineAfterKeywords) {
         queryStr = queryStr.replace(/\n/g, `\n${this.repeatChar(numTabs, '\t')}`);
         leftParen = `(\n${this.repeatChar(numTabs, '\t')}`;
         rightParen = `\n${this.repeatChar(numTabs - 1, '\t')})`;
@@ -134,7 +209,24 @@ export class Formatter {
    * @returns clause
    */
   formatClause(clause: string): string {
-    return this.enabled ? `\n${clause}` : ` ${clause}`;
+    if (this.enabled) {
+      return this.options.newLineAfterKeywords ? `\n${clause}\n\t` : `\n${clause}`;
+    }
+    return ` ${clause}`;
+  }
+
+  /**
+   * If newLineAfterKeywords is true, then no preceding space will be added
+   * This is called after clauses
+   * @param text
+   * @returns text
+   */
+  formatText(text: string): string {
+    return this.enabled && (this.options.newLineAfterKeywords || text.startsWith('\n')) ? text : ` ${text}`;
+  }
+
+  formatWithIndent(text: string): string {
+    return this.enabled ? `${this.getIndent()}${text}` : text;
   }
 
   formatOrderByArray(groupBy: string[]): string {
@@ -144,7 +236,7 @@ export class Formatter {
       groupBy.forEach((token, i) => {
         const nextToken = groupBy[i + 1];
         currLen += token.length;
-        if (nextToken && currLen + nextToken.length > this.options.fieldMaxLineLength) {
+        if (nextToken && (currLen + nextToken.length > this.options.fieldMaxLineLength || this.options.newLineAfterKeywords)) {
           output += `${token},\n\t`;
           currLen = 0;
         } else {
@@ -157,12 +249,57 @@ export class Formatter {
     }
   }
 
-  formatWhereClauseOperators(operator: string, whereClause: string): string {
+  /**
+   * Formats parens
+   * @param count
+   * @param character
+   * @param [leadingParenInline] Make the leading paren inline (last for "(" and first for ")") used for negation condition
+   * @returns
+   */
+  formatParens(count: number, character: '(' | ')', leadingParenInline = false) {
+    let output = '';
+    if (isNumber(count) && count > 0) {
+      if (this.enabled) {
+        if (character === '(') {
+          for (let i = 0; i < count; i++) {
+            if (leadingParenInline && i === count - 1) {
+              output += '(';
+            } else {
+              if (i === 0) {
+                output += '(\n';
+              } else {
+                this.currIndent++;
+                output += `${this.getIndent()}(\n`;
+              }
+            }
+          }
+          if (!leadingParenInline || count > 1) {
+            // indent the following clause
+            this.currIndent++;
+          }
+        } else {
+          for (let i = count - 1; i >= 0; i--) {
+            if (leadingParenInline && i === count - 1) {
+              output += ')';
+            } else {
+              this.currIndent--;
+              output += `\n${this.getIndent()})`;
+            }
+          }
+        }
+      } else {
+        output += generateParens(count, character);
+      }
+    }
+    return output;
+  }
+
+  formatWhereClauseOperators(operator: string, whereClause: string, additionalIndent = 0): string {
     const skipNewLineAndIndent = operator === 'NOT';
-    if (this.enabled && this.options.whereClauseOperatorsIndented) {
-      return `\n\t${operator} ${whereClause}`;
+    if (this.enabled && !skipNewLineAndIndent) {
+      return `\n${this.getIndent(additionalIndent)}${operator} ${whereClause}`;
     } else {
-      return `${this.formatAddNewLine(skipNewLineAndIndent ? '' : ' ', skipNewLineAndIndent)}${operator} ${whereClause}`;
+      return `${skipNewLineAndIndent ? '' : ' '}${operator} ${whereClause}`;
     }
   }
 
diff --git a/src/utils.ts b/src/utils.ts
index d755146..61a043b 100644
--- a/src/utils.ts
+++ b/src/utils.ts
@@ -92,8 +92,8 @@ export function pad(val: string, len: number, left: number = 0): string {
   }
 }
 
-export function generateParens(count: number, character: '(' | ')') {
-  return isNumber(count) && count > 0 ? new Array(count).fill(character).join('') : '';
+export function generateParens(count: number, character: '(' | ')', joinCharacter = '') {
+  return isNumber(count) && count > 0 ? new Array(count).fill(character).join(joinCharacter) : '';
 }
 
 /**


Test patch:
diff --git a/test/test-cases-for-format.ts b/test/test-cases-for-format.ts
index a7733fd..ed39c09 100644
--- a/test/test-cases-for-format.ts
+++ b/test/test-cases-for-format.ts
@@ -53,7 +53,7 @@ WHERE Name LIKE 'Acme%'
     formattedSoql: `SELECT UserId, COUNT(Id)
 FROM LoginHistory
 WHERE LoginTime > 2010-09-20T22:16:30.000Z
-AND LoginTime < 2010-09-21T22:16:30.000Z
+\tAND LoginTime < 2010-09-21T22:16:30.000Z
 GROUP BY UserId
 `.trim(),
   },
@@ -62,11 +62,17 @@ GROUP BY UserId
     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')))
+WHERE (
+\t\tId IN ('1', '2', '3')
+\t\tOR (NOT Id = '2')
+\t\tOR (
+\t\t\tName LIKE '%FOO%'
+\t\t\tOR (
+\t\t\t\tName LIKE '%ARM%'
+\t\t\t\tAND FOO = 'bar'
+\t\t\t)
+\t\t)
+\t)
 `.trim(),
   },
   {
@@ -75,22 +81,22 @@ AND FOO = 'bar')))
     formattedSoql: `SELECT Id, Name
 FROM Account
 WHERE Id IN (
-\tSELECT AccountId
-\tFROM Contact
-\tWHERE LastName LIKE 'apple%'
-\tAND foo = 'bar'
-)
-AND Id IN (
-\tSELECT AccountId
-\tFROM Opportunity
-\tWHERE isClosed = TRUE
-)
+\t\tSELECT AccountId
+\t\tFROM Contact
+\t\tWHERE LastName LIKE 'apple%'
+\t\t\tAND foo = 'bar'
+\t)
+\tAND Id IN (
+\t\tSELECT AccountId
+\t\tFROM Opportunity
+\t\tWHERE isClosed = TRUE
+\t)
 `.trim(),
   },
   {
     testCase: 7,
     soql: `SELECT Id, Name, Foo, Bar, Baz, Bax, aaa, bbb, ccc, ddd, Id, Name, Foo, Bar, Baz, Bax, aaa, bbb, ccc, ddd, Id, Name, Foo, Bar, Baz, Bax, aaa, bbb, ccc, ddd, Account.Name, (SELECT Id, Name, Foo, Bar, Baz, Bax, aaa, bbb, ccc, ddd, Id, Name, Foo, Bar, Baz, Bax, aaa, bbb, ccc, ddd, Contact.LastName FROM Account.Contacts WHERE Id = '123' OR Id = '456' OR pimped = TRUE), baz, (SELECT Id FROM account WHERE Boo.baz = 'bar'), bax, bar FROM Account WHERE Id IN (SELECT AccountId FROM Contact WHERE LastName LIKE 'apple%') AND Foo = 'bar' OR Baz = 'boom' AND Id IN (SELECT AccountId FROM Opportunity WHERE isClosed = TRUE) ORDER BY GROUPING(Type), GROUPING(Id, BillingCountry), Name DESC NULLS FIRST, Id ASC NULLS LAST`,
-    formatOptions: { fieldMaxLineLength: 20, fieldSubqueryParensOnOwnLine: true, whereClauseOperatorsIndented: true },
+    formatOptions: { fieldMaxLineLength: 20, fieldSubqueryParensOnOwnLine: true },
     formattedSoql: `SELECT Id, Name, Foo, Bar,
 \tBaz, Bax, aaa, bbb, ccc,
 \tddd, Id, Name, Foo, Bar,
@@ -138,7 +144,7 @@ ORDER BY GROUPING(Type),
   {
     testCase: 8,
     soql: `SELECT Id, Name, Foo, Bar, Baz, Bax, aaa, bbb, ccc, ddd, Id, Name, Foo, Bar, Baz, Bax, aaa, bbb, ccc, ddd, Id, Name, Foo, Bar, Baz, Bax, aaa, bbb, ccc, ddd, Account.Name, (SELECT Id, Name, Foo, Bar, Baz, Bax, aaa, bbb, ccc, ddd, Id, Name, Foo, Bar, Baz, Bax, aaa, bbb, ccc, ddd, Contact.LastName FROM Account.Contacts WHERE Id = '123' OR Id = '456' OR pimped = TRUE), baz, (SELECT Id FROM account WHERE Boo.baz = 'bar'), bax, bar FROM Account WHERE Id IN (SELECT AccountId FROM Contact WHERE LastName LIKE 'apple%') AND Foo = 'bar' OR Baz = 'boom' AND Id IN (SELECT AccountId FROM Opportunity WHERE isClosed = TRUE) ORDER BY GROUPING(Type), GROUPING(Id, BillingCountry), Name DESC NULLS FIRST, Id ASC NULLS LAST`,
-    formatOptions: { fieldMaxLineLength: 20, fieldSubqueryParensOnOwnLine: true, whereClauseOperatorsIndented: false },
+    formatOptions: { fieldMaxLineLength: 20, fieldSubqueryParensOnOwnLine: true },
     formattedSoql: `SELECT Id, Name, Foo, Bar,
 \tBaz, Bax, aaa, bbb, ccc,
 \tddd, Id, Name, Foo, Bar,
@@ -154,8 +160,8 @@ ORDER BY GROUPING(Type),
 \t\t\tddd, Contact.LastName
 \t\tFROM Account.Contacts
 \t\tWHERE Id = '123'
-\t\tOR Id = '456'
-\t\tOR pimped = TRUE
+\t\t\tOR Id = '456'
+\t\t\tOR pimped = TRUE
 \t),
 \tbaz,
 \t(
@@ -166,27 +172,27 @@ ORDER BY GROUPING(Type),
 \tbax, bar
 FROM Account
 WHERE Id IN (
-\tSELECT AccountId
-\tFROM Contact
-\tWHERE LastName LIKE 'apple%'
-)
-AND Foo = 'bar'
-OR Baz = 'boom'
-AND Id IN (
-\tSELECT AccountId
-\tFROM Opportunity
-\tWHERE isClosed = TRUE
-)
+\t\tSELECT AccountId
+\t\tFROM Contact
+\t\tWHERE LastName LIKE 'apple%'
+\t)
+\tAND Foo = 'bar'
+\tOR Baz = 'boom'
+\tAND Id IN (
+\t\tSELECT AccountId
+\t\tFROM Opportunity
+\t\tWHERE isClosed = TRUE
+\t)
 ORDER BY GROUPING(Type),
 \tGROUPING(Id, BillingCountry),
 \tName DESC NULLS FIRST,
-\tId ASC NULLS LAST    
+\tId ASC NULLS LAST
 `.trim(),
   },
   {
     testCase: 9,
     soql: `SELECT Id, Name, Foo, Bar, Baz, Bax, aaa, bbb, ccc, ddd, Id, Name, Foo, Bar, Baz, Bax, aaa, bbb, ccc, ddd, Id, Name, Foo, Bar, Baz, Bax, aaa, bbb, ccc, ddd, Account.Name, (SELECT Id, Name, Foo, Bar, Baz, Bax, aaa, bbb, ccc, ddd, Id, Name, Foo, Bar, Baz, Bax, aaa, bbb, ccc, ddd, Contact.LastName FROM Account.Contacts WHERE Id = '123' OR Id = '456' OR pimped = TRUE), baz, (SELECT Id FROM account WHERE Boo.baz = 'bar'), bax, bar FROM Account WHERE Id IN (SELECT AccountId FROM Contact WHERE LastName LIKE 'apple%') AND Foo = 'bar' OR Baz = 'boom' AND Id IN (SELECT AccountId FROM Opportunity WHERE isClosed = TRUE) ORDER BY GROUPING(Type), GROUPING(Id, BillingCountry), Name DESC NULLS FIRST, Id ASC NULLS LAST`,
-    formatOptions: { fieldMaxLineLength: 20, fieldSubqueryParensOnOwnLine: false, whereClauseOperatorsIndented: false },
+    formatOptions: { fieldMaxLineLength: 20, fieldSubqueryParensOnOwnLine: false },
     formattedSoql: `SELECT Id, Name, Foo, Bar,
 \tBaz, Bax, aaa, bbb, ccc,
 \tddd, Id, Name, Foo, Bar,
@@ -201,8 +207,8 @@ ORDER BY GROUPING(Type),
 \t\tddd, Contact.LastName
 \tFROM Account.Contacts
 \tWHERE Id = '123'
-\tOR Id = '456'
-\tOR pimped = TRUE),
+\t\tOR Id = '456'
+\t\tOR pimped = TRUE),
 \tbaz,
 \t(SELECT Id
 \tFROM account
@@ -212,9 +218,9 @@ FROM Account
 WHERE Id IN (SELECT AccountId
 \tFROM Contact
 \tWHERE LastName LIKE 'apple%')
-AND Foo = 'bar'
-OR Baz = 'boom'
-AND Id IN (SELECT AccountId
+\tAND Foo = 'bar'
+\tOR Baz = 'boom'
+\tAND Id IN (SELECT AccountId
 \tFROM Opportunity
 \tWHERE isClosed = TRUE)
 ORDER BY GROUPING(Type),
@@ -226,13 +232,13 @@ ORDER BY GROUPING(Type),
   {
     testCase: 10,
     soql: `SELECT Id, Name, Foo, Bar, Baz, Bax, aaa, bbb, ccc, ddd, Id, Name, Foo, Bar, Baz, Bax, aaa, bbb, ccc, ddd, Id, Name, Foo, Bar, Baz, Bax, aaa, bbb, ccc, ddd, Account.Name, (SELECT Id, Name, Foo, Bar, Baz, Bax, aaa, bbb, ccc, ddd, Id, Name, Foo, Bar, Baz, Bax, aaa, bbb, ccc, ddd, Contact.LastName FROM Account.Contacts WHERE Id = '123' OR Id = '456' OR pimped = TRUE), baz, (SELECT Id FROM account WHERE Boo.baz = 'bar'), bax, bar FROM Account WHERE Id IN (SELECT AccountId FROM Contact WHERE LastName LIKE 'apple%') AND Foo = 'bar' OR Baz = 'boom' AND Id IN (SELECT AccountId FROM Opportunity WHERE isClosed = TRUE) ORDER BY GROUPING(Type), GROUPING(Id, BillingCountry), Name DESC NULLS FIRST, Id ASC NULLS LAST`,
-    formatOptions: { fieldMaxLineLength: 170, fieldSubqueryParensOnOwnLine: false, whereClauseOperatorsIndented: false },
+    formatOptions: { fieldMaxLineLength: 170, fieldSubqueryParensOnOwnLine: false },
     formattedSoql: `SELECT Id, Name, Foo, Bar, Baz, Bax, aaa, bbb, ccc, ddd, Id, Name, Foo, Bar, Baz, Bax, aaa, bbb, ccc, ddd, Id, Name, Foo, Bar, Baz, Bax, aaa, bbb, ccc, ddd, Account.Name,
 \t(SELECT Id, Name, Foo, Bar, Baz, Bax, aaa, bbb, ccc, ddd, Id, Name, Foo, Bar, Baz, Bax, aaa, bbb, ccc, ddd, Contact.LastName
 \tFROM Account.Contacts
 \tWHERE Id = '123'
-\tOR Id = '456'
-\tOR pimped = TRUE),
+\t\tOR Id = '456'
+\t\tOR pimped = TRUE),
 \tbaz,
 \t(SELECT Id
 \tFROM account
@@ -242,9 +248,9 @@ FROM Account
 WHERE Id IN (SELECT AccountId
 \tFROM Contact
 \tWHERE LastName LIKE 'apple%')
-AND Foo = 'bar'
-OR Baz = 'boom'
-AND Id IN (SELECT AccountId
+\tAND Foo = 'bar'
+\tOR Baz = 'boom'
+\tAND Id IN (SELECT AccountId
 \tFROM Opportunity
 \tWHERE isClosed = TRUE)
 ORDER BY GROUPING(Type), GROUPING(Id, BillingCountry), Name DESC NULLS FIRST, Id ASC NULLS LAST    
@@ -253,8 +259,8 @@ ORDER BY GROUPING(Type), GROUPING(Id, BillingCountry), Name DESC NULLS FIRST, Id
   {
     testCase: 11,
     soql: `SELECT Id, Name, AccountNumber, AccountSource, AnnualRevenue, BillingAddress, BillingCity, BillingCountry, BillingGeocodeAccuracy, ShippingStreet, Sic, SicDesc, Site, SystemModstamp, TickerSymbol, Type, Website, (SELECT Id, Name, AccountId, Amount, CampaignId, CloseDate, CreatedById, Type FROM Opportunities), (SELECT Id, Name, AccountNumber, AccountSource, AnnualRevenue, BillingAddress, Website FROM ChildAccounts) FROM Account WHERE Name LIKE 'a%' OR Name LIKE 'b%' OR Name LIKE 'c%'`,
-    formatOptions: { fieldMaxLineLength: 0, fieldSubqueryParensOnOwnLine: true, whereClauseOperatorsIndented: false },
-    formattedSoql: `SELECT 
+    formatOptions: { fieldMaxLineLength: 0, fieldSubqueryParensOnOwnLine: true },
+    formattedSoql: `SELECT
 \tId,
 \tName,
 \tAccountNumber,
@@ -273,7 +279,7 @@ ORDER BY GROUPING(Type), GROUPING(Id, BillingCountry), Name DESC NULLS FIRST, Id
 \tType,
 \tWebsite,
 \t(
-\t\tSELECT 
+\t\tSELECT
 \t\t\tId,
 \t\t\tName,
 \t\t\tAccountId,
@@ -285,7 +291,7 @@ ORDER BY GROUPING(Type), GROUPING(Id, BillingCountry), Name DESC NULLS FIRST, Id
 \t\tFROM Opportunities
 \t),
 \t(
-\t\tSELECT 
+\t\tSELECT
 \t\t\tId,
 \t\t\tName,
 \t\t\tAccountNumber,
@@ -297,8 +303,130 @@ ORDER BY GROUPING(Type), GROUPING(Id, BillingCountry), Name DESC NULLS FIRST, Id
 \t)
 FROM Account
 WHERE Name LIKE 'a%'
-OR Name LIKE 'b%'
-OR Name LIKE 'c%'
+\tOR Name LIKE 'b%'
+\tOR Name LIKE 'c%'
+`.trim(),
+  },
+  {
+    testCase: 12,
+    soql:
+      'SELECT AccountNumber, (SELECT AccountNumber FROM ChildAccounts WHERE CreatedDate = 2017-04-05T10:41:42.000+0000) FROM Account WHERE CreatedDate >= 2017-04-05T10:41:42.000+0000 AND CreatedDate <= 2017-05-05T10:41:42.000+0000',
+    formatOptions: { newLineAfterKeywords: true, fieldMaxLineLength: 1 },
+    formattedSoql: `SELECT
+\tAccountNumber,
+\t(
+\t\tSELECT
+\t\t\tAccountNumber
+\t\tFROM
+\t\t\tChildAccounts
+\t\tWHERE
+\t\t\tCreatedDate = 2017-04-05T10:41:42.000+0000
+\t)
+FROM
+\tAccount
+WHERE
+\tCreatedDate >= 2017-04-05T10:41:42.000+0000
+\tAND CreatedDate <= 2017-05-05T10:41:42.000+0000
+`.trim(),
+  },
+  {
+    testCase: 13,
+    soql: `SELECT Id FROM Account WHERE (((Name = '1' OR Name = '2') AND Name = '3')) AND (((Description = '123') OR (Id = '1' AND Id = '2'))) AND Id = '1' ORDER BY Name, CreatedDate`,
+    formatOptions: { newLineAfterKeywords: true, fieldMaxLineLength: 1 },
+    formattedSoql: `SELECT
+\tId
+FROM
+\tAccount
+WHERE
+\t(
+\t\t(
+\t\t\t(
+\t\t\t\tName = '1'
+\t\t\t\tOR Name = '2'
+\t\t\t)
+\t\t\tAND Name = '3'
+\t\t)
+\t)
+\tAND (
+\t\t(
+\t\t\t(
+\t\t\t\tDescription = '123'
+\t\t\t)
+\t\t\tOR (
+\t\t\t\tId = '1'
+\t\t\t\tAND Id = '2'
+\t\t\t)
+\t\t)
+\t)
+\tAND Id = '1'
+ORDER BY
+\tName,
+\tCreatedDate
+`.trim(),
+  },
+  {
+    testCase: 14,
+    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')))`,
+    formatOptions: { newLineAfterKeywords: true, fieldMaxLineLength: 1 },
+    formattedSoql: `SELECT
+\tId
+FROM
+\tAccount
+WHERE
+\t(
+\t\tId IN ('1', '2', '3')
+\t\tOR (NOT Id = '2')
+\t\tOR (
+\t\t\tName LIKE '%FOO%'
+\t\t\tOR (
+\t\t\t\tName LIKE '%ARM%'
+\t\t\t\tAND FOO = 'bar'
+\t\t\t)
+\t\t)
+\t)
+`.trim(),
+  },
+  {
+    testCase: 15,
+    soql: `SELECT Id, TYPEOF What WHEN Account THEN Phone, NumberOfEmployees WHEN Opportunity THEN Amount, CloseDate ELSE Name, Email END, Name FROM Event`,
+    formatOptions: { newLineAfterKeywords: true, fieldMaxLineLength: 1 },
+    formattedSoql: `SELECT
+\tId,
+\tTYPEOF What
+\t\tWHEN
+\t\t\tAccount
+\t\tTHEN
+\t\t\tPhone, NumberOfEmployees
+\t\tWHEN
+\t\t\tOpportunity
+\t\tTHEN
+\t\t\tAmount, CloseDate
+\t\tELSE
+\t\t\tName, Email
+\tEND,
+\tName
+FROM
+\tEvent
+`.trim(),
+  },
+  {
+    testCase: 16,
+    soql: `SELECT Id, TYPEOF What WHEN Account THEN Phone, NumberOfEmployees WHEN Opportunity THEN Amount, CloseDate ELSE Name, Email END, Name FROM Event`,
+    formatOptions: { newLineAfterKeywords: false, fieldMaxLineLength: 60 },
+    formattedSoql: `SELECT Id,
+\tTYPEOF What WHEN Account THEN Phone, NumberOfEmployees WHEN Opportunity THEN Amount, CloseDate ELSE Name, Email END,
+\tName
+FROM Event
+`.trim(),
+  },
+  {
+    testCase: 17,
+    soql: `SELECT TYPEOF What WHEN Account THEN Phone, NumberOfEmployees WHEN Opportunity THEN Amount, CloseDate ELSE Name, Email END, Name FROM Event`,
+    formatOptions: { newLineAfterKeywords: false, fieldMaxLineLength: 60 },
+    formattedSoql: `SELECT
+\tTYPEOF What WHEN Account THEN Phone, NumberOfEmployees WHEN Opportunity THEN Amount, CloseDate ELSE Name, Email END,
+\tName
+FROM Event
 `.trim(),
   },
 ];
diff --git a/test/test.spec.ts b/test/test.spec.ts
index 387b7a7..20c9af7 100644
--- a/test/test.spec.ts
+++ b/test/test.spec.ts
@@ -22,7 +22,7 @@ const replacements = [{ matching: / last /i, replace: ' LAST ' }];
 // });
 
 // describe.only('compose queries', () => {
-//   const testCase = testCases.find(tc => tc.testCase === 32);
+//   const testCase = testCases.find(tc => tc.testCase === 104);
 //   it(`should compose correctly - test case ${testCase.testCase} - ${testCase.soql}`, () => {
 //     const soqlQuery = composeQuery(removeComposeOnlyFields(parseQuery(testCase.soql, testCase.options)));
 //     let soql = testCase.soqlComposed || testCase.soql;
@@ -53,7 +53,7 @@ const replacements = [{ matching: / last /i, replace: ' LAST ' }];
 // });
 
 // describe.only('format queries', () => {
-//   const testCase = testCasesForFormat.find(tc => tc.testCase === 5);
+//   const testCase = testCasesForFormat.find(tc => tc.testCase === 17);
 //   it(`should format query - test case ${testCase.testCase} - ${testCase.soql}`, () => {
 //     const formattedQuery = formatQuery(testCase.soql, testCase.formatOptions);
 //     expect(formattedQuery).equal(testCase.formattedSoql);