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

Issue:
DISTANCE with GEOLOCATION throw errors
<!--
  Please include the following classification.
  If your classification is not added, you can modify the list below
-->
- [ ] Feature
- [x] Bug Request

<!--If this is a bug, please include the steps to reproduce the bug-->
## Description

https://developer.salesforce.com/docs/atlas.en-us.soql_sosl.meta/soql_sosl/sforce_api_calls_soql_geolocate.htm
```sql
SELECT Name, Location__c 
FROM Warehouse__c 
WHERE DISTANCE(Location__c, GEOLOCATION(37.775,-122.418), 'mi') < 20
```

```sql
SELECT Name, StreetAddress__c 
FROM Warehouse__c 
WHERE DISTANCE(Location__c, GEOLOCATION(37.775,-122.418), 'mi') < 20 
ORDER BY DISTANCE(Location__c, GEOLOCATION(37.775,-122.418), 'mi')
LIMIT 10
```


Pull request description:
DISTANCE with GEOLOCATION throw errors #83
Added support for DISTANCE and GEOLOCATION functions that previously failed
Added test-cases
Adjusted composer field functions to have space between parameters

resolves #83

Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index aed5f44..a8a6f91 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,12 +1,22 @@
 # Changelog
 
+## 2.2.0
+
+Nov 6, 2019
+
+1. `DISTANCE` and `GEOLOCATION` functions failed to parse when used in a `WHERE` clauses and `ORDER BY` clauses.
+
 ## 2.1.0
 
+Oct 28, 2019
+
 1. The method signature for `getFlattenedFields` has changed to allow `Query | Subquery | FieldSubquery` to be passed in. this is not being considered a breaking change because it is fully backwards compatible.
 2. A new helper method `isFieldSubquery(value: any)` was added to allow determining if a Field is a FieldSubquery. This is used internally for `getFlattenedFields()`.
 
 ## 2.0.0
 
+Oct 6, 2019
+
 ### Summary
 
 Version 2.0 brings some significant bundle size and performance improvements. This library now uses [Chevrotain](https://github.com/SAP/chevrotain) instead of [antlr4](https://github.com/antlr/antlr4). With this change, everything related to parsing had to be re-written from scratch. Chevrotain uses pure javascript to handle lexing, parsing, and visiting the generated ast/cst as opposed to using a grammar file and generating a javascript parser based on the grammar.
diff --git a/docs/src/components/sample-queries-json.json b/docs/src/components/sample-queries-json.json
index 252ef38..983bd16 100644
--- a/docs/src/components/sample-queries-json.json
+++ b/docs/src/components/sample-queries-json.json
@@ -85,5 +85,8 @@
   "SELECT Id, Amount FROM Opportunity WHERE Amount IN (usd500.01, usd600)",
   "SELECT Name, COUNT(Id) FROM Account GROUP BY Name HAVING COUNT(Id) > 0 AND (Name LIKE '%testing%' OR Name LIKE '%123%')",
   "SELECT Name, COUNT(Id) FROM Account GROUP BY Name HAVING COUNT(Id) > 0 AND (Name IN ('4/30 testing account', 'amendment quote doc testing', null))",
-  "SELECT Name, COUNT(Id) FROM Account GROUP BY Name HAVING COUNT(Id) > 0 AND (NOT Name IN ('4/30 testing account', 'amendment quote doc testing'))"
+  "SELECT Name, COUNT(Id) FROM Account GROUP BY Name HAVING COUNT(Id) > 0 AND (NOT Name IN ('4/30 testing account', 'amendment quote doc testing'))",
+  "SELECT Name, Location__c FROM Warehouse__c WHERE DISTANCE(Location__c, GEOLOCATION(37.775, -122.418), 'mi') < 20",
+  "SELECT Name, StreetAddress__c FROM Warehouse__c WHERE DISTANCE(Location__c, GEOLOCATION(37.775, -122.418), 'mi') < 20 ORDER BY DISTANCE(Location__c, GEOLOCATION(37.775, -122.418), 'mi') LIMIT 10",
+  "SELECT Id, Name, Location, DISTANCE(Location, GEOLOCATION(10, 10), 'mi') FROM CONTACT"
 ]
