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

Issue:
IN clause does not allow date literals
- [ ] Feature
- [x] Bug

<!--If this is a bug, please include the steps to reproduce the bug-->
## Description

The following query is valid but throws an exception while being parsed:

```sql
SELECT Id
FROM Account
WHERE CreatedDate IN (TODAY, THIS_MONTH)
```

Pull request description:
IN clause does not allow date literals #92
1. Date literals were not properly parsed if they were included as part of a SET within a WHERE clause, such as .
   1. As part of this change, the  property in the  will be an array if a variable date literal is included in a SET where clause. Refer to test cases  through  for examples

resolves #92

Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 50ac19a..92b2819 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,12 @@
 # Changelog
 
+## 2.4
+
+Feb 25, 2020
+
+1. Date literals were not properly parsed if they were included as part of a SET within a WHERE clause, such as `WHERE IN (TODAY, LAST_N_DAYS:5)`.
+   1. As part of this change, the `dateLiteralVariable` property in the `Condition` will be an array if a variable date literal is included in a SET where clause. Refer to test cases `93` through `98` for examples
+
 ## 2.3.0
 
 Jan 13, 2020
diff --git a/README.md b/README.md
index 89025ac..c2811b8 100644
--- a/README.md
+++ b/README.md
@@ -552,7 +552,7 @@ export interface Condition {
   operator: Operator;
   value?: string | string[];
   literalType?: LiteralType | LiteralType[]; // If populated with STRING on compose, the value(s) will be wrapped in "'" if they are not already. - All other values ignored
-  dateLiteralVariable?: number; // not required for compose, will be populated if SOQL is parsed
+  dateLiteralVariable?: number | number[]; // not required for compose, will be populated if SOQL is parsed
 }
 
 export interface OrderByClause {
diff --git a/src/api/api-models.ts b/src/api/api-models.ts
index d249213..7ef7a6e 100644
--- a/src/api/api-models.ts
+++ b/src/api/api-models.ts
@@ -157,7 +157,7 @@ export interface Condition {
   operator: Operator;
   value?: string | string[];
   literalType?: LiteralType | LiteralType[]; // If populated with STRING on compose, the value(s) will be wrapped in "'" if they are not already. - All other values ignored
-  dateLiteralVariable?: number; // not required for compose, will be populated if SOQL is parsed
+  dateLiteralVariable?: number | number[]; // not required for compose, will be populated if SOQL is parsed
 }
 
 export interface OrderByClause {
diff --git a/src/parser/parser.ts b/src/parser/parser.ts
index c169612..771e219 100644
--- a/src/parser/parser.ts
+++ b/src/parser/parser.ts
@@ -564,6 +564,8 @@ export class SoqlParser extends CstParser {
               { ALT: () => this.CONSUME(lexer.Null, { LABEL: 'value' }) },
               { ALT: () => this.CONSUME(lexer.True, { LABEL: 'value' }) },
               { ALT: () => this.CONSUME(lexer.False, { LABEL: 'value' }) },
+              { ALT: () => this.CONSUME(lexer.DateLiteral, { LABEL: 'value' }) },
+              { ALT: () => this.SUBRULE(this.dateNLiteral, { LABEL: 'value' }) },
               { ALT: () => this.CONSUME(lexer.StringIdentifier, { LABEL: 'value' }) },
             ]),
         );
diff --git a/src/parser/visitor.ts b/src/parser/visitor.ts
index 1b0f050..ef5a3cd 100644
--- a/src/parser/visitor.ts
+++ b/src/parser/visitor.ts
@@ -62,7 +62,8 @@ import {
 } from '../models';
 import { isSubqueryFromFlag, isToken } from '../utils';
 import { parse, ParseQueryConfig, SoqlParser } from './parser';
-import { isString } from 'util';
+import { isString, isNull } from 'util';
+import { IToken } from 'chevrotain';
 
 const parser = new SoqlParser();
 
