Repository: paustint/soql-parser-js
Pull request #183 (2022-03-11 20:26:26)

Add support for partial parse and compose
Added better support for partial queries.

resolves https://github.com/paustint/soql-parser-js/issues/182


Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index e998b3d..4d4561e 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,15 @@
 # Changelog
 
+## 4.4
+
+March 11, 2022
+
+- Add support for partial parse and compose #182
+  - Added support for parsing and composing partial queries. When parsing, the new option `allowPartialQuery` enables this functionality.
+  - Added a third argument for `formatQuery`, allowing `ParseQueryConfig` options to be provided.
+  - Some types on the `Query` interface were made optional to support partial queries
+  - Updated CLI to include additional commands
+
 ## 4.3
 
 September 19, 2021
diff --git a/README.md b/README.md
index a96849e..b17ae29 100644
--- a/README.md
+++ b/README.md
@@ -15,8 +15,6 @@
 3. Validate a query to check if the syntax is valid.
    1. _Even if a query is returned as valid, it might still be invalid based on your Salesforce configuration_
 
-This library uses [Chevrotain](https://github.com/SAP/chevrotain) to parse queries. Prior to version 2.0.0, [antlr4](https://github.com/antlr/antlr4) was used.
-
 Migrating from version 1 to version 2? [Check out the changelog](CHANGELOG.md#200) for a full list of changes.
 
 Migrating from version 2 to version 3? [Check out the changelog](CHANGELOG.md#300) for a full list of changes.
@@ -58,7 +56,7 @@ isQueryValid('SELECT Id Foo FROM Baz'); // false
 
 **General Utility**
 
-Many of hte utility functions are provided to easily determine the shape of specific data since there are many variations. If you are using Typescript in strict mode, you can use these to narrow types with if statements.
+Many of hte utility functions are provided to easily determine the shape of specific data since there are many variants. If you are using Typescript in strict mode, you can use these to narrow your types.
 
 | Function                                | Description                                                                                                                                                                                                             | Arguments                                                                   |
 | --------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------- |
@@ -86,6 +84,7 @@ Many of hte utility functions are provided to easily determine the shape of spec
 | Property               | Type    | Description                                                                                                                                                                                                                                   | required | default |
 | ---------------------- | ------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------- | ------- |
 | allowApexBindVariables | boolean | Determines if apex variables are allowed in parsed query. Example: `WHERE Id IN :accountIds`. Only simple Apex is supported. Function calls are not supported. (e.x. `accountMap.keyset()` is not supported)                                  | FALSE    | FALSE   |
+| allowPartialQuery      | boolean | If provided, you can provide an incomplete soql query. This is useful if you need to parse WHERE clauses, for example. Subqueries are required to be valid.                                                                                   | FALSE    | FALSE   |
 | ignoreParseErrors      | boolean | If set to true, then queries with partially invalid syntax will still be parsed, but any clauses with invalid parts will be omitted. The SELECT clause and FROM clause must always be valid, but all other clauses can contain invalid parts. | FALSE    | FALSE   |
 | logErrors              | boolean | If true, parsing and lexing errors will be logged to the console.                                                                                                                                                                             | FALSE    | FALSE   |
 
@@ -117,7 +116,6 @@ Parsing a SOQL query can be completed by calling `parseQuery(soqlQueryString)`.
 
 ```typescript
 import { parseQuery } from 'soql-parser-js';
-// var soqlParserJs = require('soql-parser-js'); // node's require format - usage: soqlParserJs.parseQuery()
 
 const soql = `
   SELECT UserId, COUNT(Id)
@@ -127,8 +125,6 @@ const soql = `
   GROUP BY UserId
 `;
 
-const soqlQuery = parseQuery(soql);
-
 console.log(JSON.stringify(soqlQuery, null, 2));
 ```
 
@@ -176,6 +172,54 @@ console.log(JSON.stringify(soqlQuery, null, 2));
 
 </details>
 
+### Parsing a partial query
+
+Added support for `allowPartialQuery` in version `4.4.0`
+
+```typescript
+import { parseQuery } from 'soql-parser-js';
+
+const soql = `
+  WHERE LoginTime > 2010-09-20T22:16:30.000Z
+  AND LoginTime < 2010-09-21T22:16:30.000Z
+  GROUP BY UserId
+`;
+
+const soqlQuery = parseQuery(soql, { allowPartialQuery: true });
+
+console.log(JSON.stringify(soqlQuery, null, 2));
+```
+
+<details>
+  <summary><b>Results (click to show)</b></summary>
+
+```json
+{
+  "where": {
+    "left": {
+      "field": "LoginTime",
+      "operator": ">",
+      "value": "2010-09-20T22:16:30.000Z",
+      "literalType": "DATETIME"
+    },
+    "operator": "AND",
+    "right": {
+      "left": {
+        "field": "LoginTime",
+        "operator": "<",
+        "value": "2010-09-21T22:16:30.000Z",
+        "literalType": "DATETIME"
+      }
+    }
+  },
+  "groupBy": {
+    "field": "UserId"
+  }
+}
+```
+
+</details>
+
 ### Validating Queries
 
 ```typescript
@@ -278,6 +322,41 @@ LIMIT 150
 
 ### Composing a partial query
 
+Starting in version `4.4`, compose will not fail if there are missing `SELECT` and `FROM` clauses in your query.
+
+Partial compose support it supported without any additional steps.
+
+```typescript
+import { Compose, parseQuery } from 'soql-parser-js';
+
+const soql = `WHERE Name LIKE 'A%' AND MailingCity = 'California`;
+const parsedQuery = parseQuery(soql, { allowPartialQuery: true });
+
+// Results of Parsed Query:
+/**
+{
+  where: {
+    left: { field: 'Name', operator: 'LIKE', value: "'A%'", literalType: 'STRING' },
+    operator: 'AND',
+    right: { left: { field: 'MailingCity', operator: '=', value: "'California'", literalType: 'STRING' } },
+  },
+}
+*/
+
+const composedQuery = composeQuery(soqlQuery, { format: true });
+
+console.log(composedQuery);
+```
+
+**Results**
+
+```sql
+WHERE Name LIKE 'A%' AND MailingCity = 'California
+```
+
+<details>
+  <summary><b>See the alternate way to compose partial queries by calling the Compose class directly</b></summary>
+
 If you need to compose just a part of a query instead of the entire query, you can create an instance of the Compose class directly.
 
 For example, if you just need the `WHERE` clause from a query as a string, you can do the following:
@@ -331,6 +410,8 @@ parseOrderBy(orderBy: OrderByClause | OrderByClause[]): string;
 parseWithDataCategory(withDataCategory: WithDataCategoryClause): string;
 ```
 
+</details>
+
 ## Format Query
 
 This function is provided as a convenience and just calls parse and compose.
@@ -418,11 +499,11 @@ WHERE Name LIKE 'a%'
 
 ## CLI
 
-If you install globally, you can use the cli
+Install globally or use `npx` to interact with the cli.
 
 ### Available Commands
 
-- `soql-parser-js --help`
+- `soql-parser-js --help` (or using `npx`: `npx soql-parser-js --help`)
 - `soql-parser-js parse --help`
 - `soql-parser-js compose --help`
 - `soql-parser-js format --help`
@@ -431,7 +512,7 @@ If you install globally, you can use the cli
 
 #### Parse
 
-`soql-parser-js parse "SELECT Id FROM Account"`
+`npx soql-parser-js parse "SELECT Id FROM Account"`
 
 ```bash
 {"fields":[{"type":"Field","field":"Id"}],"sObject":"Account"}
@@ -439,13 +520,13 @@ If you install globally, you can use the cli
 
 #### Compose
 
-`soql-parser-js compose "{\"fields\":[{\"type\":\"Field\",\"field\":\"Id\"}],\"sObject\":\"Account\"}"`
+`npx soql-parser-js compose "{\"fields\":[{\"type\":\"Field\",\"field\":\"Id\"}],\"sObject\":\"Account\"}"`
 
 ```bash
 SELECT Id FROM Account
 ```
 
-`soql-parser-js compose "{\"fields\":[{\"type\":\"Field\",\"field\":\"Id\"}],\"sObject\":\"Account\"}" --json` or -j
+`npx soql-parser-js compose "{\"fields\":[{\"type\":\"Field\",\"field\":\"Id\"}],\"sObject\":\"Account\"}" --json` or -j
 
 ```json
 { "query": "SELECT Id FROM Account" }
@@ -453,7 +534,7 @@ SELECT Id FROM Account
 
 #### Format
 
-`soql-parser-js format "SELECT Name, COUNT(Id) FROM Account GROUP BY Name HAVING COUNT(Id) > 1"`
+`npx soql-parser-js format "SELECT Name, COUNT(Id) FROM Account GROUP BY Name HAVING COUNT(Id) > 1"`
 
 ```bash
 SELECT Name, COUNT(Id)
@@ -462,7 +543,7 @@ GROUP BY Name
 HAVING COUNT(Id) > 1
 ```
 
-`soql-parser-js format "SELECT Name, COUNT(Id) FROM Account GROUP BY Name HAVING COUNT(Id) > 1 -j`
+`npx soql-parser-js format "SELECT Name, COUNT(Id) FROM Account GROUP BY Name HAVING COUNT(Id) > 1 -j`
 
 ```json
 { "query": "SELECT Name, COUNT(Id)\nFROM Account\nGROUP BY Name\nHAVING COUNT(Id) > 1" }
@@ -470,13 +551,13 @@ HAVING COUNT(Id) > 1
 
 #### Is Valid
 
-`soql-parser-js valid "SELECT Id FROM Account"`
+`npx soql-parser-js valid "SELECT Id FROM Account"`
 
 ```bash
 true
 ```
 
-`soql-parser-js valid "SELECT Id invalid FROM Account"`
+`npx soql-parser-js valid "SELECT Id invalid FROM Account"`
 
 ℹ️ this returns an exit code of 1
 
@@ -484,13 +565,13 @@ true
 false
 ```
 
-`soql-parser-js valid "SELECT Id FROM Account -j`
+`npx soql-parser-js valid "SELECT Id FROM Account" -j`
 
 ```json
 { "isValid": true }
 ```
 
-`soql-parser-js valid "SELECT Id invalid invalid FROM Account -j`
+`npx soql-parser-js valid "SELECT Id invalid invalid FROM Account" -j`
 
 ℹ️ this returns an exit code of 0
 
@@ -509,19 +590,20 @@ Options:
   -h, --help                 output usage information
 
 Commands:
-  parse [options] <query>
+  parse [options] <sql>
   compose [options] <query>
-  format [options] <query>
-  valid <query>
+  format [options] <sql>
+  valid <sql>
 ```
 
 `soql-parser-js parse --help`
 
 ```bash
-Usage: parse [options] <query>
+Usage: parse [options] <sql>
 
 Options:
   -a, --allow-apex     allow apex bind variables
+  -p, --allow-partial  allow partial queries
   -i, --ignore-errors  ignore parse errors, return as much of query as possible
   -h, --help           output usage information
 ```
@@ -544,9 +626,11 @@ Options:
 `soql-parser-js format --help`
 
 ```bash
-Usage: format [options] <query>
+Usage: format [options] <sql>
 
 Options:
+  -a, --allow-apex     allow apex bind variables
+  -p, --allow-partial  allow partial queries
   -i --indent <chars>            number of tab characters to indent (default: 1)
   -m --line-length <chars>       max number of characters per lins (default: 60)
   -s --subquery-parens-new-line  subquery parens on own line
@@ -558,9 +642,11 @@ Options:
 `soql-parser-js valid --help`
 
 ```bash
-Usage: valid [options] <query>
+Usage: valid [options] <sql>
 
 Options:
+  -a, --allow-apex     allow apex bind variables
+  -p, --allow-partial  allow partial queries
   -j, --json  output as JSON
   -h, --help  output usage information
 ```
@@ -699,7 +785,7 @@ export interface FieldTypeOfCondition {
 }
 
 export interface QueryBase {
-  fields: FieldType[];
+  fields?: FieldType[];
   sObjectAlias?: string;
   usingScope?: string;
   where?: WhereClause;
@@ -714,7 +800,7 @@ export interface QueryBase {
 }
 
 export interface Query extends QueryBase {
-  sObject: string;
+  sObject?: string;
 }
 
 export interface Subquery extends QueryBase {
diff --git a/cli/index.ts b/cli/index.ts
index a92b633..f37d341 100644
--- a/cli/index.ts
+++ b/cli/index.ts
@@ -1,12 +1,16 @@
 import { parseQuery, composeQuery, isQueryValid, formatQuery, FormatOptions } from '../src';
 import { Command } from 'commander';
+import { ParseQueryConfig } from '../src/parser/parser';
 
 interface ParseCommandActions {
   allowApex: boolean;
+  allowPartial: boolean;
   ignoreErrors: boolean;
 }
 
 interface ComposeCommandOptions {
+  allowApex: boolean;
+  allowPartial: boolean;
   format: boolean;
   indent: number;
   lineLength: number;
@@ -16,19 +20,21 @@ interface ComposeCommandOptions {
 }
 
 type FormatCommandOptions = Omit<ComposeCommandOptions, 'format'>;
-type IsValidCommandOptions = Pick<ComposeCommandOptions, 'json'>;
+type IsValidCommandOptions = Pick<ComposeCommandOptions, 'allowApex' | 'allowPartial' | 'json'>;
 
 const program = new Command();
 
 program
-  .command('parse <query>')
+  .command('parse <soql>')
   .option('-a, --allow-apex', 'allow apex bind variables')
+  .option('-p, --allow-partial', 'allow partial queries')
   .option('-i, --ignore-errors', 'ignore parse errors, return as much of query as possible')
   .action((query: string, options: ParseCommandActions) => {
     console.log(
       JSON.stringify(
         parseQuery(query, {
           allowApexBindVariables: options.allowApex,
+          allowPartialQuery: options.allowPartial,
           ignoreParseErrors: options.ignoreErrors,
         }),
       ),
@@ -70,14 +76,25 @@ program
   });
 
 program
-  .command('format <query>')
+  .command('format <soql>')
+  .option('-a, --allow-apex', 'allow apex bind variables')
+  .option('-p, --allow-partial', 'allow partial queries')
   .option('-i --indent <chars>', 'number of tab characters to indent', 1)
   .option('-m --line-length <chars>', 'max number of characters per lins', 60)
   .option('-s --subquery-parens-new-line', 'subquery parens on own line')
   .option('-k --keywords-new-line', 'new line after keywords')
   .option('-j, --json', 'output as JSON')
   .action((query: string, options: FormatCommandOptions) => {
+    const parseQueryConfig: ParseQueryConfig = {};
     const formatOptions: FormatOptions = {};
+
+    if (options.allowApex) {
+      parseQueryConfig.allowApexBindVariables = true;
+    }
+    if (options.allowPartial) {
+      parseQueryConfig.allowPartialQuery = true;
+    }
+
     if (options.indent) {
       formatOptions.numIndent = options.indent;
     }
@@ -94,7 +111,7 @@ program
       formatOptions.newLineAfterKeywords = options.keywordsNewLine;
     }
 
-    const output = formatQuery(query, formatOptions);
+    const output = formatQuery(query, formatOptions, parseQueryConfig);
     if (options.json) {
       console.log(JSON.stringify({ query: output }));
     } else {
@@ -103,10 +120,21 @@ program
   });
 
 program
-  .command('valid <query>')
+  .command('valid <soql>')
+  .option('-a, --allow-apex', 'allow apex bind variables')
+  .option('-p, --allow-partial', 'allow partial queries')
   .option('-j, --json', 'output as JSON')
   .action((query: string, options: IsValidCommandOptions) => {
-    const isValid = isQueryValid(query);
+    const parseQueryConfig: ParseQueryConfig = {};
+
+    if (options.allowApex) {
+      parseQueryConfig.allowApexBindVariables = true;
+    }
+    if (options.allowPartial) {
+      parseQueryConfig.allowPartialQuery = true;
+    }
+
+    const isValid = isQueryValid(query, parseQueryConfig);
     if (options.json) {
       console.log(JSON.stringify({ isValid }));
     } else {
diff --git a/docs/package-lock.json b/docs/package-lock.json
index f31ff57..486bdee 100644
--- a/docs/package-lock.json
+++ b/docs/package-lock.json
@@ -15,7 +15,7 @@
         "gh-pages": "^2.2.0",
         "highlight.js": "^9.18.1",
         "prismjs": "^1.19.0",
-        "soql-parser-js": "^4.3.1",
+        "soql-parser-js": "^4.4.0",
         "tailwindcss": "^1.2.0"
       },
       "devDependencies": {
@@ -22735,9 +22735,9 @@
       }
     },
     "node_modules/soql-parser-js": {
-      "version": "4.3.1",
-      "resolved": "https://registry.npmjs.org/soql-parser-js/-/soql-parser-js-4.3.1.tgz",
-      "integrity": "sha512-3kkHTPsrpowheso79EB2KjeW0hzriwNy9nL2vmCr99YDBqixfcTJY6/+MgZZzCGaekBQdDCYk3jhLZfZCfHWlA==",
+      "version": "4.4.0",
+      "resolved": "https://registry.npmjs.org/soql-parser-js/-/soql-parser-js-4.4.0.tgz",
+      "integrity": "sha512-QG63VwbUbjXvs5zw01FA23g6En9Bfjig1Z7Yw04erXeO71kUAUte5jdbPMG6NWeGsh4bZS6Dd/Z6OH/md4LZsA==",
       "dependencies": {
         "chevrotain": "^9.0.1",
         "commander": "^2.20.3",
@@ -44768,9 +44768,9 @@
       }
     },
     "soql-parser-js": {
-      "version": "4.3.1",
-      "resolved": "https://registry.npmjs.org/soql-parser-js/-/soql-parser-js-4.3.1.tgz",
-      "integrity": "sha512-3kkHTPsrpowheso79EB2KjeW0hzriwNy9nL2vmCr99YDBqixfcTJY6/+MgZZzCGaekBQdDCYk3jhLZfZCfHWlA==",
+      "version": "4.4.0",
+      "resolved": "https://registry.npmjs.org/soql-parser-js/-/soql-parser-js-4.4.0.tgz",
+      "integrity": "sha512-QG63VwbUbjXvs5zw01FA23g6En9Bfjig1Z7Yw04erXeO71kUAUte5jdbPMG6NWeGsh4bZS6Dd/Z6OH/md4LZsA==",
       "requires": {
         "chevrotain": "^9.0.1",
         "commander": "^2.20.3",
diff --git a/docs/package.json b/docs/package.json
index 43968a1..89b8ab4 100644
--- a/docs/package.json
+++ b/docs/package.json
@@ -11,7 +11,7 @@
     "gh-pages": "^2.2.0",
     "highlight.js": "^9.18.1",
     "prismjs": "^1.19.0",
-    "soql-parser-js": "^4.3.1",
+    "soql-parser-js": "^4.4.0",
     "tailwindcss": "^1.2.0"
   },
   "devDependencies": {
diff --git a/docs/src/modules/my/queryParser/queryParser.html b/docs/src/modules/my/queryParser/queryParser.html
index 577a5f8..d232195 100644
--- a/docs/src/modules/my/queryParser/queryParser.html
+++ b/docs/src/modules/my/queryParser/queryParser.html
@@ -11,6 +11,7 @@
         label="Allow apex bind variables"
         onchange={handleChange}
       ></ui-checkbox>
+      <ui-checkbox name="allowPartialQuery" value={allowPartialQuery} label="Allow partial queries" onchange={handleChange}></ui-checkbox>
       <ui-checkbox name="ignoreParseErrors" value={ignoreParseErrors} label="Ignore parsing errors" onchange={handleChange}></ui-checkbox>
     </div>
     <div class="mb-1">
diff --git a/docs/src/modules/my/queryParser/queryParser.ts b/docs/src/modules/my/queryParser/queryParser.ts
index 83cbf8a..29a3ad3 100644
--- a/docs/src/modules/my/queryParser/queryParser.ts
+++ b/docs/src/modules/my/queryParser/queryParser.ts
@@ -22,6 +22,7 @@ export default class QueryParser extends LightningElement {
   @track parsedQueryJson: string;
   @track composedQuery: string;
   @track allowApexBindVariables = false;
+  @track allowPartialQuery = false;
   @track ignoreParseErrors = false;
   hasError = false;
   hasRendered = false;
@@ -37,6 +38,7 @@ export default class QueryParser extends LightningElement {
     try {
       this.parsedQuery = parseQuery(this._query || '', {
         allowApexBindVariables: this.allowApexBindVariables,
+        allowPartialQuery: this.allowPartialQuery,
         ignoreParseErrors: this.ignoreParseErrors,
         logErrors: true
       });
@@ -53,7 +55,7 @@ export default class QueryParser extends LightningElement {
   setExampleJs() {
     // @ts-ignore type-mismatch
     const element = this.template.querySelector('code.javascript');
-    element.innerText = `parseQuery(soqlQuery, { allowApexBindVariables: ${this.allowApexBindVariables}, ignoreParseErrors: ${this.ignoreParseErrors} });`;
+    element.innerText = `parseQuery(soqlQuery, { allowApexBindVariables: ${this.allowApexBindVariables}, allowPartialQuery: ${this.allowPartialQuery}, ignoreParseErrors: ${this.ignoreParseErrors} });`;
     hljs.highlightBlock(element);
   }
 
@@ -73,6 +75,10 @@ export default class QueryParser extends LightningElement {
         this.allowApexBindVariables = value;
         break;
       }
+      case 'allowPartialQuery': {
+        this.allowPartialQuery = value;
+        break;
+      }
       case 'ignoreParseErrors': {
         this.ignoreParseErrors = value;
         break;
diff --git a/package-lock.json b/package-lock.json
index 12a1ae5..e98db54 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,11 +1,11 @@
 {
   "name": "soql-parser-js",
-  "version": "4.3.1",
+  "version": "4.4.0",
   "lockfileVersion": 2,
   "requires": true,
   "packages": {
     "": {
-      "version": "4.3.1",
+      "version": "4.4.0",
       "license": "MIT",
       "dependencies": {
         "chevrotain": "^9.0.1",
@@ -13,7 +13,7 @@
         "lodash.get": "^4.4.2"
       },
       "bin": {
-        "soql-parser-js": "bin/index"
+        "soql-parser-js": "bin/soql-parser-js"
       },
       "devDependencies": {
         "@types/chai": "^4.2.3",
diff --git a/package.json b/package.json
index 14993c9..d798230 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
 {
   "name": "soql-parser-js",
-  "version": "4.3.1",
+  "version": "4.4.0",
   "description": "Salesforce.com SOQL parser and composer",
   "main": "dist/index.js",
   "module": "dist_esm/index.js",
diff --git a/src/api/api-models.ts b/src/api/api-models.ts
index 6bbda4d..a326738 100644
--- a/src/api/api-models.ts
+++ b/src/api/api-models.ts
@@ -127,7 +127,7 @@ export interface FieldTypeOfCondition {
 }
 
 export interface QueryBase {
-  fields: FieldType[];
+  fields?: FieldType[];
   sObjectAlias?: string;
   usingScope?: string;
   where?: WhereClause;
@@ -143,7 +143,7 @@ export interface QueryBase {
 }
 
 export interface Query extends QueryBase {
-  sObject: string;
+  sObject?: string;
 }
 
 export interface Subquery extends QueryBase {
diff --git a/src/composer/composer.ts b/src/composer/composer.ts
index 13c5aaa..2790f77 100644
--- a/src/composer/composer.ts
+++ b/src/composer/composer.ts
@@ -14,6 +14,7 @@ import {
 import * as utils from '../utils';
 import { FieldData, Formatter, FormatOptions } from '../formatter/formatter';
 import { parseQuery } from '../parser/visitor';
+import { ParseQueryConfig } from '../parser/parser';
 
 export interface SoqlComposeConfig {
   logging: boolean; // default=false
@@ -28,8 +29,8 @@ export interface SoqlComposeConfig {
  * @param [formatOptions]
  * @returns
  */
-export function formatQuery(soql: string, formatOptions?: FormatOptions) {
-  return composeQuery(parseQuery(soql), { format: true, formatOptions });
+export function formatQuery(soql: string, formatOptions?: FormatOptions, parseOptions?: ParseQueryConfig) {
+  return composeQuery(parseQuery(soql, parseOptions), { format: true, formatOptions });
 }
 
 /**
@@ -137,7 +138,7 @@ export class Compose {
    */
   public parseQuery(query: Query | Subquery): string {
     const fieldData: FieldData = {
-      fields: this.parseFields(query.fields).map(field => ({
+      fields: this.parseFields(query.fields || []).map(field => ({
         text: field.text,
         typeOfClause: field.typeOfClause,
         isSubquery: field.text.startsWith('('),
@@ -148,7 +149,11 @@ export class Compose {
       lineBreaks: [],
     };
 
-    let output = this.formatter.formatClause('SELECT').trimStart();
+    let output = '';
+
+    if (query.fields) {
+      output += this.formatter.formatClause('SELECT').trimStart();
+    }
 
     // Format fields based on configuration
     this.formatter.formatFields(fieldData);
@@ -163,13 +168,15 @@ export class Compose {
     });
     output += this.formatter.formatText(fieldsOutput);
 
-    output += this.formatter.formatClause('FROM');
+    if (!!(utils.isSubquery(query) ? query.relationshipName : query.sObject)) {
+      output += this.formatter.formatClause('FROM');
+    }
 
     if (utils.isSubquery(query)) {
       const sObjectPrefix = query.sObjectPrefix || [];
       sObjectPrefix.push(query.relationshipName);
       output += this.formatter.formatText(`${sObjectPrefix.join('.')}${utils.get(query.sObjectAlias, '', ' ')}`);
-    } else {
+    } else if (query.sObject) {
       output += this.formatter.formatText(`${query.sObject}${utils.get(query.sObjectAlias, '', ' ')}`);
     }
     this.log(output);
@@ -238,7 +245,7 @@ export class Compose {
       this.log(output);
     }
 
-    return output;
+    return output.trim();
   }
 
   /**
diff --git a/src/models.ts b/src/models.ts
index 3363946..cf30803 100644
--- a/src/models.ts
+++ b/src/models.ts
@@ -24,6 +24,10 @@ interface WithIdentifier {
 export interface SelectStatementContext {
   selectClause: CstNode[];
   fromClause: CstNode[];
+  clauseStatements: CstNode[];
+}
+
+export interface ClauseStatementsContext {
   usingScopeClause?: CstNode[];
   whereClause?: CstNode[];
   withClause?: CstNode[];
diff --git a/src/parser/parser.ts b/src/parser/parser.ts
index 7a0cc25..e18d80e 100644
--- a/src/parser/parser.ts
+++ b/src/parser/parser.ts
@@ -1,7 +1,8 @@
-import { CstParser, ILexingError, IRecognitionException } from 'chevrotain';
+import { CstNode, CstParser, ILexingError, IRecognitionException } from 'chevrotain';
 import * as lexer from './lexer';
 
 export interface ParseQueryConfig {
+  allowPartialQuery?: boolean;
   allowApexBindVariables?: boolean;
   ignoreParseErrors?: boolean;
   logErrors?: boolean;
@@ -58,6 +59,20 @@ export class SoqlParser extends CstParser {
   public selectStatement = this.RULE('selectStatement', () => {
     this.SUBRULE(this.selectClause);
     this.SUBRULE(this.fromClause);
+    this.SUBRULE(this.clauseStatements);
+  });
+
+  public selectStatementPartial = this.RULE('selectStatementPartial', () => {
+    this.OPTION(() => {
+      this.SUBRULE(this.selectClause);
+    });
+    this.OPTION1(() => {
+      this.SUBRULE(this.fromClause);
+    });
+    this.SUBRULE(this.clauseStatements);
+  });
+
+  public clauseStatements = this.RULE('clauseStatements', () => {
     this.OPTION(() => {
       this.SUBRULE(this.usingScopeClause);
     });
@@ -783,7 +798,7 @@ export class SoqlParser extends CstParser {
 let parser = new SoqlParser();
 
 export function parse(soql: string, options?: ParseQueryConfig) {
-  const { allowApexBindVariables, logErrors, ignoreParseErrors } = options || {
+  const { allowApexBindVariables, logErrors, ignoreParseErrors, allowPartialQuery } = options || {
     allowApexBindVariables: false,
     logErrors: false,
     ignoreParseErrors: false,
@@ -810,7 +825,12 @@ export function parse(soql: string, options?: ParseQueryConfig) {
   // If true, allows WHERE foo = :bar
   parser.allowApexBindVariables = allowApexBindVariables || false;
 
-  const cst = parser.selectStatement();
+  let cst: CstNode;
+  if (allowPartialQuery) {
+    cst = parser.selectStatementPartial();
+  } else {
+    cst = parser.selectStatement();
+  }
 
   if (parser.errors.length > 0) {
     if (logErrors) {
diff --git a/src/parser/visitor.ts b/src/parser/visitor.ts
index 097dc2f..763c312 100644
--- a/src/parser/visitor.ts
+++ b/src/parser/visitor.ts
@@ -1,4 +1,4 @@
-import { CstNode, IToken } from 'chevrotain';
+import { IToken } from 'chevrotain';
 import {
   Condition,
   ConditionWithValueQuery,
@@ -43,6 +43,7 @@ import {
   ArrayExpressionWithType,
   AtomicExpressionContext,
   BooleanContext,
+  ClauseStatementsContext,
   ConditionExpressionContext,
   DateNLiteralContext,
   ExpressionContext,
@@ -193,55 +194,83 @@ class SOQLVisitor extends BaseSoqlVisitor {
         return 'STRING';
       }
     },
+    /**
+     * Shared logic since there are two entry points depending on if allowPartialQuery=true
+     */
+    $_parseSelect(ctx: SelectStatementContext, options?: { isSubquery: boolean }): Partial<Query | Subquery> {
+      const { isSubquery } = options || { isSubquery: false };
+      let output: Partial<Query | Subquery> = {};
+
+      if (ctx.selectClause) {
+        output.fields = this.visit(ctx.selectClause);
+      }
+
+      if (ctx.fromClause) {
+        if (isSubqueryFromFlag(output, isSubquery)) {
+          const { sObject, alias, sObjectPrefix } = this.visit(ctx.fromClause);
+          output.relationshipName = sObject;
+          if (alias) {
+            output.sObjectAlias = alias;
+          }
+          if (sObjectPrefix) {
+            output.sObjectPrefix = sObjectPrefix;
+          }
+        } else {
+          const { sObject, alias } = this.visit(ctx.fromClause);
+          (output as Query).sObject = sObject;
+          if (alias) {
+            output.sObjectAlias = alias;
+          }
+        }
+      }
+
+      if (Array.isArray(output.fields)) {
+        if (!!output.sObjectAlias) {
+          output.fields.forEach((field: any) => {
+            if (field.relationships && field.relationships[0] === output.sObjectAlias) {
+              field.relationships = field.relationships.slice(1);
+              field.objectPrefix = output.sObjectAlias;
+            }
+            if (field.relationships && field.relationships.length === 0) {
+              delete field.relationships;
+              field.type = 'Field';
+            }
+          });
+        }
+      }
+
+      output = { ...output, ...this.visit(ctx.clauseStatements) };
+
+      return output;
+    },
   };
 
   /**
-   * This is the only public entry point for the parser
+   * Public entry point 1: `selectStatement`
    * @param ctx
    * @param options
    */
   selectStatement(ctx: SelectStatementContext, options?: { isSubquery: boolean }): Query | Subquery {
-    const { isSubquery } = options || { isSubquery: false };
-    const output: Partial<Query | Subquery> = {};
-
-    output.fields = this.visit(ctx.selectClause);
-
-    if (isSubqueryFromFlag(output, isSubquery)) {
-      const { sObject, alias, sObjectPrefix } = this.visit(ctx.fromClause);
-      output.relationshipName = sObject;
-      if (alias) {
-        output.sObjectAlias = alias;
-      }
-      if (sObjectPrefix) {
-        output.sObjectPrefix = sObjectPrefix;
-      }
-    } else {
-      const { sObject, alias } = this.visit(ctx.fromClause);
-      (output as Query).sObject = sObject;
-      if (alias) {
-        output.sObjectAlias = alias;
-      }
-    }
+    return this.helpers.$_parseSelect.bind(this)(ctx, options) as Query | Subquery;
+  }
 
-    if (!!output.sObjectAlias) {
-      output.fields.forEach((field: any) => {
-        if (field.relationships && field.relationships[0] === output.sObjectAlias) {
-          field.relationships = field.relationships.slice(1);
-          field.objectPrefix = output.sObjectAlias;
-        }
-        if (field.relationships && field.relationships.length === 0) {
-          delete field.relationships;
-          field.type = 'Field';
-        }
-      });
-    }
+  /**
+   * Public entry point 2: `selectStatementPartial`
+   * @param ctx
+   * @param options
+   */
+  selectStatementPartial(ctx: SelectStatementContext, options?: { isSubquery: boolean }): Query | Subquery {
+    return this.helpers.$_parseSelect.bind(this)(ctx, options) as Query | Subquery;
+  }
 
+  clauseStatements(ctx: ClauseStatementsContext): Partial<Query | Subquery> {
+    const query: Partial<Query | Subquery> = {};
     if (ctx.usingScopeClause && !ctx.usingScopeClause[0].recoveredNode) {
-      output.usingScope = this.visit(ctx.usingScopeClause);
+      query.usingScope = this.visit(ctx.usingScopeClause);
     }
 
     if (ctx.whereClause && !ctx.whereClause[0].recoveredNode) {
-      output.where = this.visit(ctx.whereClause);
+      query.where = this.visit(ctx.whereClause);
     }
 
     if (ctx.withClause) {
@@ -250,43 +279,42 @@ class SOQLVisitor extends BaseSoqlVisitor {
         .forEach(item => {
           const { withSecurityEnforced, withDataCategory } = this.visit(item);
           if (withSecurityEnforced) {
-            output.withSecurityEnforced = withSecurityEnforced;
+            query.withSecurityEnforced = withSecurityEnforced;
           }
           if (withDataCategory) {
-            output.withDataCategory = withDataCategory;
+            query.withDataCategory = withDataCategory;
           }
         });
     }
 
     if (ctx.groupByClause && !ctx.groupByClause[0].recoveredNode) {
-      output.groupBy = this.visit(ctx.groupByClause);
+      query.groupBy = this.visit(ctx.groupByClause);
     }
 
     if (ctx.havingClause && !ctx.havingClause[0].recoveredNode) {
-      output.having = this.visit(ctx.havingClause);
+      query.having = this.visit(ctx.havingClause);
     }
 
     if (ctx.orderByClause && !ctx.orderByClause[0].recoveredNode) {
-      output.orderBy = this.visit(ctx.orderByClause);
+      query.orderBy = this.visit(ctx.orderByClause);
     }
 
     if (ctx.limitClause && !ctx.limitClause[0].recoveredNode) {
-      output.limit = Number(this.visit(ctx.limitClause));
+      query.limit = Number(this.visit(ctx.limitClause));
     }
 
     if (ctx.offsetClause && !ctx.offsetClause[0].recoveredNode) {
-      output.offset = Number(this.visit(ctx.offsetClause));
+      query.offset = Number(this.visit(ctx.offsetClause));
     }
 
     if (ctx.forViewOrReference && !ctx.forViewOrReference[0].recoveredNode) {
-      output.for = this.visit(ctx.forViewOrReference);
+      query.for = this.visit(ctx.forViewOrReference);
     }
 
     if (ctx.updateTrackingViewstat && !ctx.updateTrackingViewstat[0].recoveredNode) {
-      output.update = this.visit(ctx.updateTrackingViewstat);
+      query.update = this.visit(ctx.updateTrackingViewstat);
     }
-
-    return output as Query | Subquery;
+    return query;
   }
 
   selectClause(ctx: SelectClauseContext): string[] {
@@ -299,7 +327,6 @@ class SOQLVisitor extends BaseSoqlVisitor {
             output = {
               type: 'Field',
               field: field,
-              // objectPrefix: undefined, // TODO: we cannot add this until the very und when we see if the sobject is aliased
             };
           } else {
             const splitFields = field.split('.');
@@ -307,7 +334,6 @@ class SOQLVisitor extends BaseSoqlVisitor {
               type: 'FieldRelationship',
               field: splitFields[splitFields.length - 1],
               relationships: splitFields.slice(0, splitFields.length - 1),
-              // objectPrefix: undefined, // TODO: we cannot add this until the very und when we see if the sobject is aliased
               rawValue: field,
             };
           }
@@ -358,7 +384,6 @@ class SOQLVisitor extends BaseSoqlVisitor {
       output = {
         type: 'Field',
         field: field,
-        // objectPrefix: undefined, // TODO: we cannot add this until the very und when we see if the sobject is aliased
       };
     } else {
       const splitFields = field.split('.');
@@ -366,7 +391,6 @@ class SOQLVisitor extends BaseSoqlVisitor {
         type: 'FieldRelationship',
         field: splitFields[splitFields.length - 1],
         relationships: splitFields.slice(0, splitFields.length - 1),
-        // objectPrefix: undefined, // TODO: we cannot add this until the very und when we see if the sobject is aliased
         rawValue: field,
       };
     }
@@ -889,8 +913,10 @@ class SOQLVisitor extends BaseSoqlVisitor {
 const visitor = new SOQLVisitor();
 
 /**
- * Parse query and process results
+ * Parse soql query into Query data structure.
+ *
  * @param soql
+ * @param options
  */
 export function parseQuery(soql: string, options?: ParseQueryConfig): Query {
   const { cst } = parse(soql, options);


Test patch:
diff --git a/test/test-cases-for-partial-parse.ts b/test/test-cases-for-partial-parse.ts
new file mode 100644
index 0000000..bccf97c
--- /dev/null
+++ b/test/test-cases-for-partial-parse.ts
@@ -0,0 +1,123 @@
+import { Query } from '../src';
+
+export interface TestCase {
+  testCase: number;
+  soql: string;
+  soqlComposed?: string; // used if the composed is known to be different from input
+  output: Query;
+}
+
+export const testCases: TestCase[] = [
+  {
+    testCase: 1,
+    soql: "WHERE Name LIKE 'A%' AND MailingCity = 'California'",
+    output: {
+      where: {
+        left: { field: 'Name', operator: 'LIKE', value: "'A%'", literalType: 'STRING' },
+        operator: 'AND',
+        right: { left: { field: 'MailingCity', operator: '=', value: "'California'", literalType: 'STRING' } },
+      },
+    },
+  },
+  {
+    testCase: 2,
+    soql: 'FROM Account ORDER BY Name DESC NULLS LAST',
+    output: { sObject: 'Account', orderBy: [{ field: 'Name', order: 'DESC', nulls: 'LAST' }] },
+  },
+  {
+    testCase: 3,
+    soql: "SELECT Name FROM Account WHERE Industry = 'media' LIMIT 125",
+    output: {
+      fields: [{ type: 'Field', field: 'Name' }],
+      sObject: 'Account',
+      where: { left: { field: 'Industry', operator: '=', value: "'media'", literalType: 'STRING' } },
+      limit: 125,
+    },
+  },
+  {
+    testCase: 4,
+    soql: "WHERE Industry = 'media' ORDER BY BillingPostalCode ASC NULLS LAST LIMIT 125",
+    output: {
+      where: { left: { field: 'Industry', operator: '=', value: "'media'", literalType: 'STRING' } },
+      orderBy: [{ field: 'BillingPostalCode', order: 'ASC', nulls: 'LAST' }],
+      limit: 125,
+    },
+  },
+  { testCase: 5, soql: 'FROM Lead GROUP BY LeadSource', output: { sObject: 'Lead', groupBy: [{ field: 'LeadSource' }] } },
+  {
+    testCase: 6,
+    soql: 'GROUP BY Name HAVING COUNT(Id) > 1',
+    output: {
+      groupBy: [{ field: 'Name' }],
+      having: {
+        left: {
+          operator: '>',
+          value: '1',
+          literalType: 'INTEGER',
+          fn: { rawValue: 'COUNT(Id)', functionName: 'COUNT', parameters: ['Id'] },
+        },
+      },
+    },
+  },
+  { testCase: 7, soql: 'ORDER BY Name OFFSET 100', output: { orderBy: [{ field: 'Name' }], offset: 100 } },
+  {
+    testCase: 8,
+    soql: 'SELECT Name, (SELECT LastName FROM Contacts)',
+    output: {
+      fields: [
+        { type: 'Field', field: 'Name' },
+        { type: 'FieldSubquery', subquery: { fields: [{ type: 'Field', field: 'LastName' }], relationshipName: 'Contacts' } },
+      ],
+    },
+  },
+  {
+    testCase: 9,
+    soql: "WHERE Mother_of_Child__r.LastName__c LIKE 'C%'",
+    output: { where: { left: { field: 'Mother_of_Child__r.LastName__c', operator: 'LIKE', value: "'C%'", literalType: 'STRING' } } },
+  },
+  {
+    testCase: 10,
+    soql: 'FROM LoginHistory WHERE LoginTime > 2010-09-20T22:16:30.000Z AND LoginTime < 2010-09-21 GROUP BY UserId',
+    output: {
+      sObject: 'LoginHistory',
+      where: {
+        left: { field: 'LoginTime', operator: '>', value: '2010-09-20T22:16:30.000Z', literalType: 'DATETIME' },
+        operator: 'AND',
+        right: { left: { field: 'LoginTime', operator: '<', value: '2010-09-21', literalType: 'DATE' } },
+      },
+      groupBy: [{ field: 'UserId' }],
+    },
+  },
+  {
+    testCase: 11,
+    soql: "WHERE (((Name = '1' OR Name = '2') AND Name = '3')) AND (((Description = '123') OR (Id = '1' AND Id = '2'))) AND Id = '1'",
+    output: {
+      where: {
+        left: { openParen: 3, field: 'Name', operator: '=', value: "'1'", literalType: 'STRING' },
+        operator: 'OR',
+        right: {
+          left: { field: 'Name', operator: '=', value: "'2'", literalType: 'STRING', closeParen: 1 },
+          operator: 'AND',
+          right: {
+            left: { field: 'Name', operator: '=', value: "'3'", literalType: 'STRING', closeParen: 2 },
+            operator: 'AND',
+            right: {
+              left: { openParen: 3, field: 'Description', operator: '=', value: "'123'", literalType: 'STRING', closeParen: 1 },
+              operator: 'OR',
+              right: {
+                left: { openParen: 1, field: 'Id', operator: '=', value: "'1'", literalType: 'STRING' },
+                operator: 'AND',
+                right: {
+                  left: { field: 'Id', operator: '=', value: "'2'", literalType: 'STRING', closeParen: 3 },
+                  operator: 'AND',
+                  right: { left: { field: 'Id', operator: '=', value: "'1'", literalType: 'STRING' } },
+                },
+              },
+            },
+          },
+        },
+      },
+    },
+  },
+];
+export default testCases;
diff --git a/test/test-partial-query.spec.ts b/test/test-partial-query.spec.ts
new file mode 100644
index 0000000..254c430
--- /dev/null
+++ b/test/test-partial-query.spec.ts
@@ -0,0 +1,59 @@
+import { expect } from 'chai';
+import 'mocha';
+import { composeQuery, parseQuery, Query, WhereClause } from '../src';
+import { isValueQueryCondition, isWhereClauseWithRightCondition } from '../src/api/public-utils';
+import testCases from './test-cases-for-partial-parse';
+
+const replacements = [{ matching: / last /i, replace: ' LAST ' }];
+
+describe('parse queries', () => {
+  testCases.forEach(testCase => {
+    it(`should correctly parse test case ${testCase.testCase} - ${testCase.soql}`, () => {
+      const soqlQuery = parseQuery(testCase.soql, { allowPartialQuery: true });
+      expect(testCase.output).to.deep.equal(soqlQuery);
+    });
+  });
+});
+
+describe('compose queries', () => {
+  testCases.forEach(testCase => {
+    it(`should compose correctly - test case ${testCase.testCase} - ${testCase.soql}`, () => {
+      const soqlQuery = composeQuery(removeComposeOnlyFields(parseQuery(testCase.soql, { allowPartialQuery: true })));
+      let soql = testCase.soqlComposed || testCase.soql;
+      replacements.forEach(replacement => (soql = soql.replace(replacement.matching, replacement.replace)));
+      expect(soqlQuery).to.equal(soql);
+    });
+  });
+});
+
+function removeComposeOnlyFields(query: Partial<Query>): Partial<Query> {
+  (query.fields || []).forEach(removeComposeOnlyField);
+  (query.fields || []).forEach(field => {
+    if (field.type === 'FieldSubquery') {
+      field.subquery.fields.forEach(removeComposeOnlyField);
+      removeFieldsFromWhere(field.subquery.where);
+    }
+  });
+  removeFieldsFromWhere(query.where);
+  return query;
+}
+
+function removeFieldsFromWhere(where?: WhereClause) {
+  if (!where) {
+    return;
+  }
+
+  if (isValueQueryCondition(where.left)) {
+    where.left.valueQuery.fields.forEach(removeComposeOnlyField);
+  }
+
+  if (isWhereClauseWithRightCondition(where)) {
+    removeFieldsFromWhere(where.right);
+  }
+}
+
+function removeComposeOnlyField(field: any) {
+  delete field.isAggregateFn;
+  delete field.rawValue;
+  delete field.from;
+}