diff --git a/src/composer/composer.ts b/src/composer/composer.ts
index 54d1c1f..41bd84d 100644
--- a/src/composer/composer.ts
+++ b/src/composer/composer.ts
@@ -248,7 +248,7 @@ export class Compose {
           if (field.parameters) {
             params = field.parameters
               .map(param => (utils.isString(param) ? param : this.parseFields([param as FieldFunctionExpression])))
-              .join(',');
+              .join(', ');
           }
           return `${field.functionName}(${params})${field.alias ? ` ${field.alias}` : ''}`;
         }
diff --git a/src/models.ts b/src/models.ts
index f54fd6d..644bfd7 100644
--- a/src/models.ts
+++ b/src/models.ts
@@ -129,7 +129,12 @@ export interface OrderByExpressionContext extends WithIdentifier {
 
 export interface OrderByFunctionExpressionContext extends WithIdentifier {
   fn: IToken[];
-  alias?: IToken[];
+  order?: IToken[];
+  nulls?: IToken[];
+}
+
+export interface OrderByLocationExpressionContext {
+  locationFunction: CstNode[];
   order?: IToken[];
   nulls?: IToken[];
 }
@@ -161,6 +166,17 @@ export interface FieldFunctionContext {
   fn: IToken[];
 }
 
+export interface LocationFunctionContext {
+  location1: IToken[];
+  location2: IToken[] | CstNode[];
+  unit: IToken[];
+}
+
+export interface GeoLocationFunctionContext {
+  latitude: IToken[];
+  longitude: IToken[];
+}
+
 export interface ExpressionContext {
   logicalPrefix?: IToken[];
   lhs: IToken[] | CstNode[];
@@ -183,8 +199,8 @@ export interface FromClauseContext extends WithIdentifier {
   alias?: IToken[];
 }
 
-export interface FunctionExpressionContext extends WithIdentifier {
-  fn?: CstNode[];
+export interface FunctionExpressionContext {
+  params?: (CstNode | IToken)[];
 }
 
 export interface AtomicExpressionContext {
diff --git a/src/parser/lexer.ts b/src/parser/lexer.ts
index 5d919e6..86e26dc 100644
--- a/src/parser/lexer.ts
+++ b/src/parser/lexer.ts
@@ -385,6 +385,7 @@ export const Distance = createToken({
   longer_alt: Identifier,
   categories: [LocationFunction, Identifier],
 });
+
 export const Geolocation = createToken({
   name: 'GEOLOCATION',
   pattern: /GEOLOCATION/i,
@@ -746,6 +747,13 @@ export const SignedInteger = createToken({
   pattern: /(\-|\+)[0-9]+/,
   categories: [NumberIdentifier, IntegerNumberIdentifier],
 });
+export const GeolocationUnit = createToken({
+  name: 'GEOLOCATION_UNIT',
+  pattern: /'(mi|km)'/,
+  longer_alt: Identifier,
+  categories: [Identifier],
+});
+
 export const UnsignedInteger = createToken({
   name: 'UNSIGNED_INTEGER',
   pattern: /0|[1-9]\d*/,
@@ -949,6 +957,8 @@ export const allTokens = [
   LastNFiscalYears,
   NFiscalYearsAgo,
 
+  GeolocationUnit,
+
   In,
   NotIn,
   For,
diff --git a/src/parser/parser.ts b/src/parser/parser.ts
index 7f67193..2c84044 100644
--- a/src/parser/parser.ts
+++ b/src/parser/parser.ts
@@ -201,7 +201,7 @@ export class SoqlParser extends CstParser {
     const parenCount = this.getParenCount();
     this.AT_LEAST_ONE({
       DEF: () => {
-        this.SUBRULE(this.conditionExpression, { ARGS: [parenCount, false, true] });
+        this.SUBRULE(this.conditionExpression, { ARGS: [parenCount, false, true, true] });
       },
     });
 
@@ -220,7 +220,7 @@ export class SoqlParser extends CstParser {
 
   private conditionExpression = this.RULE(
     'conditionExpression',
-    (parenCount?: ParenCount, allowSubquery?: boolean, alowAggregateFn?: boolean) => {
+    (parenCount?: ParenCount, allowSubquery?: boolean, alowAggregateFn?: boolean, allowLocationFn?: boolean) => {
       // argument is undefined during self-analysis, need to initialize to avoid exception
       parenCount = this.getParenCount(parenCount);
       this.OPTION(() => {
@@ -229,7 +229,7 @@ export class SoqlParser extends CstParser {
           { ALT: () => this.CONSUME(lexer.Or, { LABEL: 'logicalOperator' }) },
         ]);
       });
-      this.SUBRULE(this.expression, { ARGS: [parenCount, allowSubquery, alowAggregateFn] });
+      this.SUBRULE(this.expression, { ARGS: [parenCount, allowSubquery, alowAggregateFn, allowLocationFn] });
     },
   );
 
@@ -314,6 +314,7 @@ export class SoqlParser extends CstParser {
       DEF: () => {
         this.OR([
           { ALT: () => this.SUBRULE(this.orderByFunctionExpression, { LABEL: 'orderByExpressionOrFn' }) },
+          { ALT: () => this.SUBRULE(this.orderByLocationExpression, { LABEL: 'orderByExpressionOrFn' }) },
           { ALT: () => this.SUBRULE(this.orderByExpression, { LABEL: 'orderByExpressionOrFn' }) },
         ]);
       },
@@ -336,6 +337,17 @@ export class SoqlParser extends CstParser {
     this.SUBRULE(this.functionExpression);
   });
 
+  private orderByLocationExpression = this.RULE('orderByLocationExpression', () => {
+    this.SUBRULE(this.locationFunction);
+    this.OPTION(() => {
+      this.OR([{ ALT: () => this.CONSUME(lexer.Asc, { LABEL: 'order' }) }, { ALT: () => this.CONSUME(lexer.Desc, { LABEL: 'order' }) }]);
+    });
+    this.OPTION1(() => {
+      this.CONSUME(lexer.Nulls);
+      this.OR1([{ ALT: () => this.CONSUME(lexer.First, { LABEL: 'nulls' }) }, { ALT: () => this.CONSUME(lexer.Last, { LABEL: 'nulls' }) }]);
+    });
+  });
+
   private limitClause = this.RULE('limitClause', () => {
     this.CONSUME(lexer.Limit);
     this.CONSUME(lexer.UnsignedInteger, { LABEL: 'value' });
@@ -385,11 +397,6 @@ export class SoqlParser extends CstParser {
     this.SUBRULE(this.functionExpression, { ARGS: [true] });
   });
 
-  private locationFunction = this.RULE('locationFunction', () => {
-    this.OR([{ ALT: () => this.CONSUME(lexer.Distance) }, { ALT: () => this.CONSUME(lexer.Geolocation) }]);
-    this.SUBRULE(this.functionExpression);
-  });
-
   private otherFunction = this.RULE('otherFunction', () => {
     this.OR(
       this.$_otherFunction ||
@@ -420,53 +427,81 @@ export class SoqlParser extends CstParser {
       SEP: lexer.Comma,
       DEF: () => {
         this.OR([
-          { GATE: () => !skipAggregate, ALT: () => this.SUBRULE(this.aggregateFunction, { LABEL: 'fn' }) },
-          { ALT: () => this.SUBRULE(this.locationFunction, { LABEL: 'fn' }) },
-          { ALT: () => this.SUBRULE(this.otherFunction, { LABEL: 'fn' }) },
-          { ALT: () => this.CONSUME(lexer.Identifier) },
+          { GATE: () => !skipAggregate, ALT: () => this.SUBRULE(this.aggregateFunction, { LABEL: 'params' }) },
+          { ALT: () => this.SUBRULE(this.otherFunction, { LABEL: 'params' }) },
+          { ALT: () => this.CONSUME(lexer.StringIdentifier, { LABEL: 'params' }) },
+          { ALT: () => this.CONSUME(lexer.NumberIdentifier, { LABEL: 'params' }) },
+          { ALT: () => this.CONSUME(lexer.Identifier, { LABEL: 'params' }) },
         ]);
       },
     });
     this.CONSUME(lexer.RParen);
   });
 
-  private expression = this.RULE('expression', (parenCount?: ParenCount, allowSubquery?: boolean, alowAggregateFn?: boolean) => {
-    this.OPTION(() => {
-      this.MANY(() => {
-        this.CONSUME(lexer.LParen);
-        if (parenCount) {
-          parenCount.left++;
-        }
-      });
-    });
-
-    this.OPTION1(() => {
-      this.CONSUME(lexer.Not, { LABEL: 'logicalPrefix' });
-    });
-
-    this.OR1([
-      { GATE: () => alowAggregateFn, ALT: () => this.SUBRULE(this.aggregateFunction, { LABEL: 'lhs' }) },
-      { ALT: () => this.SUBRULE(this.otherFunction, { LABEL: 'lhs' }) },
-      { ALT: () => this.CONSUME(lexer.Identifier, { LABEL: 'lhs' }) },
+  private locationFunction = this.RULE('locationFunction', () => {
+    this.CONSUME(lexer.Distance);
+    this.CONSUME(lexer.LParen);
+    this.CONSUME(lexer.Identifier, { LABEL: 'location1' });
+    this.CONSUME(lexer.Comma);
+    this.OR([
+      { ALT: () => this.SUBRULE(this.geolocationFunction, { LABEL: 'location2' }) },
+      { ALT: () => this.CONSUME1(lexer.Identifier, { LABEL: 'location2' }) },
     ]);
+    this.CONSUME1(lexer.Comma);
+    this.CONSUME(lexer.GeolocationUnit, { LABEL: 'unit' });
+    this.CONSUME(lexer.RParen);
+  });
 
-    this.OR2([
-      { ALT: () => this.SUBRULE(this.expressionWithRelationalOperator, { LABEL: 'operator' }) },
-      { ALT: () => this.SUBRULE(this.expressionWithSetOperator, { LABEL: 'operator', ARGS: [allowSubquery] }) },
-    ]);
+  private geolocationFunction = this.RULE('geolocationFunction', () => {
+    this.CONSUME(lexer.Geolocation);
+    this.CONSUME(lexer.LParen);
+    this.CONSUME(lexer.NumberIdentifier, { LABEL: 'latitude' });
+    this.CONSUME(lexer.Comma);
+    this.CONSUME1(lexer.NumberIdentifier, { LABEL: 'longitude' });
+    this.CONSUME(lexer.RParen);
+  });
 
-    this.OPTION4(() => {
-      this.MANY1({
-        GATE: () => (parenCount ? parenCount.left > parenCount.right : true),
-        DEF: () => {
-          this.CONSUME(lexer.RParen);
+  private expression = this.RULE(
+    'expression',
+    (parenCount?: ParenCount, allowSubquery?: boolean, alowAggregateFn?: boolean, allowLocationFn?: boolean) => {
+      this.OPTION(() => {
+        this.MANY(() => {
+          this.CONSUME(lexer.LParen);
           if (parenCount) {
-            parenCount.right++;
+            parenCount.left++;
           }
-        },
+        });
       });
-    });
-  });
+
+      this.OPTION1(() => {
+        this.CONSUME(lexer.Not, { LABEL: 'logicalPrefix' });
+      });
+
+      this.OR1([
+        { GATE: () => alowAggregateFn, ALT: () => this.SUBRULE(this.aggregateFunction, { LABEL: 'lhs' }) },
+        { GATE: () => allowLocationFn, ALT: () => this.SUBRULE(this.locationFunction, { LABEL: 'lhs' }) },
+        { ALT: () => this.SUBRULE(this.otherFunction, { LABEL: 'lhs' }) },
+        { ALT: () => this.CONSUME(lexer.Identifier, { LABEL: 'lhs' }) },
+      ]);
+
+      this.OR2([
+        { ALT: () => this.SUBRULE(this.expressionWithRelationalOperator, { LABEL: 'operator' }) },
+        { ALT: () => this.SUBRULE(this.expressionWithSetOperator, { LABEL: 'operator', ARGS: [allowSubquery] }) },
+      ]);
+
+      this.OPTION4(() => {
+        this.MANY1({
+          GATE: () => (parenCount ? parenCount.left > parenCount.right : true),
+          DEF: () => {
+            this.CONSUME(lexer.RParen);
+            if (parenCount) {
+              parenCount.right++;
+            }
+          },
+        });
+      });
+    },
+  );
 
   private expressionWithRelationalOperator = this.RULE('expressionWithRelationalOperator', () => {
     this.SUBRULE(this.relationalOperator);
diff --git a/src/parser/visitor.ts b/src/parser/visitor.ts
index 845530c..b4d5f1c 100644
--- a/src/parser/visitor.ts
+++ b/src/parser/visitor.ts
@@ -55,9 +55,13 @@ import {
   WhereClauseSubqueryContext,
   WithClauseContext,
   WithDateCategoryContext,
+  LocationFunctionContext,
+  GeoLocationFunctionContext,
+  OrderByLocationExpressionContext,
 } from '../models';
 import { isSubqueryFromFlag, isToken } from '../utils';
 import { parse, ParseQueryConfig, SoqlParser } from './parser';
+import { isString } from 'util';
 
 const parser = new SoqlParser();
 
@@ -429,6 +433,19 @@ class SOQLVisitor extends BaseSoqlVisitor {
     return orderByClause;
   }
 
+  orderByLocationExpression(ctx: OrderByLocationExpressionContext): OrderByClause {
+    const orderByClause: OrderByClause = {
+      fn: this.visit(ctx.locationFunction, { includeType: false }),
+    };
+    if (ctx.order && ctx.order[0]) {
+      orderByClause.order = ctx.order[0].tokenType.name as OrderByCriterion;
+    }
+    if (ctx.nulls && ctx.nulls[0]) {
+      orderByClause.nulls = ctx.nulls[0].tokenType.name as NullsOrder;
+    }
+    return orderByClause;
+  }
+
   limitClause(ctx: ValueContext) {
     return ctx.value[0].image;
   }
@@ -468,10 +485,6 @@ class SOQLVisitor extends BaseSoqlVisitor {
     return this.$_getFieldFunction(ctx, true, options.includeType);
   }
 
-  locationFunction(ctx: FieldFunctionContext, options: { includeType: boolean } = { includeType: true }) {
-    return this.$_getFieldFunction(ctx, false, options.includeType);
-  }
-
   otherFunction(ctx: FieldFunctionContext, options: { includeType: boolean } = { includeType: true }) {
     return this.$_getFieldFunction(ctx, false, options.includeType);
   }
@@ -484,11 +497,53 @@ class SOQLVisitor extends BaseSoqlVisitor {
     return this.$_getFieldFunction(ctx, false, false);
   }
 
+  locationFunction(ctx: LocationFunctionContext, options: { includeType: boolean } = { includeType: true }) {
+    let output: any = {};
+    if (options.includeType) {
+      output.type = 'FieldFunctionExpression';
+    }
+    output = {
+      ...output,
+      ...{
+        functionName: 'DISTANCE',
+        parameters: [
+          ctx.location1[0].image,
+          isToken(ctx.location2) ? ctx.location2[0].image : this.visit(ctx.location2, options),
+          ctx.unit[0].image,
+        ],
+      },
+    };
+
+    output.rawValue = `DISTANCE(${output.parameters[0]}, ${
+      isString(output.parameters[1]) ? output.parameters[1] : output.parameters[1].rawValue
+    }, ${output.parameters[2]})`;
+    return output;
+  }
+
+  geolocationFunction(ctx: GeoLocationFunctionContext, options: { includeType: boolean } = { includeType: true }) {
+    let output: any = {};
+    if (options.includeType) {
+      output.type = 'FieldFunctionExpression';
+    }
+    output = {
+      ...output,
+      ...{
+        functionName: 'GEOLOCATION',
+        parameters: [ctx.latitude[0].image, ctx.longitude[0].image],
+        rawValue: `GEOLOCATION(${ctx.latitude[0].image}, ${ctx.longitude[0].image})`,
+      },
+    };
+    return output;
+  }
+
   functionExpression(ctx: FunctionExpressionContext, options: { includeType: boolean } = { includeType: true }): string[] {
-    if (ctx.Identifier) {
-      return ctx.Identifier.map((item: any) => item.image);
-    } else if (ctx.fn) {
-      return ctx.fn.map((item: any) => this.visit(item, options));
+    if (ctx.params) {
+      return ctx.params.map((item: any) => {
+        if (item.image) {
+          return item.image;
+        }
+        return this.visit(item, options);
+      });
     }
     return [];
   }


Test patch:
diff --git a/test/test-cases-for-is-valid.ts b/test/test-cases-for-is-valid.ts
index 320e695..e57d07c 100644
--- a/test/test-cases-for-is-valid.ts
+++ b/test/test-cases-for-is-valid.ts
@@ -356,5 +356,55 @@ export const testCases: TestCaseForFormat[] = [
     soql: `SELECT Name, Count(Id) FROM Account GROUP BY Name HAVING Count(Id) > 0 AND (Name LIKE '%testing%' OR Name LIKE '%123%'`,
     isValid: false,
   },
+  {
+    testCase: 137,
+    soql: `SELECT Name, StreetAddress__c FROM Warehouse__c WHERE GEOLOCATION(Location__c, DISTANCE(37.775, -122.418), 'mi') < 20`,
+    isValid: false,
+  },
+  {
+    testCase: 138,
+    soql: `SELECT Name, StreetAddress__c FROM Warehouse__c WHERE GEOLOCATION(37.775, -122.418) < 20`,
+    isValid: false,
+  },
+  {
+    testCase: 139,
+    soql: `SELECT Name, GEOLOCATION(37.775, -122.418) FROM Warehouse__c`,
+    isValid: false,
+  },
+  {
+    testCase: 140,
+    soql: `SELECT Name, DISTANCE(37.775, -122.418) FROM Warehouse__c`,
+    isValid: false,
+  },
+  {
+    testCase: 141,
+    soql: `SELECT Id, Name, Location, DISTANCE(Location, GEOLOCATION(10, 10), 'mi') FROM CONTACT`,
+    isValid: true,
+  },
+  {
+    testCase: 142,
+    soql: `SELECT Id, Name, Location, DISTANCE(Location, GEOLOCATION(10, 10), 'km') FROM CONTACT`,
+    isValid: true,
+  },
+  {
+    testCase: 143,
+    soql: `SELECT Id, Name, Location, DISTANCE(Location, GEOLOCATION(-10, -10), 'km') FROM CONTACT`,
+    isValid: true,
+  },
+  {
+    testCase: 144,
+    soql: `SELECT Id, Name, Location, DISTANCE(Location, GEOLOCATION(-10.775, -10.775), 'mi') FROM CONTACT`,
+    isValid: true,
+  },
+  {
+    testCase: 145,
+    soql: `SELECT Id, Name, Location, DISTANCE(Location, GEOLOCATION(10, 10), 'm') FROM CONTACT`,
+    isValid: false,
+  },
+  {
+    testCase: 146,
+    soql: `SELECT Id, Name, Location, DISTANCE(GEOLOCATION(37.775,-122.418), warehouse_location__c, 'km') FROM CONTACT`,
+    isValid: false,
+  },
 ];
 export default testCases;
diff --git a/test/test-cases.ts b/test/test-cases.ts
index 34654f4..385a726 100644
--- a/test/test-cases.ts
+++ b/test/test-cases.ts
@@ -1536,6 +1536,105 @@ export const testCases: TestCase[] = [
       },
     },
   },
+  {
+    testCase: 88,
+    soql: `SELECT Name, Location__c FROM Warehouse__c WHERE DISTANCE(Location__c, GEOLOCATION(37.775, -122.418), 'mi') < 20`,
+    output: {
+      fields: [{ type: 'Field', field: 'Name' }, { type: 'Field', field: 'Location__c' }],
+      sObject: 'Warehouse__c',
+      where: {
+        left: {
+          fn: {
+            rawValue: `DISTANCE(Location__c, GEOLOCATION(37.775, -122.418), 'mi')`,
+            functionName: 'DISTANCE',
+            parameters: [
+              'Location__c',
+              {
+                rawValue: 'GEOLOCATION(37.775, -122.418)',
+                functionName: 'GEOLOCATION',
+                parameters: ['37.775', '-122.418'],
+              },
+              `'mi'`,
+            ],
+          },
+          operator: '<',
+          value: '20',
+          literalType: 'INTEGER',
+        },
+      },
+    },
+  },
+  {
+    testCase: 89,
+    soql: `SELECT Name, StreetAddress__c FROM Warehouse__c WHERE DISTANCE(Location__c, GEOLOCATION(37.775, -122.418), 'mi') < 20 ORDER BY DISTANCE(Location__c, GEOLOCATION(37.775, -122.418), 'mi') LIMIT 10`,
+    output: {
+      fields: [{ type: 'Field', field: 'Name' }, { type: 'Field', field: 'StreetAddress__c' }],
+      sObject: 'Warehouse__c',
+      where: {
+        left: {
+          fn: {
+            rawValue: `DISTANCE(Location__c, GEOLOCATION(37.775, -122.418), 'mi')`,
+            functionName: 'DISTANCE',
+            parameters: [
+              'Location__c',
+              {
+                rawValue: 'GEOLOCATION(37.775, -122.418)',
+                functionName: 'GEOLOCATION',
+                parameters: ['37.775', '-122.418'],
+              },
+              `'mi'`,
+            ],
+          },
+          operator: '<',
+          value: '20',
+          literalType: 'INTEGER',
+        },
+      },
+      orderBy: {
+        fn: {
+          rawValue: `DISTANCE(Location__c, GEOLOCATION(37.775, -122.418), 'mi')`,
+          functionName: 'DISTANCE',
+          parameters: [
+            'Location__c',
+            {
+              rawValue: 'GEOLOCATION(37.775, -122.418)',
+              functionName: 'GEOLOCATION',
+              parameters: ['37.775', '-122.418'],
+            },
+            `'mi'`,
+          ],
+        },
+      },
+      limit: 10,
+    },
+  },
+  {
+    testCase: 90,
+    soql: `SELECT Id, Name, Location, DISTANCE(Location, GEOLOCATION(10, 10), 'mi') FROM CONTACT`,
+    output: {
+      fields: [
+        { type: 'Field', field: 'Id' },
+        { type: 'Field', field: 'Name' },
+        { type: 'Field', field: 'Location' },
+        {
+          type: 'FieldFunctionExpression',
+          functionName: 'DISTANCE',
+          rawValue: `DISTANCE(Location, GEOLOCATION(10, 10), 'mi')`,
+          parameters: [
+            'Location',
+            {
+              type: 'FieldFunctionExpression',
+              functionName: 'GEOLOCATION',
+              parameters: ['10', '10'],
+              rawValue: 'GEOLOCATION(10, 10)',
+            },
+            `'mi'`,
+          ],
+        },
+      ],
+      sObject: 'CONTACT',
+    },
+  },
 ];
 
 export default testCases;
diff --git a/test/test.spec.ts b/test/test.spec.ts
index bf0b582..8d05636 100644
--- a/test/test.spec.ts
+++ b/test/test.spec.ts
@@ -11,7 +11,7 @@ const replacements = [{ matching: / last /i, replace: ' LAST ' }];
 // Uncomment these to easily test one specific query - useful for troubleshooting/bugfixing
 
 // describe.only('parse queries', () => {
-//   const testCase = testCases.find(tc => tc.testCase === 8);
+//   const testCase = testCases.find(tc => tc.testCase === 90);
 //   it(`should correctly parse test case ${testCase.testCase} - ${testCase.soql}`, () => {
 //     const soqlQuery = parseQuery(testCase.soql, testCase.options);
 //     const soqlQueryWithoutUndefinedProps = JSON.parse(JSON.stringify(soqlQuery));
@@ -20,7 +20,7 @@ const replacements = [{ matching: / last /i, replace: ' LAST ' }];
 // });
 
 // describe.only('compose queries', () => {
-//   const testCase = testCases.find(tc => tc.testCase === 47);
+//   const testCase = testCases.find(tc => tc.testCase === 90);
 //   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;
@@ -31,7 +31,7 @@ const replacements = [{ matching: / last /i, replace: ' LAST ' }];
 
 // describe.only('Test valid queries', () => {
 //   testCasesForIsValid
-//     .filter(testCase => testCase.testCase === 136)
+//     .filter(testCase => testCase.testCase === 137)
 //     .forEach(testCase => {
 //       it(`should identify validity of query - test case ${testCase.testCase} - ${testCase.soql}`, () => {
 //         const soqlQuery = parseQuery(testCase.soql, { logErrors: true, ...testCase.options });