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

Issue:
Add support for WHEN clause
<!--
  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
Add support for `WHEN` conditions within fields

https://developer.salesforce.com/docs/atlas.en-us.soql_sosl.meta/soql_sosl/sforce_api_calls_soql_select_typeof.htm

```
SELECT TYPEOF What WHEN Account THEN Phone, NumberOfEmployees WHEN Opportunity THEN Amount, CloseDate ELSE Name, Email END FROM Event
```

Implement composeQuery
## Description
Implement `composeQuery` to turn a parsed query back into a query.

Pull request description:
Added compose
Added compose
Updated unit tests
Added support for typeof fields - resolves #20
Updated readme
Other minor bugfixes

resolves #16

Patch:
diff --git a/.gitignore b/.gitignore
index 1887cd1..d5218d9 100644
--- a/.gitignore
+++ b/.gitignore
@@ -3,7 +3,7 @@
 .vscode
 build/tests
 .rpt2_cache
-/dist
+dist/
 
 # Created by https://www.gitignore.io/api/node
 
diff --git a/README.md b/README.md
index 965da3d..897e1c9 100644
--- a/README.md
+++ b/README.md
@@ -3,28 +3,24 @@
 ## Description
 SOQL Parser JS will parse a SOQL query string into an object that is easy to work with and has the query broken down into usable parts.
 
