Skip to content

Commit ba99c2f

Browse files
authored
Merge pull request #43 from paustint/feature-42
Add method to validate query without full parsing #42
2 parents d7a1fb0 + b9ff393 commit ba99c2f

File tree

8 files changed

+337
-44
lines changed

8 files changed

+337
-44
lines changed

README.md

Lines changed: 105 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -18,10 +18,20 @@ For an example of the parser, check out the [example application](https://pausti
1818

1919
### Available functions
2020
1. `parseQuery(soqlQueryString, options)`
21-
2. `composeQuery(SoqlQuery, options)`
21+
2. `isQueryValid(SoqlQuery, options)`
22+
3. `composeQuery(SoqlQuery, options)`
2223

23-
### Parse
24+
### Parse Query
2425
The parser takes a SOQL query and returns structured data.
26+
27+
Options:
28+
```typescript
29+
export interface SoqlQueryConfig {
30+
continueIfErrors?: boolean; // default=false
31+
logging: boolean; // default=false
32+
}
33+
```
34+
2535
#### Typescript / ES6
2636
```typescript
2737
import { parseQuery } from 'soql-parser-js';
@@ -83,9 +93,50 @@ This yields an object with the following structure:
8393
}
8494
}
8595
```
86-
### compose
96+
### Check if Query is Valid
97+
This will parse the AST tree to confirm the syntax is valid, but will not parse the tree into a data structure.
98+
This method is faster than parsing the full query.
99+
100+
Options:
101+
```typescript
102+
export interface ConfigBase {
103+
logging: boolean; // default=false
104+
}
105+
```
106+
107+
```typescript
108+
import { isQueryValid } from 'soql-parser-js';
109+
110+
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';
111+
112+
const isValid = isQueryValid(soql);
113+
114+
console.log('isValid', isValid);
115+
116+
```
117+
118+
#### Node
119+
```javascript
120+
var soqlParserJs = require('soql-parser-js');
121+
122+
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';
123+
124+
const isValid = isQueryValid(soql);
125+
126+
console.log('isValid', isValid);
127+
```
128+
129+
### Compose Query
87130
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)
88131

132+
Options:
133+
```typescript
134+
export interface SoqlComposeConfig {
135+
logging: boolean; // default=false
136+
format: boolean; // default=false
137+
}
138+
```
139+
89140
#### Typescript / ES6
90141
```typescript
91142
import { composeQuery } from 'soql-parser-js';
@@ -155,18 +206,28 @@ export interface SoqlComposeConfig {
155206
```typescript
156207
export type LogicalOperator = 'AND' | 'OR';
157208
export type Operator = '=' | '<=' | '>=' | '>' | '<' | 'LIKE' | 'IN' | 'NOT IN' | 'INCLUDES' | 'EXCLUDES';
209+
export type TypeOfFieldConditionType = 'WHEN' | 'ELSE';
210+
export type GroupSelector = 'ABOVE' | 'AT' | 'BELOW' | 'ABOVE_OR_BELOW';
211+
export type LogicalPrefix = 'NOT';
212+
export type ForClause = 'VIEW' | 'UPDATE' | 'REFERENCE';
213+
export type UpdateClause = 'TRACKING' | 'VIEWSTAT';
158214

159215
export interface Query {
160216
fields: Field[];
161217
subqueries: Query[];
162-
sObject: string;
218+
sObject?: string;
163219
sObjectAlias?: string;
164-
whereClause?: WhereClause;
220+
sObjectPrefix?: string[];
221+
sObjectRelationshipName?: string;
222+
where?: WhereClause;
165223
limit?: number;
166224
offset?: number;
167225
groupBy?: GroupByClause;
168226
having?: HavingClause;
169227
orderBy?: OrderByClause | OrderByClause[];
228+
withDataCategory?: WithDataCategoryClause;
229+
for?: ForClause;
230+
update?: UpdateClause;
170231
}
171232