@@ -673,11 +674,16 @@ class SOQLVisitor extends BaseSoqlVisitor {
     } else if (ctx.arrayExpression) {
       const arrayValues: ArrayExpressionWithType[] = this.visit(ctx.arrayExpression);
       value = arrayValues.map((item: any) => item.value);
+      const dateLiteralTemp = arrayValues.map((item: any) => item.variable || null);
+      const hasDateLiterals = dateLiteralTemp.some(item => !isNull(item));
       if (new Set(arrayValues.map((item: any) => item.type)).size === 1) {
         literalType = this.$_getLiteralTypeFromTokenType(arrayValues[0].type);
       } else {
         literalType = arrayValues.map((item: any) => this.$_getLiteralTypeFromTokenType(item.type));
       }
+      if (hasDateLiterals) {
+        dateLiteralVariable = dateLiteralTemp;
+      }
       literalType = literalType || 'STRING';
     } else if (ctx.whereClauseSubqueryIdentifier) {
       value = this.visit(ctx.whereClauseSubqueryIdentifier);
@@ -699,10 +705,16 @@ class SOQLVisitor extends BaseSoqlVisitor {
   }
 
   arrayExpression(ctx: ValueContext): ArrayExpressionWithType[] {
-    return ctx.value.map((item: any) => ({
-      type: item.tokenType.name,
-      value: item.image,
-    }));
+    return ctx.value.map((item: any) => {
+      if (isToken(item)) {
+        return {
+          type: (item as IToken).tokenType.name,
+          value: (item as IToken).image,
+        };
+      } else {
+        return this.visit(item, { includeType: true });
+      }
+    });
   }
 
   relationalOperator(ctx: OperatorContext) {
@@ -717,11 +729,15 @@ class SOQLVisitor extends BaseSoqlVisitor {
     return ctx.boolean[0].tokenType.name;
   }
 
-  dateNLiteral(ctx: DateNLiteralContext) {
-    return {
+  dateNLiteral(ctx: DateNLiteralContext, options?: { includeType: true }) {
+    const output: any = {
       value: `${ctx.dateNLiteral[0].image}:${ctx.variable[0].image}`,
       variable: Number(ctx.variable[0].image),
     };
+    if (options && options.includeType) {
+      output.type = ctx.dateNLiteral[0].tokenType.name;
+    }
+    return output;
   }
 
   forViewOrReference(ctx: ValueContext) {


Test patch:
diff --git a/test/test-cases.ts b/test/test-cases.ts
index 3eb7b6e..a426bc0 100644
--- a/test/test-cases.ts
+++ b/test/test-cases.ts
@@ -1730,6 +1730,89 @@ export const testCases: TestCase[] = [
       sObject: 'CONTACT',
     },
   },
+  {
+    testCase: 93,
+    soql: `SELECT Id FROM Account WHERE CreatedDate IN (TODAY)`,
+    output: {
+      fields: [{ type: 'Field', field: 'Id' }],
+      sObject: 'Account',
+      where: {
+        left: {
+          field: 'CreatedDate',
+          literalType: 'DATE_LITERAL',
+          operator: 'IN',
+          value: ['TODAY'],
+        },
+      },
+    },
+  },
+  {
+    testCase: 94,
+    soql: `SELECT Id FROM Account WHERE CreatedDate IN (TODAY)`,
+    output: {
+      fields: [{ type: 'Field', field: 'Id' }],
+      sObject: 'Account',
+      where: {
+        left: {
+          field: 'CreatedDate',
+          literalType: 'DATE_LITERAL',
+          operator: 'IN',
+          value: ['TODAY'],
+        },
+      },
+    },
+  },
+  {
+    testCase: 95,
+    soql: `SELECT Id FROM Account WHERE CreatedDate IN (TODAY, LAST_N_DAYS:4)`,
+    output: {
+      fields: [{ type: 'Field', field: 'Id' }],
+      sObject: 'Account',
+      where: {
+        left: {
+          field: 'CreatedDate',
+          literalType: ['DATE_LITERAL', 'DATE_N_LITERAL'],
+          operator: 'IN',
+          value: ['TODAY', 'LAST_N_DAYS:4'],
+          dateLiteralVariable: [null, 4],
+        },
+      },
+    },
+  },
+  {
+    testCase: 96,
+    soql: `SELECT Id FROM Account WHERE CreatedDate IN (LAST_N_DAYS:2)`,
+    output: {
+      fields: [{ type: 'Field', field: 'Id' }],
+      sObject: 'Account',
+      where: {
+        left: {
+          field: 'CreatedDate',
+          literalType: 'DATE_N_LITERAL',
+          operator: 'IN',
+          value: ['LAST_N_DAYS:2'],
+          dateLiteralVariable: [2],
+        },
+      },
+    },
+  },
+  {
+    testCase: 98,
+    soql: `SELECT Id FROM Account WHERE CreatedDate IN (LAST_N_DAYS:4, LAST_N_DAYS:7)`,
+    output: {
+      fields: [{ type: 'Field', field: 'Id' }],
+      sObject: 'Account',
+      where: {
+        left: {
+          field: 'CreatedDate',
+          literalType: 'DATE_N_LITERAL',
+          operator: 'IN',
+          value: ['LAST_N_DAYS:4', 'LAST_N_DAYS:7'],
+          dateLiteralVariable: [4, 7],
+        },
+      },
+    },
+  },
 ];
 
 export default testCases;
diff --git a/test/test.spec.ts b/test/test.spec.ts
index 6d08832..58333be 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 === 91);
+//   const testCase = testCases.find(tc => tc.testCase === 97);
 //   it(`should correctly parse test case ${testCase.testCase} - ${testCase.soql}`, () => {
 //     const soqlQuery = parseQuery(testCase.soql, testCase.options);
 //     const soqlQueryWithoutUndefinedProps = JSON.parse(JSON.stringify(soqlQuery));