-## TODO
-- [ ] Assess all property/function/variable names and make any adjustments as needed
-- [x] Analyze more SOQL parsing examples to ensure that output is appropriate
-- [ ] Include information on how to contribute
-- [x] Keep examples up-to-date as the package is finalized
-- [x] Figure out proper build/packaging for npm
-  - [x] ~~Consider Webpack for build~~
-- [x] Figure out how/if we can create a bundle that is browser compatible and include examples
-  - [ ] Provide instructions for using with node, in the browser, using TS and JS
-  - [ ] Figure out other builds (UMD - minified)
-- [x] Create typescript typings for the bundled JS
-- [x] Provide a GitHub pages example application
-## Future Idea List
-- [ ] Provide a CLI interface
-- [ ] Provide ability to turn parsed SOQL back to SOQL
+This works in the browser as long as npm is used to install the package with dependencies and the browser supports ES6 or a transpiler is used.
+
+*Warning*: antlr4 is a very large library and is required for the parser to function, so be aware of this prior to including in your browser bundles.
 
 ## Examples
 For an example of the parser, check out the [example application](https://paustint.github.io/soql-parser-js/).
 
-### Typescript / ES6
+## Usage
+
+### Available functions
+1. `parseQuery(soqlQueryString, options)`
+2. `composeQuery(SoqlQuery, options)`
+
+### Parse
+The parser takes a SOQL query and returns structured data.
+#### Typescript / ES6
 ```typescript
-import { parseQuery } from './SoqlParser';
+import { parseQuery } from 'soql-parser-js';
 
 const 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';
 
@@ -34,9 +30,9 @@ console.log(JSON.stringify(soqlQuery, null, 2));
 
 ```
 
-### Node
+#### Node
 ```javascript
-var soqlParserJs = require("soql-parser-js");
+var soqlParserJs = require('soql-parser-js');
 
 const 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';
 
@@ -83,8 +79,75 @@ This yields an object with the following structure:
   }
 }
 ```
+### compose
+Composing a query turns a parsed query back into a SOQL query. For some operators, they may be converted to upper case (e.x. NOT, AND)
+
+#### Typescript / ES6
+```typescript
+import { composeQuery } from 'soql-parser-js';
+
+const soqlQuery = {
+  fields: [
+    {
+      text: 'UserId',
+    },
+    {
+      fn: {
+        text: 'COUNT(Id)',
+        name: 'COUNT',
+        parameter: 'Id',
+      },
+    },
+  ],
+  subqueries: [],
+  sObject: 'LoginHistory',
+  whereClause: {
+    left: {
+      field: 'LoginTime',
+      operator: '>',
+      value: '2010-09-20T22:16:30.000Z',
+    },
+    operator: 'AND',
+    right: {
+      left: {
+        field: 'LoginTime',
+        operator: '<',
+        value: '2010-09-21T22:16:30.000Z',
+      },
+    },
+  },
+  groupBy: {
+    field: 'UserId',
+  },
+};
+
+const query = composeQuery(soqlQuery);
+
+console.log(query);
+
+```
+
+This yields an object with the following structure:
+
+```sql
+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
+```
+
+### Options
+
+```typescript
+export interface SoqlQueryConfig {
+  continueIfErrors?: boolean; // default=false
+  logging: boolean; // default=false
+  includeSubqueryAsField: boolean; // default=true
+}
+
+export interface SoqlComposeConfig {
+  logging: boolean; // default=false
+}
+```
 
-### Data Model of Parsed Data
+### Data Models
 ```typescript
 export type LogicalOperator = 'AND' | 'OR';
 export type Operator = '=' | '<=' | '>=' | '>' | '<' | 'LIKE' | 'IN' | 'NOT IN' | 'INCLUDES' | 'EXCLUDES';
diff --git a/lib/SoqlComposer.ts b/lib/SoqlComposer.ts
new file mode 100644
index 0000000..a8fdc3e
--- /dev/null
+++ b/lib/SoqlComposer.ts
@@ -0,0 +1,198 @@
+import {
+  Query,
+  Field,
+  FunctionExp,
+  WhereClause,
+  GroupByClause,
+  HavingClause,
+  OrderByClause,
+  TypeOfField,
+} from './models/SoqlQuery.model';
+import * as utils from './utils';
+
+export interface SoqlComposeConfig {
+  logging: boolean; // default=false
+}
+
+export function composeQuery(soql: Query, config: Partial<SoqlComposeConfig> = {}): string {
+  if (config.logging) {
+    console.time('parser');
+    console.log('Parsing Query:', soql);
+  }
+
+  const query = new Compose(soql, config).query;
+
+  if (config.logging) {
+    console.timeEnd('parser');
+  }
+
+  return query;
+}
+
+export class Compose {
+  private subqueryFieldRegex = /^{.+}$/;
+  private subqueryFieldReplaceRegex = /^{|}$/g;
+
+  public logging: boolean = false;
+  public query: string;
+
+  constructor(private soql: Query, config: Partial<SoqlComposeConfig> = {}) {
+    const { logging } = config;
+    this.logging = logging;
+    this.query = '';
+    this.start();
+  }
+
+  public start(): void {
+    this.query = this.parseQuery(this.soql);
+  }
+
+  private log(soql: string) {
+    if (this.logging) {
+      console.log('Current SOQL:', soql);
+    }
+  }
+
+  private parseQuery(query: Query): string {
+    let output = `SELECT`;
+    // Parse Fields
+    const fields = this.parseFields(query.fields);
+    // Replace subquery fields with parsed subqueries
+    fields.forEach((field, i) => {
+      if (field.match(this.subqueryFieldRegex)) {
+        const subquery = query.subqueries.find(
+          subquery => subquery.sObject === field.replace(this.subqueryFieldReplaceRegex, '')
+        );
+        if (subquery) {
+          fields[i] = `(${this.parseQuery(subquery)})`;
+        }
+      }
+    });
+    output += ` ${fields.join(', ').trim()} FROM`;
+    output += ` ${utils.get(query.sObjectPrefix, '.')}${query.sObject}${utils.get(query.sObjectAlias, '', ' ')}`;
+    this.log(output);
+
+    if (query.whereClause) {
+      output += ` WHERE ${this.parseWhereClause(query.whereClause)}`;
+      this.log(output);
+    }
+
+    // TODO: add WITH support https://github.com/paustint/soql-parser-js/issues/18
+
+    if (query.groupBy) {
+      output += ` GROUP BY ${this.parseGroupByClause(query.groupBy)}`;
+      this.log(output);
+      if (query.having) {
+        output += ` HAVING ${this.parseHavingClause(query.having)}`;
+        this.log(output);
+      }
+    }
+
+    if (query.orderBy) {
+      output += ` ORDER BY ${this.parseOrderBy(query.orderBy)}`;
+      this.log(output);
+    }
+
+    if (utils.isNumber(query.limit)) {
+      output += ` LIMIT ${query.limit}`;
+      this.log(output);
+    }
+
+    if (utils.isNumber(query.offset)) {
+      output += ` OFFSET ${query.offset}`;
+      this.log(output);
+    }
+
+    // TODO: add FOR support https://github.com/paustint/soql-parser-js/issues/19
+
+    return output;
+  }
+
+  private parseFields(fields: Field[]): string[] {
+    return fields
+      .map(field => {
+        if (utils.isString(field.text)) {
+          return `${utils.get(field.alias, '.')}${field.text}`;
+        } else if (utils.isObject(field.fn)) {
+          // parse fn
+          return this.parseFn(field.fn);
+        } else if (utils.isString(field.subqueryObjName)) {
+          // needs to be replaced with subquery
+          return `{${field.subqueryObjName}}`;
+        } else if (utils.isObject(field.typeOf)) {
+          return this.parseTypeOfField(field.typeOf);
+        }
+      })
+      .filter(field => !utils.isNil(field));
+  }
+
+  private parseTypeOfField(typeOfField: TypeOfField): 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`;
+    return output;
+  }
+
+  private parseFn(fn: FunctionExp): string {
+    return `${(fn.text || '').replace(/,/g, ', ')} ${fn.alias || ''}`.trim();
+  }
+
+  private parseWhereClause(where: WhereClause): string {
+    let output = '';
+    if (where.left) {
+      output +=
+        utils.isNumber(where.left.openParen) && where.left.openParen > 0
+          ? new Array(where.left.openParen).fill('(').join('')
+          : '';
+      output += `${utils.get(where.left.logicalPrefix, ' ')}`;
+      output += `${where.left.field} ${where.left.operator} ${utils.getAsArrayStr(where.left.value)}`;
+      output +=
+        utils.isNumber(where.left.closeParen) && where.left.closeParen > 0
+          ? new Array(where.left.closeParen).fill(')').join('')
+          : '';
+    }
+    if (where.right) {
+      return `${output} ${utils.get(where.operator)} ${this.parseWhereClause(where.right)}`.trim();
+    } else {
+      return output.trim();
+    }
+  }
+
+  private parseGroupByClause(groupBy: GroupByClause): string {
+    if (groupBy.type) {
+      return `${groupBy.type}${utils.getAsArrayStr(groupBy.field, true)}`;
+    } else {
+      return (Array.isArray(groupBy.field) ? groupBy.field : [groupBy.field]).join(', ');
+    }
+  }
+
+  private parseHavingClause(having: HavingClause): string {
+    let output = '';
+    if (having.left) {
+      output += new Array(having.left.openParen || 0).fill('(').join('');
+      output += having.left.fn ? this.parseFn(having.left.fn) : having.left.field;
+      output += ` ${having.left.operator} ${having.left.value}`;
+      output += new Array(having.left.closeParen || 0).fill(')').join('');
+    }
+    if (having.right) {
+      return `${output} ${utils.get(having.operator)} ${this.parseHavingClause(having.right)}`;
+    } else {
+      return output.trim();
+    }
+  }
+
+  private parseOrderBy(orderBy: OrderByClause | OrderByClause[]): string {
+    if (Array.isArray(orderBy)) {
+      return orderBy.map(ob => this.parseOrderBy(ob)).join(', ');
+    } else {
+      let output = `${utils.get(orderBy.field, ' ')}`;
+      output += orderBy.fn ? this.parseFn(orderBy.fn) : '';
+      output += `${utils.get(orderBy.order, ' ')}${utils.get(orderBy.nulls, '', 'NULLS ')}`;
+      return output.trim();
+    }
+  }
+}
diff --git a/lib/SoqlListener.ts b/lib/SoqlListener.ts
index c935ebf..d684e74 100644
--- a/lib/SoqlListener.ts
+++ b/lib/SoqlListener.ts
@@ -13,7 +13,7 @@ import {
 } from './models/SoqlQuery.model';
 import { SoqlQueryConfig } from './SoqlParser';
 