172233
export interface SelectStatement {
@@ -178,22 +239,36 @@ export interface Field {
178239
alias?: string;
179240
relationshipFields?: string[];
180241
fn?: FunctionExp;
181-
subqueryObjName?: string;
242+
subqueryObjName?: string; // populated if subquery
243+
typeOf?: TypeOfField;
244+
}
245+
246+
export interface TypeOfField {
247+
field: string;
248+
conditions: TypeOfFieldCondition[];
249+
}
250+
251+
export interface TypeOfFieldCondition {
252+
type: TypeOfFieldConditionType;
253+
objectType?: string; // not present when ELSE
254+
fieldList: string[];
182255
}
183256

184257
export interface WhereClause {
185-
left: Condition | WhereClause;
186-
right?: Condition | WhereClause;
258+
left: Condition;
259+
right?: WhereClause;
187260
operator?: LogicalOperator;
188261
}
189262

190263
export interface Condition {
191-
openParen?: boolean;
192-
closeParen?: boolean;
193-
logicalPrefix?: 'NOT';
194-
field: string;
264+
openParen?: number;
265+
closeParen?: number;
266+
logicalPrefix?: LogicalPrefix;
267+
field?: string;
268+
fn?: FunctionExp;
195269
operator: Operator;
196-
value: string | string[];
270+
value?: string | string[];
271+
valueQuery?: Query;
197272
}
198273

199274
export interface OrderByClause {
@@ -209,23 +284,36 @@ export interface GroupByClause {
209284
}
210285

211286
export interface HavingClause {
212-
left: HavingCondition | HavingClause;
213-
right?: HavingCondition | HavingClause;
287+
left: HavingCondition;
288+
right?: HavingClause;
214289
operator?: LogicalOperator;
215290
}
216291

217292
export interface HavingCondition {
293+
openParen?: number;
294+
closeParen?: number;
218295
field?: string;
219296
fn?: FunctionExp;
220297
operator: string;
221298
value: string | number;
222299
}
223300

224301
export interface FunctionExp {
225-
text?: string;
226-
name?: string;
302+
text?: string; // Count(Id)
303+
name?: string; // Count
227304
alias?: string;
228305
parameter?: string | string[];
306+
fn?: FunctionExp; // used for nested functions FORMAT(MIN(CloseDate))
307+
}
308+
309+
export interface WithDataCategoryClause {
310+
conditions: WithDataCategoryCondition[];
311+
}
312+
313+
export interface WithDataCategoryCondition {
314+
groupName: string;
315+
selector: GroupSelector;
316+
parameters: string[];
229317
}
230318
```
231319

debug/test.js

Lines changed: 16 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,24 @@
11
var soqlParserJs = require('../dist');
22

3-
const query = `
4-
SELECT Id, Name FROM Account WHERE Id IN (SELECT AccountId FROM Contact WHERE LastName LIKE 'apple%') AND Id IN (SELECT AccountId FROM Opportunity WHERE isClosed = false)
5-
`;
3+
// const query = `
4+
// SELECT Id, Name FROM Account WHERE Id IN (SELECT AccountId FROM Contact WHERE LastName LIKE 'apple%') AND Id IN (SELECT AccountId FROM Opportunity WHERE isClosed = false)
5+
// `;
66

7-
const parsedQuery = soqlParserJs.parseQuery(query, { logging: true });
8-
console.log(JSON.stringify(parsedQuery, null, 2));
7+
// const parsedQuery = soqlParserJs.parseQuery(query, { logging: true });
8+
// console.log(JSON.stringify(parsedQuery, null, 2));
99

10-
const composedQuery = soqlParserJs.composeQuery(parsedQuery, { logging: true, format: true });
11-
console.log(composedQuery);
10+
// const composedQuery = soqlParserJs.composeQuery(parsedQuery, { logging: true, format: true });
11+
// console.log(composedQuery);
1212

1313
// SELECT Id, Name FROM Account WHERE Id IN (SELECT AccountId FROM Opportunity WHERE StageName = 'Closed Lost')
1414
// SELECT Id FROM Account WHERE Id NOT IN (SELECT AccountId FROM Opportunity WHERE IsClosed = false)
1515
// SELECT Id, Name FROM Account WHERE Id IN (SELECT AccountId FROM Contact WHERE LastName LIKE 'apple%') AND Id IN (SELECT AccountId FROM Opportunity WHERE isClosed = false)
16+
17+
const query = `
18+
SELECT Id, Name FROM Account WHERE Id IN (SELECT AccountId FROM Contact WHERE LastName LIKE 'apple%') AND Id IN (SELECT AccountId FROM Opportunity WHERE isClosed = false)
19+
`;
20+
21+
const isValid = soqlParserJs.isQueryValid(query, true);
22+
console.log('isValid', isValid);
23+
24+
const parsedQuery = soqlParserJs.parseQuery(query, { logging: true });

lib/SoqlComposer.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ import { FieldData, Formatter } from './SoqlFormatter';
1414

1515
export interface SoqlComposeConfig {
1616
logging: boolean; // default=false
17-
format: boolean;
17+
format: boolean; // default=false
1818
}
1919

2020
export function composeQuery(soql: Query, config: Partial<SoqlComposeConfig> = {}): string {

lib/SoqlListener.ts

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,11 @@ export class SoqlQuery implements Query {
5050
}
5151
}
5252

53+
/**
54+
* Class used to parse query for validity, but ignore results
55+
*/
56+
export class ListenerQuick implements SOQLListener {}
57+
5358
export class Listener implements SOQLListener {
5459
context: Context = {
5560
isSubQuery: false,
@@ -67,9 +72,6 @@ export class Listener implements SOQLListener {
6772

6873
constructor(private config: Partial<SoqlQueryConfig> = {}) {
6974
config.logging = utils.isBoolean(config.logging) ? config.logging : false;
70-
config.includeSubqueryAsField = utils.isBoolean(config.includeSubqueryAsField)
71-
? config.includeSubqueryAsField
72-
: true;
7375
this.soqlQuery = new SoqlQuery();
7476
}
7577

lib/SoqlParser.ts

Lines changed: 38 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -5,22 +5,23 @@ import { SyntaxErrorListener } from './ErrorListener';
55
import { SOQLLexer } from './generated/SOQLLexer';
66
import { SOQLParser, Soql_queryContext } from './generated/SOQLParser';
77
import { Query } from './models/SoqlQuery.model';
8-
import { Listener } from './SoqlListener';
8+
import { Listener, ListenerQuick } from './SoqlListener';
99

10-
export interface SoqlQueryConfig {
11-
/**
12-
* If true, continue to parse even if there appears to be a syntax error.
13-
* Other exceptions may be thrown when building the SoqlQuery object
14-
*/
10+
export interface ConfigBase {
11+
logging?: boolean; // default=false
12+
}
13+
14+
export interface SoqlQueryConfig extends ConfigBase {
1515
continueIfErrors?: boolean; // default=false
16-
logging: boolean; // default=false
17-
includeSubqueryAsField: boolean; // default=true
16+
}
17+
18+
function configureBaseDefaults(config: Partial<ConfigBase> = {}) {
19+
config.logging = utils.isBoolean(config.logging) ? config.logging : false;
1820
}
1921

2022
function configureDefaults(config: Partial<SoqlQueryConfig> = {}) {
2123
config.continueIfErrors = utils.isBoolean(config.continueIfErrors) ? config.continueIfErrors : false;
2224
config.logging = utils.isBoolean(config.logging) ? config.logging : false;
23-
config.includeSubqueryAsField = utils.isBoolean(config.includeSubqueryAsField) ? config.includeSubqueryAsField : true;
2425
}
2526

2627
/**
@@ -66,3 +67,31 @@ export function parseQuery(soql: string, config: Partial<SoqlQueryConfig> = {}):
6667
}
6768
return listener.soqlQuery;
6869
}
70+
71+
/**
72+
* @description Parse query to determine if the query is valid.
73+
* @param {soql} String SOQL query
74+
* @param {logging} boolean optional Prints out logging information
75+
* @returns boolean
76+
*/
77+
export function isQueryValid(soql: string, config: ConfigBase = {}): boolean {
78+
configureBaseDefaults(config);
79+
let isValid = true;
80+
if (config.logging) {
81+
console.time('isQueryValid');
82+
console.log('Parsing Query:', soql);
83+
}
84+
85+
try {
86+
getSoqlQueryContext(soql).soql_query();
87+
} catch (ex) {
88+
isValid = false;
89+
} finally {
90+
if (config.logging) {
91+
console.log('isValidQuery', isValid);
92+
console.timeEnd('isQueryValid');
93+
}
94+
}
95+
96+
return isValid;
97+
}

test/SoqlParser.spec.ts

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
1-
import { parseQuery, composeQuery } from '../lib';
1+
import { parseQuery, composeQuery, isQueryValid } from '../lib';
22
import { expect } from 'chai';
33
import 'mocha';
44
import testCases from './TestCases';
55
import testCasesForFormat from './TestCasesForFormat';
6+
import testCasesForIsValid from './TestCasesForIsValid';
67
import { formatQuery } from '../lib/SoqlFormatter';
78

89
const replacements = [
@@ -33,6 +34,7 @@ describe('compose queries', () => {
3334
});
3435
});
3536
});
37+
3638
describe('format queries', () => {
3739
testCasesForFormat.forEach(testCase => {
3840
it(`should format query - test case ${testCase.testCase} - ${testCase.soql}`, () => {
@@ -41,3 +43,18 @@ describe('format queries', () => {
4143
});
4244
});
4345
});
46+
47+
describe('validate queries', () => {
48+
testCasesForIsValid.filter(testCase => testCase.isValid).forEach(testCase => {
49+
it(`should identify valid queries - test case ${testCase.testCase} - ${testCase.soql}`, () => {
50+
const isValid = isQueryValid(testCase.soql);
51+
expect(isValid).equal(testCase.isValid);
52+
});
53+
});
54+
testCasesForIsValid.filter(testCase => !testCase.isValid).forEach(testCase => {
55+
it(`should identify invalid queries - test case ${testCase.testCase} - ${testCase.soql}`, () => {
56+
const isValid = isQueryValid(testCase.soql);
57+
expect(isValid).equal(testCase.isValid);
58+
});
59+
});
60+
});

test/TestCases.ts

Lines changed: 0 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,3 @@
1-
/*
2-
* Copyright (c) Austin Turner
3-
* The software in this package is published under the terms of MIT
4-
* license, a copy of which has been included with this distribution in the
5-
* LICENSE.txt file.
6-
*/
71
import { Query } from '../lib/models/SoqlQuery.model';
82

93
// Queries obtained from SFDC examples

0 commit comments

Comments
 (0)