-export type currItem = 'field' | 'from' | 'where' | 'groupby' | 'orderby' | 'having';
+export type currItem = 'field' | 'typeof' | 'from' | 'where' | 'groupby' | 'orderby' | 'having';
 
 export interface Context {
   isSubQuery: boolean;
@@ -609,7 +609,6 @@ export class Listener implements SOQLListener {
       console.log('enterField_spec:', ctx.text);
     }
     this.context.currentItem = 'field';
-    let relatedFields: string[];
     if (ctx.text.includes('.')) {
       this.getSoqlQuery().fields.push({ text: ctx.text, relationshipFields: ctx.text.split('.') });
     } else {
@@ -717,11 +716,21 @@ export class Listener implements SOQLListener {
     if (this.config.logging) {
       console.log('enterTypeof_spec:', ctx.text);
     }
+    this.context.currentItem = 'typeof';
+    this.context.tempData = {
+      typeOf: {
+        field: ctx.getChild(1).text,
+        conditions: [],
+      },
+    };
   }
   exitTypeof_spec(ctx: Parser.Typeof_specContext) {
     if (this.config.logging) {
       console.log('exitTypeof_spec:', ctx.text);
     }
+    this.getSoqlQuery().fields.push(this.context.tempData);
+    this.context.tempData = null;
+    this.context.currentItem = 'field';
   }
   enterTypeof_when_then_clause_list(ctx: Parser.Typeof_when_then_clause_listContext) {
     if (this.config.logging) {
@@ -737,6 +746,10 @@ export class Listener implements SOQLListener {
     if (this.config.logging) {
       console.log('enterTypeof_when_then_clause:', ctx.text);
     }
+    this.context.tempData.typeOf.conditions.push({
+      type: 'WHEN',
+      objectType: ctx.getChild(1).text,
+    });
   }
   exitTypeof_when_then_clause(ctx: Parser.Typeof_when_then_clauseContext) {
     if (this.config.logging) {
@@ -747,6 +760,8 @@ export class Listener implements SOQLListener {
     if (this.config.logging) {
       console.log('enterTypeof_then_clause:', ctx.text);
     }
+    const whenThenClause = this.context.tempData.typeOf.conditions[this.context.tempData.typeOf.conditions.length - 1];
+    whenThenClause.fieldList = ctx.getChild(1).text.split(',');
   }
   exitTypeof_then_clause(ctx: Parser.Typeof_then_clauseContext) {
     if (this.config.logging) {
@@ -757,6 +772,10 @@ export class Listener implements SOQLListener {
     if (this.config.logging) {
       console.log('enterTypeof_else_clause:', ctx.text);
     }
+    this.context.tempData.typeOf.conditions.push({
+      type: 'ELSE',
+      fieldList: ctx.getChild(1).text.split(','),
+    });
   }
   exitTypeof_else_clause(ctx: Parser.Typeof_else_clauseContext) {
     if (this.config.logging) {
@@ -780,8 +799,10 @@ export class Listener implements SOQLListener {
     this.getSoqlQuery().sObject = ctx.getChild(0).text;
     if (this.config.includeSubqueryAsField && this.context.isSubQuery) {
       if (ctx.getChild(0).text.includes('.')) {
+        this.getSoqlQuery().sObject = ctx.getChild(1).text;
+        this.getSoqlQuery().sObjectPrefix = ctx.getChild(0).text.replace('.', '');
         this.soqlQuery.fields.push({
-          subqueryObjName: ctx.text,
+          subqueryObjName: ctx.getChild(1).text,
         });
       } else {
         this.soqlQuery.fields.push({
@@ -850,7 +871,6 @@ export class Listener implements SOQLListener {
       console.log('enterParenthesis:', ctx.text);
     }
     if (this.context.currentItem === 'where' || this.context.currentItem === 'having') {
-      this.context.tempData.nextHasCloseParen = false;
       this.context.tempData.nextHasOpenParen = true;
     }
   }
@@ -859,11 +879,9 @@ export class Listener implements SOQLListener {
       console.log('exitParenthesis:', ctx.text);
     }
     if (this.context.currentItem === 'where' || this.context.currentItem === 'having') {
-      if (this.context.tempData.nextHasCloseParen) {
-        this.context.tempData.stack.pop();
-      }
-      this.context.tempData.stack[this.context.tempData.stack.length - 1].left.closeParen = true;
-      this.context.tempData.nextHasCloseParen = true;
+      const currConditionOperation = this.context.tempData.currConditionOperation.left;
+      currConditionOperation.closeParen = currConditionOperation.closeParen || 0;
+      currConditionOperation.closeParen += 1;
     }
   }
   enterSimple_condition(ctx: Parser.Simple_conditionContext) {
@@ -888,7 +906,8 @@ export class Listener implements SOQLListener {
       if (!this.context.tempData.currConditionOperation.left) {
         this.context.tempData.currConditionOperation.left = currItem;
         if (this.context.tempData.nextHasOpenParen) {
-          currItem.openParen = true;
+          currItem.openParen = currItem.openParen || 0;
+          currItem.openParen += 1;
           this.context.tempData.nextHasOpenParen = false;
         }
         if (this.context.tempData.nextHasLogicalPrefix) {
@@ -907,7 +926,8 @@ export class Listener implements SOQLListener {
       if (!this.context.tempData.currConditionOperation.left) {
         this.context.tempData.currConditionOperation.left = currItem;
         if (this.context.tempData.nextHasOpenParen) {
-          currItem.openParen = true;
+          currItem.openParen = currItem.openParen || 0;
+          currItem.openParen += 1;
           this.context.tempData.nextHasOpenParen = false;
         }
         if (this.context.tempData.nextHasLogicalPrefix) {
@@ -942,7 +962,8 @@ export class Listener implements SOQLListener {
       if (!this.context.tempData.currConditionOperation.left) {
         this.context.tempData.currConditionOperation.left = currItem;
         if (this.context.tempData.nextHasOpenParen) {
-          currItem.openParen = true;
+          currItem.openParen = currItem.openParen || 0;
+          currItem.openParen += 1;
           this.context.tempData.nextHasOpenParen = false;
         }
         if (this.context.tempData.nextHasLogicalPrefix) {
@@ -975,7 +996,8 @@ export class Listener implements SOQLListener {
       if (!this.context.tempData.currConditionOperation.left) {
         this.context.tempData.currConditionOperation.left = currItem;
         if (this.context.tempData.nextHasOpenParen) {
-          currItem.openParen = true;
+          currItem.openParen = currItem.openParen || 0;
+          currItem.openParen += 1;
           this.context.tempData.nextHasOpenParen = false;
         }
         if (this.context.tempData.nextHasLogicalPrefix) {
diff --git a/lib/index.ts b/lib/index.ts
index f913d5e..44e8f23 100644
--- a/lib/index.ts
+++ b/lib/index.ts
@@ -3,4 +3,5 @@
  * The software in this package is published under the terms of the MIT license,
  * a copy of which has been included with this distribution in the LICENSE.txt file.
  */
-export * from './SoqlParser';
+export { parseQuery } from './SoqlParser';
+export { composeQuery } from './SoqlComposer';
diff --git a/lib/models/SoqlQuery.model.ts b/lib/models/SoqlQuery.model.ts
index 18041ef..dc50505 100644
--- a/lib/models/SoqlQuery.model.ts
+++ b/lib/models/SoqlQuery.model.ts
@@ -6,6 +6,7 @@ export interface Query {
   subqueries: Query[];
   sObject: string;
   sObjectAlias?: string;
+  sObjectPrefix?: string;
   whereClause?: WhereClause;
   limit?: number;
   offset?: number;
@@ -24,17 +25,29 @@ export interface Field {
   relationshipFields?: string[];
   fn?: FunctionExp;
   subqueryObjName?: string; // populated if subquery
+  typeOf?: TypeOfField;
+}
+
+export interface TypeOfField {
+  field: string;
+  conditions: TypeOfFieldCondition[];
+}
+
+export interface TypeOfFieldCondition {
+  type: 'WHEN' | 'ELSE';
+  objectType?: string; // not present when ELSE
+  fieldList: string[];
 }
 
 export interface WhereClause {
-  left: Condition | WhereClause;
-  right?: Condition | WhereClause;
+  left: Condition;
+  right?: WhereClause;
   operator?: LogicalOperator;
 }
 
 export interface Condition {
-  openParen?: boolean;
-  closeParen?: boolean;
+  openParen?: number;
+  closeParen?: number;
   logicalPrefix?: 'NOT';
   field: string;
   operator: Operator;
@@ -54,12 +67,14 @@ export interface GroupByClause {
 }
 
 export interface HavingClause {
-  left: HavingCondition | HavingClause;
-  right?: HavingCondition | HavingClause;
+  left: HavingCondition;
+  right?: HavingClause;
   operator?: LogicalOperator;
 }
 
 export interface HavingCondition {
+  openParen?: number;
+  closeParen?: number;
   field?: string;
   fn?: FunctionExp;
   operator: string;
diff --git a/lib/utils.ts b/lib/utils.ts
index 2e71ec4..d94ff9b 100644
--- a/lib/utils.ts
+++ b/lib/utils.ts
@@ -1,7 +1,13 @@
+import { isArray } from 'util';
+
 export function isString(val: any): boolean {
   return typeof val === 'string';
 }
 
+export function isNumber(val: any): boolean {
+  return Number.isFinite(val);
+}
+
 export function isBoolean(val: any): boolean {
   return typeof val === typeof true;
 }
@@ -13,3 +19,23 @@ export function isObject(val: any): boolean {
 export function isNil(val: any): boolean {
   return val === null || val === undefined;
 }
+
+export function get(val: string | null | undefined, suffix?: string, prefix?: string): string {
+  return isNil(val) ? '' : `${prefix || ''}${val}${suffix || ''}`;
+}
+
+export function getIfTrue(val: boolean | null | undefined, returnStr: string): string {
+  return isBoolean(val) && val ? returnStr : '';
+}
+
+export function getAsArrayStr(val: string | string[], alwaysParens: boolean = false): string {
+  if (isArray(val)) {
+    if (val.length > 0) {
+      return `(${val.join(', ')})`;
+    } else {
+      return alwaysParens ? '()' : '';
+    }
+  } else {
+    return alwaysParens ? `(${val || ''})` : val || '';
+  }
+}
diff --git a/rollup.config.js b/rollup.config.js
index a80e0da..c5463cb 100644
--- a/rollup.config.js
+++ b/rollup.config.js
@@ -9,15 +9,15 @@ export default [
       {
         file: pkg.main,
         format: 'cjs',
-        sourcemap: false,
+        sourcemap: true,
       },
       {
         file: pkg.module,
         format: 'es',
-        sourcemap: false,
+        sourcemap: true,
       },
     ],
     external: [...Object.keys(pkg.dependencies || {}), ...Object.keys(pkg.peerDependencies || {})],
-    plugins: [typescript({}), minify({ comments: false, sourceMap: false })],
+    plugins: [typescript({})],
   },
 ];
diff --git a/tsconfig.json b/tsconfig.json
index d67720e..cf08794 100644
--- a/tsconfig.json
+++ b/tsconfig.json
@@ -12,7 +12,7 @@
         "moduleResolution": "node",
         "noImplicitAny": true,
         "outDir": "dist",
-        "sourceMap": false,
+        "sourceMap": true,
         "target": "es2017"
     },
     "include": [


Test patch:
diff --git a/debug/test.js b/debug/test.js
index 4851084..1be70fc 100644
--- a/debug/test.js
+++ b/debug/test.js
@@ -1,10 +1,7 @@
 var soqlParserJs = require('../dist');
 
 const query = `
-SELECT a.Id, a.Name,
-(SELECT a2.Id FROM ChildAccounts a2),
-(SELECT a1.Id FROM ChildAccounts1 a1)
-FROM Account a
+SELECT Account.Name, (SELECT Contact.LastName FROM Account.Contacts) FROM Account
 `;
 
 const parsedQuery = soqlParserJs.parseQuery(query, { logging: true });
diff --git a/test/SoqlParser.spec.ts b/test/SoqlParser.spec.ts
index 328cf5c..fbce0ca 100644
--- a/test/SoqlParser.spec.ts
+++ b/test/SoqlParser.spec.ts
@@ -1,8 +1,17 @@
-import { parseQuery } from '../lib/SoqlParser';
+import { parseQuery, composeQuery } from '../lib';
 import { expect } from 'chai';
 import 'mocha';
 import testCases from './TestCases';
 
+const replacements = [
+  { matching: / and /i, replace: ' AND ' },
+  { matching: / or /i, replace: ' OR ' },
+  { matching: / asc /i, replace: ' ASC ' },
+  { matching: / desc /i, replace: ' DESC ' },
+  { matching: / first /i, replace: ' FIRST ' },
+  { matching: / last /i, replace: ' LAST ' },
+];
+
 describe('parse queries', () => {
   testCases.forEach(testCase => {
     it(`should parse correctly - test case ${testCase.testCase} - ${testCase.soql}`, () => {
@@ -11,3 +20,14 @@ describe('parse queries', () => {
     });
   });
 });
+
+describe('compose queries', () => {
+  testCases.forEach(testCase => {
+    it(`should compose correctly - test case ${testCase.testCase} - ${testCase.soql}`, () => {
+      const soqlQuery = composeQuery(parseQuery(testCase.soql));
+      let soql = testCase.soql;
+      replacements.forEach(replacement => (soql = soql.replace(replacement.matching, replacement.replace)));
+      expect(soqlQuery).equal(soql);
+    });
+  });
+});
diff --git a/test/TestCases.ts b/test/TestCases.ts
index 94cf172..fb167f8 100644
--- a/test/TestCases.ts
+++ b/test/TestCases.ts
@@ -322,7 +322,7 @@ export const testCases: TestCase[] = [
           relationshipFields: ['Account', 'Name'],
         },
         {
-          subqueryObjName: 'Account.Contacts',
+          subqueryObjName: 'Contacts',
         },
       ],
       subqueries: [
@@ -334,7 +334,8 @@ export const testCases: TestCase[] = [
             },
           ],
           subqueries: [],
-          sObject: 'Account.',
+          sObject: 'Contacts',
+          sObjectPrefix: 'Account',
         },
       ],
       sObject: 'Account',
@@ -444,7 +445,7 @@ export const testCases: TestCase[] = [
   },
   {
     testCase: 18,
-    soql: "SELECT Id, Owner.Name FROM Task WHERE Owner.FirstName like 'B%'",
+    soql: "SELECT Id, Owner.Name FROM Task WHERE Owner.FirstName LIKE 'B%'",
     output: {
       fields: [
         {
@@ -516,7 +517,29 @@ export const testCases: TestCase[] = [
     soql:
       'SELECT TYPEOF What WHEN Account THEN Phone, NumberOfEmployees WHEN Opportunity THEN Amount, CloseDate ELSE Name, Email END FROM Event',
     output: {
-      fields: [],
+      fields: [
+        {
+          typeOf: {
+            field: 'What',
+            conditions: [
+              {
+                type: 'WHEN',
+                objectType: 'Account',
+                fieldList: ['Phone', 'NumberOfEmployees'],
+              },
+              {
+                type: 'WHEN',
+                objectType: 'Opportunity',
+                fieldList: ['Amount', 'CloseDate'],
+              },
+              {
+                type: 'ELSE',
+                fieldList: ['Name', 'Email'],
+              },
+            ],
+          },
+        },
+      ],
       subqueries: [],
       sObject: 'Event',
     },
@@ -594,7 +617,7 @@ export const testCases: TestCase[] = [
   },
   {
     testCase: 24,
-    soql: 'SELECT UserId, LoginTime from LoginHistory',
+    soql: 'SELECT UserId, LoginTime FROM LoginHistory',
     output: {
       fields: [
         {
@@ -611,7 +634,7 @@ export const testCases: TestCase[] = [
   {
     testCase: 25,
     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',
+      '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',
     output: {
       fields: [
         {
@@ -867,7 +890,7 @@ export const testCases: TestCase[] = [
       sObject: 'Account',
       whereClause: {
         left: {
-          openParen: true,
+          openParen: 1,
           field: 'Id',
           operator: 'IN',
           value: ["'1'", "'2'", "'3'"],
@@ -875,30 +898,28 @@ export const testCases: TestCase[] = [
         operator: 'OR',
         right: {
           left: {
-            openParen: true,
+            openParen: 1,
             logicalPrefix: 'NOT',
             field: 'Id',
             operator: '=',
             value: "'2'",
-            closeParen: true,
+            closeParen: 1,
           },
           operator: 'OR',
           right: {
             left: {
-              openParen: true,
+              openParen: 1,
               field: 'Name',
               operator: 'LIKE',
               value: "'%FOO%'",
-              closeParen: true,
             },
             operator: 'OR',
             right: {
               left: {
-                openParen: true,
+                openParen: 1,
                 field: 'Name',
                 operator: 'LIKE',
                 value: "'%ARM%'",
-                closeParen: true,
               },
               operator: 'AND',
               right: {
@@ -906,7 +927,7 @@ export const testCases: TestCase[] = [
                   field: 'FOO',
                   operator: '=',
                   value: "'bar'",
-                  closeParen: true,
+                  closeParen: 3,
                 },
               },
             },
diff --git a/test/utils.spec.ts b/test/utils.spec.ts
index 3a22790..3cf5521 100644
--- a/test/utils.spec.ts
+++ b/test/utils.spec.ts
@@ -21,6 +21,24 @@ describe('isString', () => {
   });
 });
 
+describe('isNumber', () => {
+  it(`correctly determine number`, () => {
+    expect(utils.isNumber(-1)).equal(true);
+    expect(utils.isNumber(0)).equal(true);
+    expect(utils.isNumber(1)).equal(true);
+  });
+  it(`should correctly determine non-number`, () => {
+    expect(utils.isNumber(null)).equal(false);
+    expect(utils.isNumber(undefined)).equal(false);
+    expect(utils.isNumber(true)).equal(false);
+    expect(utils.isNumber(false)).equal(false);
+    expect(utils.isNumber([])).equal(false);
+    expect(utils.isNumber({})).equal(false);
+    expect(utils.isNumber(Infinity)).equal(false);
+    expect(utils.isNumber(NaN)).equal(false);
+  });
+});
+
 describe('isBoolean', () => {
   it(`correctly determine boolean`, () => {
     expect(utils.isBoolean(true)).equal(true);
@@ -80,3 +98,44 @@ describe('isNil', () => {
     expect(utils.isNil(NaN)).equal(false);
   });
 });
+
+describe('get', () => {
+  it(`correctly get value`, () => {
+    expect(utils.get('value')).equal('value');
+  });
+  it(`correctly get value with suffix`, () => {
+    expect(utils.get('value', '.')).equal('value.');
+  });
+  it(`correctly get value with suffix and prefix`, () => {
+    expect(utils.get('value', '.', '.')).equal('.value.');
+  });
+  it(`should correctly return empty string if no value`, () => {
+    expect(utils.get(null)).equal('');
+    expect(utils.get(null, '.')).equal('');
+    expect(utils.get(null, '.', '.')).equal('');
+    expect(utils.get(undefined)).equal('');
+    expect(utils.get(undefined, '.')).equal('');
+    expect(utils.get(undefined, '.', '.')).equal('');
+  });
+});
+
+describe('getIfTrue', () => {
+  it(`correctly get value`, () => {
+    expect(utils.getIfTrue(true, 'retVal')).equal('retVal');
+    expect(utils.getIfTrue(false, 'retVal')).equal('');
+  });
+});
+
+describe('getAsArrayStr', () => {
+  it(`correctly get value from array`, () => {
+    expect(utils.getAsArrayStr(['a', 'b'])).equal(`(a, b)`);
+    expect(utils.getAsArrayStr([])).equal(``);
+    expect(utils.getAsArrayStr([], true)).equal(`()`);
+  });
+  it(`correctly get value from string`, () => {
+    expect(utils.getAsArrayStr('a')).equal(`a`);
+    expect(utils.getAsArrayStr(null)).equal(``);
+    expect(utils.getAsArrayStr('')).equal(``);
+    expect(utils.getAsArrayStr(null, true)).equal(`()`);
+  });
+});