('');
19 | const [parsedQuery, setParsedQuery] = useState('');
20 |
21 | useEffect(() => {
22 | try {
23 | setQueryObj(JSON.stringify(parseQuery(selectedQuery, { allowApexBindVariables: true }), null, 2));
24 | } catch (ex) {
25 | setQueryObj('{\n}');
26 | }
27 | }, [selectedQuery]);
28 |
29 | useEffect(() => {
30 | try {
31 | if (queryObj) {
32 | setParsedQuery(
33 | composeQuery(JSON.parse(queryObj), {
34 | format: formatQuery,
35 | formatOptions: {
36 | fieldMaxLineLength,
37 | fieldSubqueryParensOnOwnLine,
38 | newLineAfterKeywords,
39 | numIndent,
40 | },
41 | }),
42 | );
43 | }
44 | } catch (ex) {
45 | // ignore
46 | }
47 | }, [queryObj, formatQuery, numIndent, fieldMaxLineLength, fieldSubqueryParensOnOwnLine, newLineAfterKeywords]);
48 |
49 | function handleQueryChange(value: string) {
50 | setQueryObj(value);
51 | }
52 |
53 | return (
54 | <>
55 |
56 |
57 | Parse Options
58 |
59 |
60 | setFormatQuery(event.target.checked)} />
61 | formatQuery
62 |
63 |
64 |
65 |
66 | numIndent
67 | setNumIndent(Number(event.target.value ?? 0))}
73 | />
74 |
75 |
76 |
77 |
81 | fieldMaxLineLength
82 | setFieldMaxLineLength(Number(event.target.value ?? 0))}
88 | />
89 |
90 |
91 |
92 |
96 | setFieldSubqueryParensOnOwnLine(event.target.checked)}
101 | />
102 | fieldSubqueryParensOnOwnLine
103 |
104 |
105 |
106 |
110 | setNewLineAfterKeywords(event.target.checked)}
115 | />
116 | newLineAfterKeywords
117 |
118 |
119 |
120 |
121 | >
122 | );
123 | }
124 |
--------------------------------------------------------------------------------
/docs/src/components/HomepageFeatures/index.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import clsx from 'clsx';
3 | import styles from './styles.module.css';
4 | import { Highlight } from '../Utilities/Highlight';
5 |
6 | type FeatureItem = {
7 | title: string;
8 | description: JSX.Element;
9 | };
10 |
11 | const exampleSoql = `SELECT Name
12 | FROM Account
13 | WHERE Industry = 'media'
14 | ORDER BY BillingPostalCode ASC NULLS LAST
15 | LIMIT 125`;
16 |
17 | const exampleCompose = JSON.stringify(
18 | {
19 | fields: [
20 | {
21 | type: 'Field',
22 | field: 'Name',
23 | },
24 | ],
25 | sObject: 'Account',
26 | where: {
27 | left: {
28 | field: 'Industry',
29 | operator: '=',
30 | literalType: 'STRING',
31 | value: "'media'",
32 | },
33 | },
34 | orderBy: [
35 | {
36 | field: 'BillingPostalCode',
37 | order: 'ASC',
38 | nulls: 'LAST',
39 | },
40 | ],
41 | limit: 125,
42 | },
43 | null,
44 | 2,
45 | );
46 |
47 | const FeatureList: FeatureItem[] = [
48 | {
49 | title: 'Parse',
50 | description: (
51 | <>
52 |
53 | into
54 |
55 | >
56 | ),
57 | },
58 | {
59 | title: 'Compose',
60 | description: (
61 | <>
62 |
63 | into
64 |
65 | >
66 | ),
67 | },
68 | {
69 | title: 'Battle Tested',
70 | description: (
71 | <>
72 |
73 | Your SOQL query is parsed using a language parser,{' '}
74 |
75 | Chevrotain JS
76 |
77 | , and aims to support every SOQL feature.
78 |
79 |
80 | This library has been powering{' '}
81 |
82 | Jetstream
83 | {' '}
84 | in production for many years and has parsed and composed millions of queries from thousands of users.
85 |
86 | >
87 | ),
88 | },
89 | ];
90 |
91 | function Feature({ title, description }: FeatureItem) {
92 | return (
93 |
94 |
95 |
{title}
96 | {description}
97 |
98 |
99 | );
100 | }
101 |
102 | export default function HomepageFeatures(): JSX.Element {
103 | return (
104 |
105 |
106 |
107 | {FeatureList.map((props, idx) => (
108 |
109 | ))}
110 |
111 |
112 |
113 | );
114 | }
115 |
--------------------------------------------------------------------------------
/docs/src/components/HomepageFeatures/styles.module.css:
--------------------------------------------------------------------------------
1 | .features {
2 | display: flex;
3 | align-items: center;
4 | padding: 2rem 0;
5 | width: 100%;
6 | }
7 |
8 | .featuresSubHeading {
9 | text-align: center;
10 | text-transform: uppercase;
11 | font-weight: semi-bold;
12 | margin: 0.5rem 0;
13 | }
14 |
15 | .featureSvg {
16 | height: 200px;
17 | width: 200px;
18 | }
19 |
--------------------------------------------------------------------------------
/docs/src/components/ParseQueries/ParsedOutput/index.tsx:
--------------------------------------------------------------------------------
1 | import React, { useEffect, useState } from 'react';
2 | import { parseQuery, Query } from '@jetstreamapp/soql-parser-js';
3 | import { Highlight } from '../../Utilities/Highlight';
4 |
5 | export interface ParsedOutputProps {
6 | query: string;
7 | }
8 |
9 | export default function ParsedOutput({ query }: ParsedOutputProps): JSX.Element {
10 | const [allowPartialQuery, setAllowPartialQuery] = useState(true);
11 | const [ignoreParseErrors, setIgnoreParseErrors] = useState(false);
12 | const [allowApexBindVariables, setAllowApexBindVariables] = useState(true);
13 | const [parsedQuery, setParsedQuery] = useState(null);
14 | const [invalidMessage, setInvalidMessage] = useState(null);
15 |
16 | // TODO: debounce
17 | useEffect(() => {
18 | try {
19 | if (query) {
20 | setParsedQuery(parseQuery(query, { allowPartialQuery, ignoreParseErrors, allowApexBindVariables }));
21 | }
22 | } catch (ex) {
23 | setParsedQuery(null);
24 | setInvalidMessage(ex.message);
25 | }
26 | }, [query, allowPartialQuery, ignoreParseErrors, allowApexBindVariables]);
27 |
28 | const code = parsedQuery ? JSON.stringify(parsedQuery, null, 2) : invalidMessage;
29 |
30 | return (
31 |
60 | );
61 | }
62 |
--------------------------------------------------------------------------------
/docs/src/components/ParseQueries/ParsedOutput/styles.module.css:
--------------------------------------------------------------------------------
1 | textarea {
2 | width: 100%;
3 | height: 150px;
4 | padding: 12px 20px;
5 | box-sizing: border-box;
6 | border: 2px solid #ccc;
7 | border-radius: 4px;
8 | background-color: #f8f8f8;
9 | font-size: 16px;
10 | resize: vertical;
11 | }
12 |
--------------------------------------------------------------------------------
/docs/src/components/ParseQueries/SoqlInput/index.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import clsx from 'clsx';
3 | import styles from './styles.module.css';
4 |
5 | export interface SoqlInputProps {
6 | soql: string;
7 | onChange: (value: string) => void;
8 | }
9 |
10 | export default function SoqlInput({ soql, onChange }: SoqlInputProps): JSX.Element {
11 | return (
12 |
13 |
14 | SOQL Query
15 |
17 |
18 | );
19 | }
20 |
--------------------------------------------------------------------------------
/docs/src/components/ParseQueries/SoqlInput/styles.module.css:
--------------------------------------------------------------------------------
1 | .textarea {
2 | width: 100%;
3 | height: 150px;
4 | padding: 12px 20px;
5 | box-sizing: border-box;
6 | border: 2px solid #ccc;
7 | border-radius: 4px;
8 | font-size: 16px;
9 | resize: vertical;
10 | }
11 |
--------------------------------------------------------------------------------
/docs/src/components/ParseQueries/index.tsx:
--------------------------------------------------------------------------------
1 | import React, { useEffect, useState } from 'react';
2 | import ParsedOutput from './ParsedOutput';
3 | import SoqlInput from './SoqlInput';
4 |
5 | export interface ParseQueriesProps {
6 | selectedQuery: string;
7 | }
8 |
9 | export default function ParseQueries({ selectedQuery }: ParseQueriesProps) {
10 | const [query, setQuery] = useState(selectedQuery);
11 |
12 | useEffect(() => {
13 | setQuery(selectedQuery);
14 | }, [selectedQuery]);
15 |
16 | function handleQueryChange(value: string) {
17 | setQuery(value);
18 | }
19 |
20 | return (
21 | <>
22 |
23 |
24 | >
25 | );
26 | }
27 |
--------------------------------------------------------------------------------
/docs/src/components/SoqlList/index.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import clsx from 'clsx';
3 | import styles from './styles.module.css';
4 | import sampleQueriesJson from '@site/static/sample-queries-json.json';
5 | import { Highlight } from '../Utilities/Highlight';
6 |
7 | const sampleQueries: string[] = sampleQueriesJson;
8 |
9 | export interface SoqlListProps {
10 | isOpen?: boolean;
11 | selected?: string;
12 | onSelected: (selected: string) => void;
13 | onToggleOpen: () => void;
14 | }
15 |
16 | export default function SoqlList({ isOpen = true, selected, onSelected, onToggleOpen }: SoqlListProps): JSX.Element {
17 | return (
18 |
19 |
20 | {isOpen ? '<' : '>'}
21 |
22 | {isOpen && (
23 |
24 | {sampleQueries.map((query, i) => (
25 | onSelected(query)}
29 | >
30 |
31 |
32 | ))}
33 |
34 | )}
35 |
36 | );
37 | }
38 |
--------------------------------------------------------------------------------
/docs/src/components/SoqlList/styles.module.css:
--------------------------------------------------------------------------------
1 | .container {
2 | border-right: 1px solid #e5e5e5;
3 | position: relative;
4 | min-width: 1.25rem;
5 | min-height: 4rem;
6 | margin-right: 2rem;
7 | font-size: 0.8rem;
8 | }
9 |
10 | .container.collapsed {
11 | border-right: none !important;
12 | }
13 |
14 | .collapseIcon {
15 | position: absolute;
16 | right: -1rem;
17 | top: 1rem;
18 | height: 2rem;
19 | width: 2rem;
20 | border: none;
21 | border-radius: 50%;
22 | }
23 |
24 | .list {
25 | list-style: none;
26 | margin: 0;
27 | padding: 0;
28 | max-height: calc(100vh - var(--ifm-navbar-height) - 62px - 1rem);
29 | overflow-y: scroll;
30 | margin-top: 0.2rem;
31 | }
32 |
33 | .listItem {
34 | padding: 0.5rem;
35 | cursor: pointer;
36 | border-left: 5px solid transparent;
37 | }
38 |
39 | .listItem.selected {
40 | border-left: 5px solid #808080;
41 | }
42 |
43 | [data-theme='dark'] .listItem:hover {
44 | border-left: 5px solid #e5e5e5;
45 | text-decoration: none;
46 | }
47 |
48 | .listItem:hover {
49 | border-left: 5px solid #808080;
50 | text-decoration: none;
51 | }
52 |
--------------------------------------------------------------------------------
/docs/src/components/Tabs/index.tsx:
--------------------------------------------------------------------------------
1 | import clsx from 'clsx';
2 | import React, { ReactNode, useState } from 'react';
3 |
4 | export interface TabsProps {
5 | initialTab?: string;
6 | className?: string;
7 | tabs: {
8 | id: string;
9 | label: string;
10 | content: ReactNode;
11 | }[];
12 | }
13 |
14 | export default function Tabs({ initialTab, className, tabs = [] }: TabsProps): JSX.Element {
15 | const [activeTab, setActiveTab] = useState(initialTab || tabs[0]?.id);
16 |
17 | return (
18 |
19 |
20 | {tabs.map(({ id, label }) => (
21 | setActiveTab(id)}>
22 | {label}
23 |
24 | ))}
25 |
26 |
{tabs.find(({ id }) => id === activeTab)?.content}
27 |
28 | );
29 | }
30 |
--------------------------------------------------------------------------------
/docs/src/components/Tabs/styles.module.css:
--------------------------------------------------------------------------------
1 | textarea {
2 | width: 100%;
3 | height: 150px;
4 | padding: 12px 20px;
5 | box-sizing: border-box;
6 | border: 2px solid #ccc;
7 | border-radius: 4px;
8 | background-color: #f8f8f8;
9 | font-size: 16px;
10 | resize: vertical;
11 | }
12 |
--------------------------------------------------------------------------------
/docs/src/components/Utilities/Highlight.tsx:
--------------------------------------------------------------------------------
1 | import { Language, Highlight as PrismaHighlight, themes } from 'prism-react-renderer';
2 | import { useColorMode } from '@docusaurus/theme-common';
3 |
4 | interface HighlightProps {
5 | code: string;
6 | language: Language;
7 | classNames?: {
8 | pre?: string;
9 | };
10 | }
11 |
12 | export function Highlight({ code, language, classNames = {} }: HighlightProps) {
13 | const { colorMode } = useColorMode();
14 |
15 | const theme = colorMode === 'light' ? themes.github : themes.shadesOfPurple;
16 |
17 | return (
18 |
19 | {({ style, tokens, getLineProps, getTokenProps }) => (
20 |
27 | {tokens.map((line, i) => (
28 |
29 | {line.map((token, key) => (
30 |
31 | ))}
32 |
33 | ))}
34 |
35 | )}
36 |
37 | );
38 | }
39 |
--------------------------------------------------------------------------------
/docs/src/components/Utilities/index.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import {
3 | FieldFunctionExpression,
4 | FieldSubquery,
5 | getField,
6 | getFlattenedFields,
7 | GroupByFieldClause,
8 | GroupByFnClause,
9 | hasAlias,
10 | HavingClause,
11 | isFieldSubquery,
12 | isGroupByField,
13 | isGroupByFn,
14 | isHavingClauseWithRightCondition,
15 | isOrderByField,
16 | isOrderByFn,
17 | isSubquery,
18 | isValueCondition,
19 | isValueFunctionCondition,
20 | isValueQueryCondition,
21 | isValueWithDateLiteralCondition,
22 | isValueWithDateNLiteralCondition,
23 | isWhereClauseWithRightCondition,
24 | isWhereOrHavingClauseWithRightCondition,
25 | OrderByFieldClause,
26 | OrderByFnClause,
27 | Query,
28 | Subquery,
29 | ValueCondition,
30 | ValueFunctionCondition,
31 | ValueQueryCondition,
32 | ValueWithDateLiteralCondition,
33 | ValueWithDateNLiteralCondition,
34 | WhereClause,
35 | } from '@jetstreamapp/soql-parser-js';
36 | import { Highlight } from './Highlight';
37 |
38 | const sampleQuery: Query = {
39 | fields: [
40 | {
41 | type: 'Field',
42 | field: 'Id',
43 | },
44 | {
45 | type: 'Field',
46 | field: 'Name',
47 | },
48 | {
49 | type: 'FieldRelationship',
50 | field: 'Name',
51 | relationships: ['Account'],
52 | rawValue: 'Account.Name',
53 | },
54 | ],
55 | sObject: 'Contact',
56 | };
57 |
58 | const fieldWithAlias: FieldFunctionExpression = {
59 | type: 'FieldFunctionExpression',
60 | functionName: 'FORMAT',
61 | parameters: [
62 | {
63 | type: 'FieldFunctionExpression',
64 | functionName: 'MIN',
65 | parameters: ['closedate'],
66 | isAggregateFn: true,
67 | rawValue: 'MIN(closedate)',
68 | },
69 | ],
70 | rawValue: 'FORMAT(MIN(closedate))',
71 | alias: 'Amt',
72 | };
73 |
74 | const subquery: Subquery = {
75 | fields: [
76 | {
77 | type: 'Field',
78 | field: 'Name',
79 | },
80 | ],
81 | relationshipName: 'Line_Items__r',
82 | };
83 |
84 | const fieldSubquery: FieldSubquery = {
85 | type: 'FieldSubquery',
86 | subquery: subquery,
87 | };
88 |
89 | const whereClause: WhereClause = {
90 | left: {
91 | field: 'LoginTime',
92 | operator: '>',
93 | value: '2010-09-20T22:16:30.000Z',
94 | literalType: 'DATETIME',
95 | },
96 | operator: 'AND',
97 | right: {
98 | left: {
99 | field: 'LoginTime',
100 | operator: '<',
101 | value: '2010-09-21',
102 | literalType: 'DATE',
103 | },
104 | },
105 | };
106 |
107 | const havingClause: HavingClause = {
108 | left: {
109 | fn: {
110 | functionName: 'COUNT',
111 | parameters: ['Id'],
112 | rawValue: 'COUNT(Id)',
113 | },
114 | operator: '>',
115 | value: '1',
116 | literalType: 'INTEGER',
117 | },
118 | };
119 |
120 | const valueCondition: ValueCondition = {
121 | field: 'Amount',
122 | operator: 'IN',
123 | value: ['usd500.01', 'usd600'],
124 | literalType: ['DECIMAL_WITH_CURRENCY_PREFIX', 'INTEGER_WITH_CURRENCY_PREFIX'],
125 | };
126 |
127 | const valueWithDateLiteral: ValueWithDateLiteralCondition = {
128 | field: 'CreatedDate',
129 | operator: 'IN',
130 | value: ['TODAY'],
131 | literalType: 'DATE_LITERAL',
132 | };
133 |
134 | const valueWithDateNLiteral: ValueWithDateNLiteralCondition = {
135 | field: 'CreatedDate',
136 | operator: 'IN',
137 | value: ['LAST_N_DAYS:2'],
138 | literalType: 'DATE_N_LITERAL',
139 | dateLiteralVariable: [2],
140 | };
141 |
142 | const valueFunctionCondition: ValueFunctionCondition = {
143 | fn: {
144 | functionName: 'DISTANCE',
145 | parameters: [
146 | 'Location__c',
147 | {
148 | functionName: 'GEOLOCATION',
149 | parameters: ['37.775', '-122.418'],
150 | rawValue: 'GEOLOCATION(37.775, -122.418)',
151 | },
152 | "'mi'",
153 | ],
154 | rawValue: "DISTANCE(Location__c, GEOLOCATION(37.775, -122.418), 'mi')",
155 | },
156 | operator: '<',
157 | value: '20',
158 | literalType: 'INTEGER',
159 | };
160 |
161 | const valueQuery: ValueQueryCondition = {
162 | field: 'CreatedById',
163 | operator: 'IN',
164 | valueQuery: {
165 | fields: [
166 | {
167 | type: 'FieldTypeof',
168 | field: 'Owner',
169 | conditions: [
170 | {
171 | type: 'WHEN',
172 | objectType: 'User',
173 | fieldList: ['Id'],
174 | },
175 | {
176 | type: 'WHEN',
177 | objectType: 'Group',
178 | fieldList: ['CreatedById'],
179 | },
180 | ],
181 | },
182 | ],
183 | sObject: 'CASE',
184 | },
185 | };
186 |
187 | const orderByField: OrderByFieldClause = {
188 | field: 'Name',
189 | order: 'DESC',
190 | nulls: 'LAST',
191 | };
192 |
193 | const orderByFn: OrderByFnClause = {
194 | fn: {
195 | functionName: 'COUNT',
196 | parameters: ['Id'],
197 | rawValue: 'COUNT(Id)',
198 | },
199 | order: 'DESC',
200 | };
201 |
202 | const groupByField: GroupByFieldClause = {
203 | field: 'LeadSource',
204 | };
205 |
206 | const groupByFn: GroupByFnClause = {
207 | fn: {
208 | functionName: 'CUBE',
209 | parameters: ['Type', 'BillingCountry'],
210 | rawValue: 'CUBE(Type, BillingCountry)',
211 | },
212 | };
213 | // TODO: seems buggy
214 | // isNegationCondition
215 |
216 | const utilityFns = [
217 | {
218 | label: `hasAlias(value)`,
219 | input: `hasAlias(${JSON.stringify(fieldWithAlias, null, 2)});`,
220 | output: JSON.stringify(hasAlias(fieldWithAlias), null, 2) || 'false',
221 | },
222 | {
223 | label: `getField(value)`,
224 | input: `getField(${'Name'});`,
225 | output: JSON.stringify(getField('Name'), null, 2) || 'false',
226 | },
227 | {
228 | label: `getFlattenedFields(value)`,
229 | input: `getFlattenedFields(${JSON.stringify(sampleQuery, null, 2)});`,
230 | output: JSON.stringify(getFlattenedFields(sampleQuery), null, 2) || 'false',
231 | },
232 | {
233 | label: `isSubquery(value)`,
234 | input: `isSubquery(${JSON.stringify(subquery, null, 2)});`,
235 | output: JSON.stringify(isSubquery(subquery), null, 2) || 'false',
236 | },
237 | {
238 | label: `isFieldSubquery(value)`,
239 | input: `isFieldSubquery(${JSON.stringify(fieldSubquery, null, 2)});`,
240 | output: JSON.stringify(isFieldSubquery(fieldSubquery), null, 2) || 'false',
241 | },
242 | {
243 | label: `isWhereClauseWithRightCondition(value)`,
244 | input: `isWhereClauseWithRightCondition(${JSON.stringify(whereClause, null, 2)});`,
245 | output: JSON.stringify(isWhereClauseWithRightCondition(whereClause), null, 2) || 'false',
246 | },
247 | {
248 | label: `isHavingClauseWithRightCondition(value)`,
249 | input: `isHavingClauseWithRightCondition(${JSON.stringify(havingClause, null, 2)});`,
250 | output: JSON.stringify(isHavingClauseWithRightCondition(havingClause), null, 2) || 'false',
251 | },
252 | {
253 | label: `isWhereOrHavingClauseWithRightCondition(value)`,
254 | input: `isWhereOrHavingClauseWithRightCondition(${JSON.stringify(whereClause, null, 2)});`,
255 | output: JSON.stringify(isWhereOrHavingClauseWithRightCondition(whereClause), null, 2) || 'false',
256 | },
257 | {
258 | label: `isValueCondition(value)`,
259 | input: `isValueCondition(${JSON.stringify(valueCondition, null, 2)});`,
260 | output: JSON.stringify(isValueCondition(valueCondition), null, 2) || 'false',
261 | },
262 | {
263 | label: `isValueWithDateLiteralCondition(value)`,
264 | input: `isValueWithDateLiteralCondition(${JSON.stringify(valueWithDateLiteral, null, 2)});`,
265 | output: JSON.stringify(isValueWithDateLiteralCondition(valueWithDateLiteral), null, 2) || 'false',
266 | },
267 | {
268 | label: `isValueWithDateNLiteralCondition(value)`,
269 | input: `isValueWithDateNLiteralCondition(${JSON.stringify(valueWithDateNLiteral, null, 2)});`,
270 | output: JSON.stringify(isValueWithDateNLiteralCondition(valueWithDateNLiteral), null, 2) || 'false',
271 | },
272 | {
273 | label: `isValueFunctionCondition(value)`,
274 | input: `isValueFunctionCondition(${JSON.stringify(valueFunctionCondition, null, 2)});`,
275 | output: JSON.stringify(isValueFunctionCondition(valueFunctionCondition), null, 2) || 'false',
276 | },
277 | {
278 | label: `isNegationCondition(value)`,
279 | input: `TODO`,
280 | output: 'TODO',
281 | },
282 | {
283 | label: `isValueQueryCondition(value)`,
284 | input: `isValueQueryCondition(${JSON.stringify(valueQuery, null, 2)});`,
285 | output: JSON.stringify(isValueQueryCondition(valueQuery), null, 2) || 'false',
286 | },
287 | {
288 | label: `isOrderByField(value)`,
289 | input: `isOrderByField(${JSON.stringify(orderByField, null, 2)});`,
290 | output: JSON.stringify(isOrderByField(orderByField), null, 2) || 'false',
291 | },
292 | {
293 | label: `isOrderByFn(value)`,
294 | input: `isOrderByFn(${JSON.stringify(orderByFn, null, 2)});`,
295 | output: JSON.stringify(isOrderByFn(orderByFn), null, 2) || 'false',
296 | },
297 | {
298 | label: `isGroupByField(value)`,
299 | input: `isGroupByField(${JSON.stringify(groupByField, null, 2)});`,
300 | output: JSON.stringify(isGroupByField(groupByField), null, 2) || 'false',
301 | },
302 | {
303 | label: `isGroupByFn(value)`,
304 | input: `isGroupByFn(${JSON.stringify(groupByFn, null, 2)});`,
305 | output: JSON.stringify(isGroupByFn(groupByFn), null, 2) || 'false',
306 | },
307 | ];
308 |
309 | export interface UtilitiesProps {}
310 |
311 | export default function Utilities({}: UtilitiesProps) {
312 | return (
313 | <>
314 | {utilityFns.map(item => (
315 |
316 |
{item.label}
317 |
327 |
328 | ))}
329 | >
330 | );
331 | }
332 |
--------------------------------------------------------------------------------
/docs/src/css/custom.css:
--------------------------------------------------------------------------------
1 | /**
2 | * Any CSS included here will be global. The classic template
3 | * bundles Infima by default. Infima is a CSS framework designed to
4 | * work well for content-centric websites.
5 | */
6 |
7 | /* You can override the default Infima variables here. */
8 | :root {
9 | --ifm-color-primary: #2e8555;
10 | --ifm-color-primary-dark: #29784c;
11 | --ifm-color-primary-darker: #277148;
12 | --ifm-color-primary-darkest: #205d3b;
13 | --ifm-color-primary-light: #33925d;
14 | --ifm-color-primary-lighter: #359962;
15 | --ifm-color-primary-lightest: #3cad6e;
16 | --ifm-code-font-size: 95%;
17 | --docusaurus-highlighted-code-line-bg: rgba(0, 0, 0, 0.1);
18 | }
19 |
20 | /* For readability concerns, you should choose a lighter palette in dark mode. */
21 | [data-theme='dark'] {
22 | --ifm-color-primary: #25c2a0;
23 | --ifm-color-primary-dark: #21af90;
24 | --ifm-color-primary-darker: #1fa588;
25 | --ifm-color-primary-darkest: #1a8870;
26 | --ifm-color-primary-light: #29d5b0;
27 | --ifm-color-primary-lighter: #32d8b4;
28 | --ifm-color-primary-lightest: #4fddbf;
29 | --docusaurus-highlighted-code-line-bg: rgba(0, 0, 0, 0.3);
30 | }
31 |
32 | .label {
33 | color: var(--ifm-color-primary);
34 | font-size: 0.8rem;
35 | font-weight: bold;
36 | margin-right: 0.5rem;
37 | }
38 |
39 | .label.label--large {
40 | font-size: 1.2rem;
41 | }
42 |
43 | label.disabled {
44 | opacity: 0.75;
45 | }
46 |
47 | label > input[type='checkbox'] {
48 | margin-right: 0.5rem;
49 | }
50 |
51 | label > input[type='number'] {
52 | margin-left: 0.5rem;
53 | }
54 |
55 | .wrap-text {
56 | white-space: pre-wrap;
57 | overflow-wrap: anywhere;
58 | }
59 |
--------------------------------------------------------------------------------
/docs/src/pages/index.module.css:
--------------------------------------------------------------------------------
1 | /**
2 | * CSS files with the .module.css suffix will be treated as CSS modules
3 | * and scoped locally.
4 | */
5 |
6 | .heroBanner {
7 | padding: 4rem 0;
8 | text-align: center;
9 | position: relative;
10 | overflow: hidden;
11 | }
12 |
13 | @media screen and (max-width: 996px) {
14 | .heroBanner {
15 | padding: 2rem;
16 | }
17 | }
18 |
19 | .buttons {
20 | display: flex;
21 | align-items: center;
22 | justify-content: center;
23 | gap: 1rem;
24 | }
25 |
--------------------------------------------------------------------------------
/docs/src/pages/index.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import clsx from 'clsx';
3 | import Link from '@docusaurus/Link';
4 | import useDocusaurusContext from '@docusaurus/useDocusaurusContext';
5 | import Layout from '@theme/Layout';
6 | import HomepageFeatures from '@site/src/components/HomepageFeatures';
7 |
8 | import styles from './index.module.css';
9 |
10 | function HomepageHeader() {
11 | const { siteConfig } = useDocusaurusContext();
12 | return (
13 |
27 | );
28 | }
29 |
30 | export default function Home(): JSX.Element {
31 | const { siteConfig } = useDocusaurusContext();
32 | return (
33 |
34 |
35 |
36 |
37 |
38 |
39 | );
40 | }
41 |
--------------------------------------------------------------------------------
/docs/src/pages/playground.module.css:
--------------------------------------------------------------------------------
1 | /**
2 | * CSS files with the .module.css suffix will be treated as CSS modules
3 | * and scoped locally.
4 | */
5 |
6 | .heroBanner {
7 | padding: 4rem 0;
8 | text-align: center;
9 | position: relative;
10 | overflow: hidden;
11 | }
12 |
13 | @media screen and (max-width: 996px) {
14 | .heroBanner {
15 | padding: 2rem;
16 | }
17 | }
18 |
19 | .buttons {
20 | display: flex;
21 | align-items: center;
22 | justify-content: center;
23 | }
24 |
--------------------------------------------------------------------------------
/docs/src/pages/playground.tsx:
--------------------------------------------------------------------------------
1 | import ComposeQueries from '@site/src/components/ComposeQueries';
2 | import ParseQueries from '@site/src/components/ParseQueries';
3 | import SoqlList from '@site/src/components/SoqlList';
4 | import Tabs from '@site/src/components/Tabs';
5 | import Layout from '@theme/Layout';
6 | import clsx from 'clsx';
7 | import React, { useState } from 'react';
8 | import Utilities from '../components/Utilities';
9 |
10 | export default function Playground() {
11 | const [query, setQuery] = useState('SELECT Id, Name, BillingCity FROM Account');
12 | const [selectedQuery, setSelectedQuery] = useState('SELECT Id, Name, BillingCity FROM Account');
13 | const [sidebarOpen, setSidebarOpen] = useState(true);
14 |
15 | function handleSelection(query: string) {
16 | setSelectedQuery(query);
17 | setQuery(query);
18 | }
19 |
20 | function handledToggleSidebarOpen() {
21 | setSidebarOpen(!sidebarOpen);
22 | }
23 |
24 | function handleQueryChange(value: string) {
25 | setQuery(value);
26 | }
27 |
28 | return (
29 |
30 |
31 |
38 |
39 |
45 |
46 |
49 |
50 | ),
51 | },
52 | {
53 | id: 'compose',
54 | label: 'Compose Queries',
55 | content: (
56 |
57 |
58 |
64 |
65 |
66 |
67 |
68 |
69 | ),
70 | },
71 | {
72 | id: 'utilities',
73 | label: 'Utility Functions',
74 | content: (
75 |
76 | {/*
77 |
83 |
*/}
84 |
85 |
86 |
87 |
88 | ),
89 | },
90 | ]}
91 | />
92 |
93 |
94 | );
95 | }
96 |
--------------------------------------------------------------------------------
/docs/static/.nojekyll:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/jetstreamapp/soql-parser-js/4ca954e9a174d491809fa5bf0f8e1b7ddf9ebe7b/docs/static/.nojekyll
--------------------------------------------------------------------------------
/docs/static/CNAME:
--------------------------------------------------------------------------------
1 | soql-parser-js.getjetstream.app
--------------------------------------------------------------------------------
/docs/static/img/docusaurus.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/jetstreamapp/soql-parser-js/4ca954e9a174d491809fa5bf0f8e1b7ddf9ebe7b/docs/static/img/docusaurus.png
--------------------------------------------------------------------------------
/docs/static/img/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/jetstreamapp/soql-parser-js/4ca954e9a174d491809fa5bf0f8e1b7ddf9ebe7b/docs/static/img/favicon.ico
--------------------------------------------------------------------------------
/docs/static/img/logo.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/docs/static/img/soql-parser-js-logo.svg:
--------------------------------------------------------------------------------
1 | soql-parser-js-logo
--------------------------------------------------------------------------------
/docs/static/img/undraw_docusaurus_tree.svg:
--------------------------------------------------------------------------------
1 |
2 | Focus on What Matters
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 |
32 |
33 |
34 |
35 |
36 |
37 |
38 |
39 |
40 |
41 |
--------------------------------------------------------------------------------
/docs/static/sample-queries-json.json:
--------------------------------------------------------------------------------
1 | [
2 | "SELECT Id, Name, BillingCity FROM Account",
3 | "SELECT Id FROM Contact WHERE Name LIKE 'A%' AND MailingCity = 'California'",
4 | "SELECT Name FROM Account ORDER BY Name DESC NULLS LAST",
5 | "SELECT Name FROM Account WHERE Industry = 'media' LIMIT 125",
6 | "SELECT Name FROM Account WHERE Industry = 'media' ORDER BY BillingPostalCode ASC NULLS LAST LIMIT 125",
7 | "SELECT COUNT() FROM Contact",
8 | "SELECT LeadSource, COUNT(Name) FROM Lead GROUP BY LeadSource",
9 | "SELECT Name, COUNT(Id) FROM Account GROUP BY Name HAVING COUNT(Id) > 1",
10 | "SELECT Name, Id FROM Merchandise__c ORDER BY Name OFFSET 100",
11 | "SELECT Name, Id FROM Merchandise__c ORDER BY Name LIMIT 20 OFFSET 100",
12 | "SELECT Contact.FirstName, Contact.Account.Name FROM Contact",
13 | "SELECT Id, Name, Account.Name FROM Contact WHERE Account.Industry = 'media'",
14 | "SELECT Name, (SELECT LastName FROM Contacts) FROM Account",
15 | "SELECT Account.Name, (SELECT Contact.LastName FROM Account.Contacts) FROM Account",
16 | "SELECT Name, (SELECT LastName FROM Contacts WHERE CreatedBy.Alias='x') FROM Account WHERE Industry='media'",
17 | "SELECT Id, FirstName__c, Mother_of_Child__r.FirstName__c FROM Daughter__c WHERE Mother_of_Child__r.LastName__c LIKE 'C%'",
18 | "SELECT Name, (SELECT Name FROM Line_Items__r) FROM Merchandise__c WHERE Name LIKE 'Acme%'",
19 | "SELECT Id, Owner.Name FROM Task WHERE Owner.FirstName LIKE 'B%'",
20 | "SELECT Id, Who.FirstName, Who.LastName FROM Task WHERE Owner.FirstName LIKE 'B%'",
21 | "SELECT Id, What.Name FROM Event",
22 | "SELECT TYPEOF What WHEN Account THEN Phone, NumberOfEmployees WHEN Opportunity THEN Amount, CloseDate ELSE Name, Email END FROM Event",
23 | "SELECT Name, (SELECT CreatedBy.Name FROM Notes) FROM Account",
24 | "SELECT Amount, Id, Name, (SELECT Quantity, ListPrice, PricebookEntry.UnitPrice, PricebookEntry.Name FROM OpportunityLineItems) FROM Opportunity",
25 | "SELECT UserId, LoginTime FROM LoginHistory",
26 | "SELECT UserId, COUNT(Id) FROM LoginHistory WHERE LoginTime > 2010-09-20T22:16:30.000Z AND LoginTime < 2010-09-21 GROUP BY UserId",
27 | "SELECT Id, Name, IsActive, SobjectType, DeveloperName, Description FROM RecordType",
28 | "SELECT CampaignId, AVG(Amount) avg FROM Opportunity GROUP BY CampaignId HAVING COUNT(Id, Name) > 1",
29 | "SELECT LeadSource, COUNT(Name) cnt FROM Lead GROUP BY ROLLUP(LeadSource)",
30 | "SELECT Status, LeadSource, COUNT(Name) cnt FROM Lead GROUP BY ROLLUP(Status, LeadSource)",
31 | "SELECT Type, BillingCountry, GROUPING(Type)grpType, GROUPING(BillingCountry) grpCty, COUNT(id) accts FROM Account GROUP BY CUBE(Type,BillingCountry) ORDER BY GROUPING(Type), GROUPING(Id,BillingCountry), Name DESC NULLS FIRST, Id ASC NULLS LAST",
32 | "SELECT c.Name, c.Account.Name FROM Contact c",
33 | "SELECT Id FROM Account WHERE (Id IN ('1', '2', '3') OR (NOT Id = '2') OR (Name LIKE '%FOO%' OR (Name LIKE '%ARM%' AND FOO = 'bar')))",
34 | "SELECT LeadSource, COUNT(Name) FROM Lead GROUP BY LeadSource HAVING COUNT(Name) > 100 AND LeadSource > 'Phone'",
35 | "SELECT a.Id, a.Name, (SELECT a2.Id FROM ChildAccounts a2), (SELECT a1.Id FROM ChildAccounts1 a1) FROM Account a",
36 | "SELECT Title FROM KnowledgeArticleVersion WHERE PublishStatus = 'online' WITH DATA CATEGORY Geography__c ABOVE usa__c",
37 | "SELECT Title FROM Question WHERE LastReplyDate > 2005-10-08T01:02:03Z WITH DATA CATEGORY Geography__c AT (usa__c, uk__c)",
38 | "SELECT UrlName FROM KnowledgeArticleVersion WHERE PublishStatus = 'draft' WITH DATA CATEGORY Geography__c AT usa__c AND Product__c ABOVE_OR_BELOW mobile_phones__c",
39 | "SELECT Id FROM Contact FOR VIEW",
40 | "SELECT Id FROM Contact FOR REFERENCE",
41 | "SELECT Id FROM Contact FOR UPDATE",
42 | "SELECT Id FROM FAQ__kav FOR UPDATE",
43 | "SELECT Id FROM FAQ__kav FOR VIEW UPDATE TRACKING",
44 | "SELECT Id FROM FAQ__kav UPDATE VIEWSTAT",
45 | "SELECT amount, FORMAT(amount) Amt, convertCurrency(amount) editDate, FORMAT(convertCurrency(amount)) convertedCurrency FROM Opportunity WHERE id = '12345'",
46 | "SELECT FORMAT(MIN(closedate)) Amt FROM Opportunity",
47 | "SELECT Company, toLabel(Status) FROM Lead WHERE toLabel(Status) = 'le Draft'",
48 | "SELECT Id, Name FROM Account WHERE Id IN (SELECT AccountId FROM Opportunity WHERE StageName = 'Closed Lost')",
49 | "SELECT Id FROM Account WHERE Id NOT IN (SELECT AccountId FROM Opportunity WHERE IsClosed = TRUE)",
50 | "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)",
51 | "SELECT Account.Name, (SELECT Contact.LastName FROM Account.Contact.Foo.Bars) FROM Account",
52 | "SELECT LeadSource, COUNT(Name)cnt FROM Lead",
53 | "SELECT Id, Name FROM Account WHERE Name != 'foo'",
54 | "SELECT Id FROM Account WHERE Foo IN ('1', '2', '3') OR Bar IN (1, 2, 3) OR Baz IN (101.00, 102.50) OR Bam IN ('FOO', null)",
55 | "SELECT Id, Name FROM Account WHERE CreatedDate > LAST_N_YEARS:1 AND LastModifiedDate > LAST_MONTH",
56 | "SELECT Id, CreatedById, CreatedDate, DefType, IsDeleted, Format, LastModifiedById, LastModifiedDate, AuraDefinitionBundleId, ManageableState, Source, SystemModstamp FROM AuraDefinition",
57 | "SELECT Id, Name, BillingCity FROM Account WITH SECURITY_ENFORCED",
58 | "SELECT Title FROM KnowledgeArticleVersion WHERE PublishStatus = 'online' WITH DATA CATEGORY Geography__c ABOVE usa__c WITH SECURITY_ENFORCED",
59 | "SELECT Id FROM Account WHERE (((Name = '1' OR Name = '2') AND Name = '3')) AND (((Description = '123') OR (Id = '1' AND Id = '2'))) AND Id = '1'",
60 | "SELECT TYPEOF What WHEN Account THEN Phone, NumberOfEmployees WHEN Opportunity THEN Amount, CloseDate END FROM Event",
61 | "SELECT Name FROM Account WHERE CreatedById IN (SELECT TYPEOF Owner WHEN User THEN Id WHEN Group THEN CreatedById END FROM CASE)",
62 | "SELECT Name FROM Account OFFSET 1",
63 | "SELECT Name FROM Account WHERE Id = :foo",
64 | "SELECT Name FROM Account WHERE Industry IN ('media', null, 1, 'media', 2) LIMIT 125",
65 | "SELECT Name FROM Account WHERE Foo = NULL",
66 | "SELECT Name FROM Account WHERE Foo = TODAY",
67 | "SELECT Name FROM Account WHERE Foo = LAST_N_YEARS:1",
68 | "SELECT Name FROM Account WHERE Foo = 2010-09-20T22:16:30.000Z",
69 | "SELECT Name FROM Account WHERE Foo = 1",
70 | "SELECT Name FROM Account WHERE Foo = TRUE AND bar = FALSE",
71 | "SELECT CALENDAR_YEAR(CreatedDate) calYear, SUM(Amount) mySum FROM Opportunity GROUP BY CALENDAR_YEAR(CreatedDate)",
72 | "SELECT CALENDAR_YEAR(convertTimezone(CreatedDate)) calYear, SUM(Amount) mySum FROM Opportunity GROUP BY CALENDAR_YEAR(convertTimezone(CreatedDate))",
73 | "SELECT COUNT_DISTINCT(Company) distinct FROM Lead",
74 | "SELECT Name, toLabel(Recordtype.Name) FROM Account",
75 | "SELECT Id, MSP1__c FROM CustObj__c WHERE MSP1__c INCLUDES ('AAA;BBB', 'CCC')",
76 | "SELECT Id FROM Account WHERE CreatedDate > LAST_N_FISCAL_QUARTERS:6",
77 | "SELECT Id FROM Opportunity WHERE CloseDate < NEXT_N_FISCAL_YEARS:3",
78 | "SELECT Id FROM Opportunity WHERE CloseDate > LAST_N_FISCAL_YEARS:3",
79 | "SELECT Id, Title FROM Dashboard USING SCOPE allPrivate WHERE Type != 'SpecifiedUser'",
80 | "SELECT LeadSource, Rating, GROUPING(LeadSource) grpLS, GROUPING(Rating) grpRating, COUNT(Name) cnt FROM Lead GROUP BY ROLLUP(LeadSource, Rating)",
81 | "SELECT Type, BillingCountry, GROUPING(Type) grpType, GROUPING(BillingCountry) grpCty, COUNT(id) accts FROM Account GROUP BY CUBE(Type, BillingCountry) ORDER BY GROUPING(Type), GROUPING(BillingCountry)",
82 | "SELECT HOUR_IN_DAY(convertTimezone(CreatedDate)), SUM(Amount) FROM Opportunity GROUP BY HOUR_IN_DAY(convertTimezone(CreatedDate))",
83 | "SELECT Id FROM Opportunity WHERE Amount > USD5000",
84 | "SELECT Id FROM Opportunity WHERE Amount > USD5000.01",
85 | "SELECT Id, Amount FROM Opportunity WHERE Amount IN (usd500.01, usd600)",
86 | "SELECT Name, COUNT(Id) FROM Account GROUP BY Name HAVING COUNT(Id) > 0 AND (Name LIKE '%testing%' OR Name LIKE '%123%')",
87 | "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))",
88 | "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'))",
89 | "SELECT Name, Location__c FROM Warehouse__c WHERE DISTANCE(Location__c, GEOLOCATION(37.775, -122.418), 'mi') < 20",
90 | "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') DESC LIMIT 10",
91 | "SELECT Id, Name, Location, DISTANCE(Location, GEOLOCATION(10, 10), 'mi') FROM CONTACT",
92 | "SELECT BillingState, BillingStreet, COUNT(Id) FROM Account GROUP BY BillingState, BillingStreet",
93 | "SELECT Id, Name, Location__c, DISTANCE(Location__c, GEOLOCATION(-10.775, -10.775), 'MI') FROM CONTACT",
94 | "SELECT Id FROM Account WHERE CreatedDate IN (TODAY)",
95 | "SELECT Id FROM Account WHERE CreatedDate IN (TODAY)",
96 | "SELECT Id FROM Account WHERE CreatedDate IN (TODAY, LAST_N_DAYS:4)",
97 | "SELECT Id FROM Account WHERE CreatedDate IN (LAST_N_DAYS:2)",
98 | "SELECT Id FROM Account WHERE CreatedDate IN (LAST_N_DAYS:4, LAST_N_DAYS:7)",
99 | "SELECT SBQQ__Product__r.Name foo, SBQQ__Quote__c foo1 FROM SBQQ__Quoteline__c GROUP BY SBQQ__Quote__c, SBQQ__Product__r.Name",
100 | "SELECT Id, convertCurrency(Amount) FROM Opportunity WHERE Amount > 0 AND CALENDAR_YEAR(CloseDate) = 2020",
101 | "SELECT Id FROM LoginHistory WHERE LoginTime > 2020-04-23T09:00:00.00000000000000000000000000000000+00:00 AND LoginTime < 2020-04-15T02:40:03.000+0000",
102 | "SELECT ProductCode FROM Product2 GROUP BY ProductCode HAVING COUNT(Id) > 1 ORDER BY COUNT(Id) DESC",
103 | "SELECT AnnualRevenue FROM Account WHERE NOT (AnnualRevenue > 0 AND AnnualRevenue < 200000)",
104 | "SELECT AnnualRevenue FROM Account WHERE ((NOT AnnualRevenue > 0) AND AnnualRevenue < 200000)",
105 | "SELECT Id FROM Account WHERE NOT Id = '2'",
106 | "SELECT WEEK_IN_YEAR(CloseDate), SUM(amount) FROM Opportunity GROUP BY WEEK_IN_YEAR(CloseDate) ORDER BY WEEK_IN_YEAR(CloseDate)",
107 | "SELECT WEEK_IN_YEAR(CloseDate), SUM(amount) FROM Opportunity GROUP BY WEEK_IN_YEAR(CloseDate) ORDER BY WEEK_IN_YEAR(CloseDate) DESC NULLS FIRST",
108 | "SELECT WEEK_IN_YEAR(CloseDate), SUM(amount) FROM Opportunity GROUP BY WEEK_IN_YEAR(CloseDate) ORDER BY WEEK_IN_YEAR(CloseDate) DESC NULLS LAST, SUM(amount) ASC NULLS LAST",
109 | "SELECT FIELDS(ALL) FROM Account",
110 | "SELECT FIELDS(CUSTOM), FIELDS(STANDARD) FROM Account",
111 | "SELECT Id, (SELECT FIELDS(ALL) FROM Contacts) FROM Account",
112 | "SELECT UserId, CALENDAR_MONTH(LoginTime) month FROM LoginHistory WHERE NetworkId != NULL GROUP BY UserId, CALENDAR_MONTH(LoginTime)",
113 | "SELECT Id, (SELECT Id FROM Contacts WHERE Id IN :contactMap.keySet()) FROM Account WHERE Id IN :accountMap.keySet()",
114 | "SELECT Id, (SELECT Id FROM Contacts WHERE Id IN :contact_900Map.keySet()) FROM Account WHERE Id IN :acco INVALID untMap.keySet()",
115 | "SELECT Id FROM Account WHERE Id IN :new Map(someVar).keySet()",
116 | "SELECT Id FROM Account WHERE Id IN :new Map(someVar).getSomeClass().records",
117 | "SELECT Id FROM SBQQ__QuoteTerm__c WHERE SBQQ__StandardTerm__c = :CPQ_Hard_Coded_Ids__c.getInstance().Standard_Quote_Term_Id__c",
118 | "SELECT Id FROM Opportunity WHERE SBQQ__StandardTerm__c = :quotes[3].SBQQ__QuoteLine__r[0].Term__c",
119 | "SELECT Name FROM Account WHERE Name IN ('GenePoint\\'s \\n Ok!?!@#$^%$&*()_+')",
120 | "SELECT State_Abbr_c FROM Contact WHERE State_Abbr_c = 'MI' OR State_Abbr_c = 'km'",
121 | "SELECT State_Abbr_c FROM Contact WHERE State_Abbr_c = 'KM'",
122 | "SELECT State_Abbr_c FROM Contact WHERE State_Abbr_c IN ('mi', 'KM')",
123 | "SELECT LeadSource, COUNT(Name) FROM Lead GROUP BY LeadSource HAVING COUNT(Name) > 100 AND LeadSource > 'km'",
124 | "SELECT Id FROM Account WITH USER_MODE",
125 | "SELECT Id FROM Account WITH SYSTEM_MODE",
126 | "SELECT Id, BillingCity FROM Account WHERE NOT (NOT BillingCity LIKE '%123%')",
127 | "SELECT Id FROM Account WHERE NOT (NOT Invoice_Type__c LIKE '%Usage%')",
128 | "SELECT Id FROM Account WHERE (NOT Invoice_Type__c LIKE '%Usage%')",
129 | "SELECT Id, City FROM Lead WHERE NOT ((NOT (City LIKE '%LHR%')) AND City LIKE '%KHR%')",
130 | "SELECT Name FROM Invoice__c WHERE Balance__c < USD-500"
131 | ]
--------------------------------------------------------------------------------
/docs/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | // This file is not used in compilation. It is here just for a nice editor experience.
3 | "extends": "@docusaurus/tsconfig",
4 | "compilerOptions": {
5 | "baseUrl": ".",
6 | "resolveJsonModule": true
7 | }
8 | }
9 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "@jetstreamapp/soql-parser-js",
3 | "version": "6.1.0",
4 | "description": "Salesforce.com SOQL parser and composer",
5 | "main": "dist/cjs/index.js",
6 | "module": "dist/esm/index.js",
7 | "types": "dist/types/index.d.ts",
8 | "scripts": {
9 | "clean": "rm -rf ./dist/*",
10 | "prebuild": "npm run clean",
11 | "build": "npm-run-all build:esm build:cjs build:cli build:declarations",
12 | "build:esm": "esbuild src/index.ts --bundle --outfile=dist/esm/index.js --minify --format=esm --target=es2022",
13 | "build:cjs": "esbuild src/index.ts --bundle --outfile=dist/cjs/index.js --minify --format=cjs --target=es2022",
14 | "build:cli": "esbuild cli/index.ts --bundle --outfile=dist/cli/index.js --minify --format=cjs --target=es2022 --platform=node",
15 | "build:declarations": "tsc --project tsconfig.json",
16 | "tsc": "./node_modules/.bin/tsc",
17 | "release": "release-it",
18 | "copy-tc-to-docs": "tsx ./tasks/copy-test-cases-to-docs.ts",
19 | "test": "vitest --passWithNoTests --testTimeout 10000",
20 | "test:watch": "jest --watch",
21 | "soql-parser-js": "node ./bin"
22 | },
23 | "author": "Austin Turner ",
24 | "license": "MIT",
25 | "bin": {
26 | "soql-parser-js": "bin/soql-parser-js"
27 | },
28 | "publishConfig": {
29 | "access": "public"
30 | },
31 | "files": [
32 | "dist/**",
33 | "bin/**",
34 | "AUTHORS.md",
35 | "CHANGELOG.md",
36 | "LICENSE.txt",
37 | "package.json",
38 | "README.md"
39 | ],
40 | "dependencies": {
41 | "chevrotain": "^11.0.3",
42 | "commander": "^2.20.3",
43 | "lodash.get": "^4.4.2"
44 | },
45 | "devDependencies": {
46 | "@types/lodash.get": "^4.4.9",
47 | "@types/node": "^20.14.2",
48 | "@vitest/ui": "^3.1.3",
49 | "chalk": "^4.1.2",
50 | "esbuild": "0.25.4",
51 | "license-webpack-plugin": "^4.0.2",
52 | "npm-run-all": "^4.1.5",
53 | "prettier": "^3.3.2",
54 | "release-it": "^17.0.1",
55 | "tsx": "^4.19.4",
56 | "typescript": "^4.2.3",
57 | "vitest": "^3.1.3"
58 | },
59 | "keywords": [
60 | "soql",
61 | "salesforce",
62 | "parse",
63 | "compose",
64 | "parser"
65 | ],
66 | "repository": {
67 | "type": "git",
68 | "url": "https://github.com/jetstreamapp/soql-parser-js"
69 | }
70 | }
71 |
--------------------------------------------------------------------------------
/src/api/api-models.ts:
--------------------------------------------------------------------------------
1 | export type LogicalOperatorAnd = 'AND';
2 | export type LogicalOperatorOr = 'OR';
3 | export type LogicalOperatorNot = 'NOT';
4 | export type LogicalOperator = LogicalOperatorAnd | LogicalOperatorOr | LogicalOperatorNot;
5 | export type Operator = '=' | '!=' | '<=' | '>=' | '>' | '<' | 'LIKE' | 'IN' | 'NOT IN' | 'INCLUDES' | 'EXCLUDES';
6 | export type FieldTypeOfConditionType = 'WHEN' | 'ELSE';
7 | export type GroupSelector = 'ABOVE' | 'AT' | 'BELOW' | 'ABOVE_OR_BELOW';
8 | export type ForClause = 'VIEW' | 'UPDATE' | 'REFERENCE';
9 | export type UpdateClause = 'TRACKING' | 'VIEWSTAT';
10 | export type LiteralType =
11 | | 'STRING'
12 | | 'INTEGER'
13 | | 'DECIMAL'
14 | | 'INTEGER_WITH_CURRENCY_PREFIX'
15 | | 'DECIMAL_WITH_CURRENCY_PREFIX'
16 | | 'BOOLEAN'
17 | | 'NULL'
18 | | 'DATETIME'
19 | | 'DATE'
20 | | 'DATE_LITERAL'
21 | | 'DATE_N_LITERAL'
22 | | 'APEX_BIND_VARIABLE'
23 | | 'SUBQUERY';
24 | export type FieldType =
25 | | Field
26 | | FieldWithAlias
27 | | FieldFunctionExpression
28 | | FieldRelationship
29 | | FieldRelationshipWithAlias
30 | | FieldSubquery
31 | | FieldTypeOf;
32 | export type OrderByCriterion = 'ASC' | 'DESC';
33 | export type NullsOrder = 'FIRST' | 'LAST';
34 | export type GroupByType = 'CUBE' | 'ROLLUP';
35 | export type AccessLevel = 'USER_MODE' | 'SYSTEM_MODE';
36 | export type DateLiteral =
37 | | 'YESTERDAY'
38 | | 'TODAY'
39 | | 'TOMORROW'
40 | | 'LAST_WEEK'
41 | | 'THIS_WEEK'
42 | | 'NEXT_WEEK'
43 | | 'LAST_MONTH'
44 | | 'THIS_MONTH'
45 | | 'NEXT_MONTH'
46 | | 'LAST_90_DAYS'
47 | | 'NEXT_90_DAYS'
48 | | 'THIS_QUARTER'
49 | | 'LAST_QUARTER'
50 | | 'NEXT_QUARTER'
51 | | 'THIS_YEAR'
52 | | 'LAST_YEAR'
53 | | 'NEXT_YEAR'
54 | | 'THIS_FISCAL_QUARTER'
55 | | 'LAST_FISCAL_QUARTER'
56 | | 'NEXT_FISCAL_QUARTER'
57 | | 'THIS_FISCAL_YEAR'
58 | | 'LAST_FISCAL_YEAR'
59 | | 'NEXT_FISCAL_YEAR';
60 |
61 | export type DateNLiteral =
62 | | 'YESTERDAY'
63 | | 'NEXT_N_DAYS'
64 | | 'LAST_N_DAYS'
65 | | 'N_DAYS_AGO'
66 | | 'NEXT_N_WEEKS'
67 | | 'LAST_N_WEEKS'
68 | | 'N_WEEKS_AGO'
69 | | 'NEXT_N_MONTHS'
70 | | 'LAST_N_MONTHS'
71 | | 'N_MONTHS_AGO'
72 | | 'NEXT_N_QUARTERS'
73 | | 'LAST_N_QUARTERS'
74 | | 'N_QUARTERS_AGO'
75 | | 'NEXT_N_YEARS'
76 | | 'LAST_N_YEARS'
77 | | 'N_YEARS_AGO'
78 | | 'NEXT_N_FISCAL_QUARTERS'
79 | | 'LAST_N_FISCAL_QUARTERS'
80 | | 'N_FISCAL_QUARTERS_AGO'
81 | | 'NEXT_N_FISCAL_YEARS'
82 | | 'LAST_N_FISCAL_YEARS'
83 | | 'N_FISCAL_YEARS_AGO';
84 |
85 | export interface Field {
86 | type: 'Field';
87 | field: string;
88 | alias?: string;
89 | }
90 |
91 | export interface FieldWithAlias extends Field {
92 | objectPrefix: string;
93 | rawValue: string;
94 | }
95 |
96 | export interface FieldFunctionExpression {
97 | type: 'FieldFunctionExpression';
98 | functionName: string;
99 | parameters: (string | FieldFunctionExpression)[];
100 | alias?: string;
101 | isAggregateFn?: boolean; // not required for compose, will be populated if SOQL is parsed
102 | rawValue?: string; // not required for compose, will be populated if SOQL is parsed
103 | }
104 |
105 | export interface FieldRelationship {
106 | type: 'FieldRelationship';
107 | field: string;
108 | relationships: string[];
109 | rawValue?: string; // not required for compose, will be populated if SOQL is parsed with the raw value of the entire field
110 | }
111 |
112 | export interface FieldRelationshipWithAlias extends FieldRelationship {
113 | objectPrefix: string;
114 | alias: string;
115 | }
116 |
117 | export interface FieldSubquery {
118 | type: 'FieldSubquery';
119 | subquery: Subquery;
120 | }
121 |
122 | export interface FieldTypeOf {
123 | type: 'FieldTypeof';
124 | field: string;
125 | conditions: FieldTypeOfCondition[];
126 | }
127 |
128 | export interface FieldTypeOfCondition {
129 | type: FieldTypeOfConditionType;
130 | objectType?: string; // not present when ELSE
131 | fieldList: string[];
132 | }
133 |
134 | export interface QueryBase {
135 | fields?: FieldType[];
136 | sObjectAlias?: string;
137 | usingScope?: string;
138 | where?: WhereClause;
139 | limit?: number;
140 | offset?: number;
141 | groupBy?: GroupByClause | GroupByClause[];
142 | having?: HavingClause;
143 | orderBy?: OrderByClause | OrderByClause[];
144 | withDataCategory?: WithDataCategoryClause;
145 | withSecurityEnforced?: boolean;
146 | withAccessLevel?: AccessLevel;
147 | for?: ForClause;
148 | update?: UpdateClause;
149 | }
150 |
151 | export interface Query extends QueryBase {
152 | sObject?: string;
153 | }
154 |
155 | export interface Subquery extends QueryBase {
156 | relationshipName: string;
157 | sObjectPrefix?: string[];
158 | }
159 |
160 | export type WhereClause = WhereClauseWithoutOperator | WhereClauseWithoutNegationOperator | WhereClauseWithRightCondition;
161 |
162 | export interface WhereClauseWithoutOperator {
163 | left: ConditionWithValueQuery;
164 | }
165 |
166 | export interface WhereClauseWithRightCondition extends WhereClauseWithoutOperator {
167 | operator: LogicalOperator;
168 | right: WhereClause;
169 | }
170 |
171 | /**
172 | * This is a special case where the left side of the where clause can potentially be null if there is a negation without parentheses
173 | */
174 | export interface WhereClauseWithoutNegationOperator {
175 | left: NegationCondition | null;
176 | operator: LogicalOperatorNot;
177 | right: WhereClause;
178 | }
179 |
180 | export type Condition =
181 | | ValueCondition
182 | | ValueWithDateLiteralCondition
183 | | ValueWithDateNLiteralCondition
184 | | ValueFunctionCondition
185 | | NegationCondition;
186 |
187 | export type ConditionWithValueQuery = Condition | ValueQueryCondition;
188 |
189 | export interface OptionalParentheses {
190 | openParen?: number;
191 | closeParen?: number;
192 | }
193 |
194 | export interface ValueCondition extends OptionalParentheses {
195 | field: string;
196 | operator: Operator;
197 | value: string | string[];
198 | literalType?: LiteralType | LiteralType[];
199 | }
200 |
201 | export interface ValueWithDateLiteralCondition extends OptionalParentheses {
202 | field: string;
203 | operator: Operator;
204 | value: DateLiteral | DateLiteral[];
205 | literalType?: 'DATE_LITERAL' | 'DATE_LITERAL'[];
206 | }
207 |
208 | export interface ValueWithDateNLiteralCondition extends OptionalParentheses {
209 | field: string;
210 | operator: Operator;
211 | value: string | string[];
212 | literalType?: 'DATE_N_LITERAL' | 'DATE_N_LITERAL'[];
213 | dateLiteralVariable: number | number[];
214 | }
215 |
216 | export interface ValueQueryCondition extends OptionalParentheses {
217 | field: string;
218 | operator: Operator;
219 | valueQuery: Query;
220 | literalType?: 'SUBQUERY';
221 | }
222 |
223 | export interface ValueFunctionCondition extends OptionalParentheses {
224 | fn: FunctionExp;
225 | operator: Operator;
226 | value: string | string[];
227 | literalType?: LiteralType | LiteralType[];
228 | }
229 |
230 | export interface NegationCondition {
231 | openParen: number;
232 | }
233 |
234 | export type OrderByClause = OrderByFieldClause | OrderByFnClause;
235 |
236 | export interface OrderByOptionalFieldsClause {
237 | order?: OrderByCriterion;
238 | nulls?: NullsOrder;
239 | }
240 |
241 | export interface OrderByFieldClause extends OrderByOptionalFieldsClause {
242 | field: string;
243 | }
244 |
245 | export interface OrderByFnClause extends OrderByOptionalFieldsClause {
246 | fn: FunctionExp;
247 | }
248 |
249 | export type GroupByClause = GroupByFieldClause | GroupByFnClause;
250 |
251 | export interface GroupByFieldClause {
252 | field: string;
253 | }
254 |
255 | export interface GroupByFnClause {
256 | fn: FunctionExp;
257 | }
258 |
259 | export type HavingClause = HavingClauseWithoutOperator | HavingClauseWithRightCondition;
260 |
261 | export interface HavingClauseWithoutOperator {
262 | left: Condition;
263 | }
264 |
265 | export interface HavingClauseWithRightCondition extends HavingClauseWithoutOperator {
266 | operator: LogicalOperator;
267 | right: HavingClause;
268 | }
269 |
270 | export interface FunctionExp {
271 | rawValue?: string; // only used for compose fields if useRawValueForFn=true. Should be formatted like this: Count(Id)
272 | functionName?: string; // only used for compose fields if useRawValueForFn=false, will be populated if SOQL is parsed
273 | alias?: string;
274 | parameters?: (string | FunctionExp)[]; // only used for compose fields if useRawValueForFn=false, will be populated if SOQL is parsed
275 | isAggregateFn?: boolean; // not used for compose, will be populated if SOQL is parsed
276 | }
277 |
278 | export interface WithDataCategoryClause {
279 | conditions: WithDataCategoryCondition[];
280 | }
281 |
282 | export interface WithDataCategoryCondition {
283 | groupName: string;
284 | selector: GroupSelector;
285 | parameters: string[];
286 | }
287 |
--------------------------------------------------------------------------------
/src/api/public-utils.ts:
--------------------------------------------------------------------------------
1 | import {
2 | getParams,
3 | hasAlias,
4 | isComposeField,
5 | isComposeFieldFunction,
6 | isComposeFieldRelationship,
7 | isComposeFieldSubquery,
8 | isComposeFieldTypeof,
9 | isFieldSubquery,
10 | isGroupByField,
11 | isGroupByFn,
12 | isHavingClauseWithRightCondition,
13 | isNegationCondition,
14 | isNestedParamAggregateFunction,
15 | isOrderByField,
16 | isOrderByFn,
17 | isString,
18 | isSubquery,
19 | isValueCondition,
20 | isValueFunctionCondition,
21 | isValueQueryCondition,
22 | isValueWithDateLiteralCondition,
23 | isValueWithDateNLiteralCondition,
24 | isWhereClauseWithRightCondition,
25 | isWhereOrHavingClauseWithRightCondition,
26 | } from '../utils';
27 | import * as SoqlModels from './api-models';
28 |
29 | // re-exported utils available as public API
30 | export {
31 | hasAlias,
32 | isFieldSubquery,
33 | isGroupByField,
34 | isGroupByFn,
35 | isHavingClauseWithRightCondition,
36 | isNegationCondition,
37 | isOrderByField,
38 | isOrderByFn,
39 | isString,
40 | isSubquery,
41 | isValueCondition,
42 | isValueFunctionCondition,
43 | isValueQueryCondition,
44 | isValueWithDateLiteralCondition,
45 | isValueWithDateNLiteralCondition,
46 | isWhereClauseWithRightCondition,
47 | isWhereOrHavingClauseWithRightCondition,
48 | };
49 |
50 | export type ComposeFieldInput = ComposeField | ComposeFieldFunction | ComposeFieldRelationship | ComposeFieldSubquery | ComposeFieldTypeof;
51 |
52 | export interface ComposeField {
53 | field: string;
54 | objectPrefix?: string;
55 | }
56 |
57 | export interface ComposeFieldFunction {
58 | // @Deprecated - will still be used if populated, but functionName is checked first and preferred
59 | fn?: string;
60 | functionName: string;
61 | parameters?: string | SoqlModels.FieldFunctionExpression | (string | SoqlModels.FieldFunctionExpression)[];
62 | alias?: string;
63 | }
64 |
65 | export interface ComposeFieldRelationship {
66 | field: string;
67 | relationships: string[];
68 | objectPrefix?: string;
69 | }
70 |
71 | export interface ComposeFieldSubquery {
72 | subquery?: SoqlModels.Subquery;
73 | }
74 |
75 | export interface ComposeFieldTypeof {
76 | field: string;
77 | conditions: SoqlModels.FieldTypeOfCondition[];
78 | }
79 |
80 | /**
81 | * @deprecated - use `getField()` instead
82 | * Pass any a basic string or populate required properties on the ComposeField object
83 | * and a constructed field will be returned
84 | * @param input string | ComposeFieldInput
85 | * @returns FieldType
86 | */
87 | export function getComposedField(input: string | ComposeFieldInput): SoqlModels.FieldType {
88 | return getField(input);
89 | }
90 | /**
91 | * Pass any a basic string or populate required properties on the ComposeField object
92 | * and a constructed field will be returned
93 | * @param input string | ComposeFieldInput
94 | * @returns FieldType
95 | */
96 | export function getField(input: string | ComposeFieldInput): SoqlModels.FieldType {
97 | if (typeof input === 'string') {
98 | return {
99 | type: 'Field',
100 | field: input,
101 | };
102 | } else if (isComposeFieldFunction(input)) {
103 | let parameters: string[] | SoqlModels.FieldFunctionExpression[] = [];
104 | if (input.parameters) {
105 | parameters = (Array.isArray(input.parameters) ? input.parameters : [input.parameters]) as
106 | | string[]
107 | | SoqlModels.FieldFunctionExpression[];
108 | }
109 | return {
110 | type: 'FieldFunctionExpression',
111 | functionName: (input.functionName || input.fn)!,
112 | parameters,
113 | alias: input.alias,
114 | };
115 | } else if (isComposeFieldRelationship(input)) {
116 | return {
117 | type: 'FieldRelationship',
118 | field: input.field,
119 | relationships: input.relationships,
120 | objectPrefix: input.objectPrefix,
121 | };
122 | } else if (isComposeFieldSubquery(input)) {
123 | return {
124 | type: 'FieldSubquery',
125 | subquery: input.subquery!,
126 | };
127 | } else if (isComposeFieldTypeof(input)) {
128 | return {
129 | type: 'FieldTypeof',
130 | field: input.field,
131 | conditions: input.conditions,
132 | };
133 | } else if (isComposeField(input)) {
134 | return {
135 | type: 'Field',
136 | field: input.field,
137 | objectPrefix: input.objectPrefix,
138 | };
139 | } else {
140 | throw new TypeError('The input object provided did not match any valid field types');
141 | }
142 | }
143 |
144 | /**
145 | * Gets flattened fields - this will turn a Query into a list of fields that can be used to parse results from a returned dataset from SFDC
146 | * Subqueries only include the child SObject relationship name
147 | * @param query
148 | * @param [isAggregateResult] pass in true to force expr0...1 for all non-aliased functions even if field is not explicitly an aggregate expression
149 | * @returns flattened fields
150 | */
151 | export function getFlattenedFields(
152 | query: SoqlModels.Query | SoqlModels.Subquery | SoqlModels.FieldSubquery,
153 | isAggregateResult?: boolean,
154 | ): string[] {
155 | if (!query) {
156 | return [];
157 | }
158 | query = isFieldSubquery(query) ? query.subquery : query;
159 | const fields = query.fields;
160 | if (!fields) {
161 | return [];
162 | }
163 | // if a relationship field is used in a group by, then Salesforce removes the relationship portion of the field in the returned records
164 | let groupByFields: { [field: string]: string } = {};
165 | if (!!query.groupBy) {
166 | groupByFields = (Array.isArray(query.groupBy) ? query.groupBy : [query.groupBy]).reduce(
167 | (output: { [field: string]: string }, clause) => {
168 | if (isGroupByField(clause)) {
169 | output[clause.field.toLocaleLowerCase()] = clause.field;
170 | }
171 | return output;
172 | },
173 | {},
174 | );
175 | }
176 | let currUnAliasedAggExp = -1;
177 | let sObject = (isSubquery(query) ? query.relationshipName : query.sObject || '').toLowerCase();
178 | let sObjectAlias = (query.sObjectAlias || '').toLowerCase();
179 |
180 | const parsedFields = fields
181 | .flatMap(field => {
182 | switch (field.type) {
183 | case 'Field': {
184 | return field.alias || field.field;
185 | }
186 | case 'FieldFunctionExpression': {
187 | let params = getParams(field);
188 | // If the parameter has dot notation and the first entry is the object name/alias, remove it
189 | params = params.map(param => {
190 | if (param.includes('.')) {
191 | let tempParams = param.split('.');
192 | const firstParam = tempParams[0].toLowerCase();
193 | if (firstParam === sObjectAlias || firstParam === sObject) {
194 | tempParams = tempParams.slice(1);
195 | }
196 | return tempParams.join('.');
197 | }
198 | return param;
199 | });
200 |
201 | if (field.alias && (field.isAggregateFn || isAggregateResult)) {
202 | return field.alias;
203 | }
204 |
205 | if (field.alias) {
206 | const firstParam = params[0];
207 | // Include the full path and replace the field with the alias
208 | if (firstParam.includes('.')) {
209 | params = firstParam.split('.').slice(0, -1);
210 | params.push(field.alias);
211 | return params.join('.');
212 | }
213 | return field.alias;
214 | }
215 | // Non-aliased aggregate fields use computed name expr0, expr1, etc..
216 | if (field.isAggregateFn || isNestedParamAggregateFunction(field) || isAggregateResult) {
217 | currUnAliasedAggExp++;
218 | return `expr${currUnAliasedAggExp}`;
219 | }
220 | if (params.length > 0) {
221 | return params.join('.');
222 | }
223 | return field.functionName;
224 | }
225 | case 'FieldRelationship': {
226 | const firstRelationship = field.relationships[0].toLowerCase();
227 | if (hasAlias(field)) {
228 | return field.alias;
229 | }
230 | // If relationship field is used in groupby, then return field instead of full path
231 | if (field.rawValue && groupByFields[field.rawValue.toLocaleLowerCase()]) {
232 | return field.field;
233 | }
234 | // If first object is the same object queried, remove the object
235 | if (firstRelationship === sObjectAlias || firstRelationship === sObject) {
236 | return field.relationships.concat([field.field]).slice(1).join('.');
237 | }
238 | return field.relationships.concat([field.field]).join('.');
239 | }
240 | case 'FieldSubquery': {
241 | return field.subquery.relationshipName;
242 | }
243 | case 'FieldTypeof': {
244 | // keep track of fields to avoid adding duplicates
245 | const priorFields = new Set();
246 | const fields: string[] = [];
247 | // Add all unique fields across all conditions
248 | field.conditions.forEach(condition => {
249 | condition.fieldList.forEach(currField => {
250 | if (!priorFields.has(currField)) {
251 | priorFields.add(currField);
252 | fields.push(`${field.field}.${currField}`);
253 | }
254 | });
255 | });
256 | return fields;
257 | }
258 | default:
259 | break;
260 | }
261 | })
262 | .filter(field => isString(field)) as string[];
263 |
264 | return parsedFields;
265 | }
266 |
--------------------------------------------------------------------------------
/src/formatter/formatter.ts:
--------------------------------------------------------------------------------
1 | import { FieldTypeOfCondition } from '../api/api-models';
2 | import { isNumber, generateParens } from '../utils';
3 |
4 | export interface FieldData {
5 | fields: {
6 | text: string;
7 | typeOfClause?: string[];
8 | isSubquery: boolean;
9 | prefix: string;
10 | suffix: string;
11 | }[];
12 | isSubquery: boolean;
13 | lineBreaks: number[];
14 | }
15 |
16 | export interface FormatOptions {
17 | /**
18 | * @default 1
19 | *
20 | * Number of tabs to indent by
21 | * These defaults to one
22 | */
23 | numIndent?: number;
24 | /**
25 | * @default 60
26 | *
27 | * Number of characters before wrapping to a new line.
28 | * Set to 0 or 1 to force every field on a new line.
29 | * TYPEOF fields do not honor this setting, they will always be on one line unless `newLineAfterKeywords` is true,
30 | * in which case it will span multiple lines.
31 | */
32 | fieldMaxLineLength?: number;
33 | /**
34 | * @default true
35 | *
36 | * Set to true to have a subquery parentheses start and end on a new line.
37 | * This will be set to true if `newLineAfterKeywords` is true, in which case this property can be omitted
38 | */
39 | fieldSubqueryParensOnOwnLine?: boolean;
40 | /** @deprecated as of 3.3.0 - this is always true and will be removed in future version */
41 | whereClauseOperatorsIndented?: boolean;
42 | /**
43 | * @default false
44 | *
45 | * Adds a new line and indent after all keywords (such as SELECT, FROM, WHERE, ORDER BY, etc..)
46 | * Setting this to true will add new lines in other places as well, such as complex WHERE clauses
47 | */
48 | newLineAfterKeywords?: boolean;
49 | logging?: boolean;
50 | }
51 |
52 | /**
53 | * Formatter
54 | * This class aids in building a SOQL query from a parse query
55 | * and optionally formats parts of the query based on the configuration options passed in
56 | */
57 | export class Formatter {
58 | enabled: boolean;
59 | private options: Required;
60 | private currIndent = 1;
61 |
62 | constructor(enabled: boolean, options: FormatOptions) {
63 | this.enabled = enabled;
64 | this.options = {
65 | numIndent: options.numIndent ?? 1,
66 | fieldMaxLineLength: options.fieldMaxLineLength ?? 60,
67 | fieldSubqueryParensOnOwnLine: options.fieldSubqueryParensOnOwnLine ?? true,
68 | whereClauseOperatorsIndented: true,
69 | newLineAfterKeywords: options.newLineAfterKeywords ?? false,
70 | logging: options.logging ?? false,
71 | };
72 | if (this.options.newLineAfterKeywords) {
73 | this.options.fieldSubqueryParensOnOwnLine = true;
74 | }
75 | }
76 |
77 | private log(data: any) {
78 | if (this.options.logging) {
79 | console.log(data);
80 | }
81 | }
82 |
83 | private getIndent(additionalIndent = 0) {
84 | return this.repeatChar((this.currIndent + additionalIndent) * (this.options.numIndent || 1), '\t');
85 | }
86 |
87 | private repeatChar(numTimes: number, char: string) {
88 | return new Array(numTimes).fill(char).join('');
89 | }
90 |
91 | setSubquery(isSubquery: boolean) {
92 | this.currIndent = isSubquery ? (this.currIndent += 1) : (this.currIndent -= 1);
93 | }
94 |
95 | stepCurrIndex(num: number) {
96 | this.currIndent += num;
97 | }
98 |
99 | /**
100 | * Format fields
101 | * @param fieldData
102 | */
103 | formatFields(fieldData: FieldData): void {
104 | function trimPrevSuffix(currIdx: number) {
105 | if (fieldData.fields[currIdx - 1]) {
106 | fieldData.fields[currIdx - 1].suffix = fieldData.fields[currIdx - 1].suffix.trim();
107 | }
108 | }
109 |
110 | fieldData.fields.forEach((field, i) => {
111 | field.suffix = fieldData.fields.length - 1 === i ? '' : ', ';
112 | });
113 |
114 | if (this.enabled) {
115 | let lineLen = 0;
116 | let newLineAndIndentNext = false;
117 | fieldData.fields.forEach((field, i) => {
118 | if (field.isSubquery) {
119 | // Subquery should always be on a stand-alone line
120 | trimPrevSuffix(i);
121 | field.prefix = `\n${this.getIndent()}`;
122 | field.suffix = fieldData.fields.length - 1 === i ? '' : ', ';
123 | lineLen = 0;
124 | newLineAndIndentNext = true;
125 | } else if (Array.isArray(field.typeOfClause)) {
126 | trimPrevSuffix(i);
127 | // always show on a new line
128 | field.prefix = `\n${this.getIndent()}`;
129 | newLineAndIndentNext = true;
130 | } else if (isNumber(this.options.fieldMaxLineLength)) {
131 | // If max line length is specified, create a new line when needed
132 | // Add two to account for ", "
133 | lineLen += field.text.length + field.suffix.length;
134 | if (lineLen > this.options.fieldMaxLineLength || newLineAndIndentNext) {
135 | trimPrevSuffix(i);
136 | if (!this.options.newLineAfterKeywords || i > 0) {
137 | field.prefix += `\n${this.getIndent()}`;
138 | }
139 | lineLen = 0;
140 | newLineAndIndentNext = false;
141 | }
142 | }
143 |
144 | this.log(field);
145 | });
146 | }
147 | }
148 |
149 | formatTyeOfField(text: string, typeOfClause: string[]) {
150 | if (this.enabled && this.options.newLineAfterKeywords) {
151 | return typeOfClause
152 | .map((part, i) => {
153 | if (i === 0) {
154 | return part;
155 | } else if (i === typeOfClause.length - 1) {
156 | return `${this.getIndent()}${part}`;
157 | } else {
158 | return `${this.getIndent()}\t${part}`;
159 | }
160 | })
161 | .join('\n');
162 | }
163 | return text;
164 | }
165 |
166 | formatTypeofFieldCondition(condition: FieldTypeOfCondition) {
167 | let output = '';
168 | const fields = condition.fieldList.join(', ');
169 | if (this.enabled && this.options.newLineAfterKeywords) {
170 | const indent = this.getIndent();
171 | output = `${condition.type}`;
172 | if (condition.objectType) {
173 | output += `\n${indent}\t\t${condition.objectType}\n${indent}\tTHEN\n${indent}\t\t${fields}`;
174 | } else {
175 | output += `\n${indent}\t\t${fields}`;
176 | }
177 | } else {
178 | output = condition.type;
179 | if (condition.objectType) {
180 | output += ` ${condition.objectType} THEN ${fields}`;
181 | } else {
182 | output += ` ${fields}`;
183 | }
184 | }
185 | return output;
186 | }
187 |
188 | /**
189 | * Formats subquery with additional indents
190 | */
191 | formatSubquery(queryStr: string, numTabs = 2, incrementTabsWhereClauseOpIndent: boolean = false): string {
192 | if (incrementTabsWhereClauseOpIndent) {
193 | numTabs++;
194 | }
195 | let leftParen = '(';
196 | let rightParen = ')';
197 | if (this.enabled) {
198 | if (this.options.fieldSubqueryParensOnOwnLine || this.options.newLineAfterKeywords) {
199 | queryStr = queryStr.replace(/\n/g, `\n${this.repeatChar(numTabs, '\t')}`);
200 | leftParen = `(\n${this.repeatChar(numTabs, '\t')}`;
201 | rightParen = `\n${this.repeatChar(numTabs - 1, '\t')})`;
202 | } else {
203 | queryStr = queryStr.replace(/\n/g, '\n\t');
204 | }
205 | }
206 | return `${leftParen}${queryStr}${rightParen}`;
207 | }
208 |
209 | /**
210 | * Formats all clauses that do not have a more specialized format function
211 | * If formatting is enabled, then this will put a new line before the clause
212 | * @param clause
213 | * @returns clause
214 | */
215 | formatClause(clause: string): string {
216 | if (this.enabled) {
217 | return this.options.newLineAfterKeywords ? `\n${clause}\n\t` : `\n${clause}`;
218 | }
219 | return ` ${clause}`;
220 | }
221 |
222 | /**
223 | * If newLineAfterKeywords is true, then no preceding space will be added
224 | * This is called after clauses
225 | * @param text
226 | * @returns text
227 | */
228 | formatText(text: string): string {
229 | return this.enabled && (this.options.newLineAfterKeywords || text.startsWith('\n')) ? text : ` ${text}`;
230 | }
231 |
232 | formatWithIndent(text: string): string {
233 | return this.enabled ? `${this.getIndent()}${text}` : text;
234 | }
235 |
236 | formatOrderByArray(groupBy: string[]): string {
237 | if (this.enabled) {
238 | let currLen = 0;
239 | let output = '';
240 | groupBy.forEach((token, i) => {
241 | const nextToken = groupBy[i + 1];
242 | currLen += token.length;
243 | if (nextToken && (currLen + nextToken.length > (this.options.fieldMaxLineLength || 0) || this.options.newLineAfterKeywords)) {
244 | output += `${token},\n\t`;
245 | currLen = 0;
246 | } else {
247 | output += `${token}${nextToken ? ', ' : ''}`;
248 | }
249 | });
250 | return output;
251 | } else {
252 | return groupBy.join(', ');
253 | }
254 | }
255 |
256 | /**
257 | * Formats parens
258 | * @param count
259 | * @param character
260 | * @param [leadingParenInline] Make the leading paren inline (last for "(" and first for ")") used for negation condition
261 | * @returns
262 | */
263 | formatParens(count: number | undefined, character: '(' | ')', leadingParenInline = false) {
264 | let output = '';
265 | if (isNumber(count) && count > 0) {
266 | if (this.enabled) {
267 | if (character === '(') {
268 | for (let i = 0; i < count; i++) {
269 | if (leadingParenInline && i === count - 1) {
270 | output += '(';
271 | } else {
272 | if (i === 0) {
273 | output += '(\n';
274 | } else {
275 | this.currIndent++;
276 | output += `${this.getIndent()}(\n`;
277 | }
278 | }
279 | }
280 | if (!leadingParenInline || count > 1) {
281 | // indent the following clause
282 | this.currIndent++;
283 | }
284 | } else {
285 | for (let i = count - 1; i >= 0; i--) {
286 | if (leadingParenInline && i === count - 1) {
287 | output += ')';
288 | } else {
289 | this.currIndent--;
290 | output += `\n${this.getIndent()})`;
291 | }
292 | }
293 | }
294 | } else {
295 | output += generateParens(count, character);
296 | }
297 | }
298 | return output;
299 | }
300 |
301 | formatWhereClauseOperators(operator: string, whereClause: string, additionalIndent = 0): string {
302 | const skipNewLineAndIndent = operator === 'NOT';
303 | if (this.enabled && !skipNewLineAndIndent) {
304 | return `\n${this.getIndent(additionalIndent)}${operator} ${whereClause}`;
305 | } else {
306 | return `${skipNewLineAndIndent ? '' : ' '}${operator} ${whereClause}`;
307 | }
308 | }
309 |
310 | formatAddNewLine(alt: string = ' ', skipNewLineAndIndent?: boolean): string {
311 | return this.enabled && !skipNewLineAndIndent ? `\n` : alt;
312 | }
313 | }
314 |
--------------------------------------------------------------------------------
/src/index.ts:
--------------------------------------------------------------------------------
1 | /*
2 | * Copyright (c) Austin Turner
3 | * The software in this package is published under the terms of the MIT license,
4 | * a copy of which has been included with this distribution in the LICENSE.txt file.
5 | */
6 | export { parseQuery, isQueryValid } from './parser/visitor';
7 | export * from './api/api-models';
8 | export * from './api/public-utils';
9 | export * from './composer/composer';
10 | export type { FormatOptions } from './formatter/formatter';
11 |
--------------------------------------------------------------------------------
/src/models.ts:
--------------------------------------------------------------------------------
1 | import { CstNode, IToken } from 'chevrotain';
2 | import { LiteralType } from './api/api-models';
3 |
4 | export type LiteralTypeWithSubquery = LiteralType | Omit[];
5 |
6 | export interface ArrayExpressionWithType {
7 | type: string;
8 | value: string;
9 | }
10 |
11 | export interface ExpressionTree {
12 | expressionTree?: T;
13 | prevExpression?: T;
14 | }
15 |
16 | /**
17 | * CONTEXT TYPES
18 | */
19 |
20 | interface WithIdentifier {
21 | Identifier: IToken[];
22 | }
23 |
24 | export interface SelectStatementContext {
25 | selectClause: CstNode[];
26 | fromClause: CstNode[];
27 | clauseStatements: CstNode[];
28 | }
29 |
30 | export interface ClauseStatementsContext {
31 | usingScopeClause?: CstNode[];
32 | whereClause?: CstNode[];
33 | withClause?: CstNode[];
34 | groupByClause?: CstNode[];
35 | havingClause?: CstNode[];
36 | orderByClause?: CstNode[];
37 | limitClause?: CstNode[];
38 | offsetClause?: CstNode[];
39 | forViewOrReference?: CstNode[];
40 | updateTrackingViewstat?: CstNode[];
41 | }
42 |
43 | export interface SelectClauseIdentifierContext extends WithIdentifier {
44 | alias?: IToken[];
45 | }
46 |
47 | export interface SelectClauseFunctionIdentifierContext extends WithIdentifier {}
48 |
49 | export interface SelectClauseContext {
50 | field: (IToken | CstNode)[];
51 | }
52 |
53 | export interface SelectClauseFieldIdentifierContext extends WithIdentifier {
54 | fn: CstNode[];
55 | alias?: IToken[];
56 | }
57 |
58 | export interface SelectClauseFunctionIdentifierContext extends WithIdentifier {
59 | fn: CstNode[];
60 | alias?: IToken[];
61 | }
62 |
63 | export interface SelectClauseSubqueryIdentifierContext extends WithIdentifier {
64 | selectStatement: CstNode[];
65 | }
66 |
67 | export interface SelectClauseTypeOfContext extends WithIdentifier {
68 | typeOfField: IToken[];
69 | selectClauseTypeOfThen: CstNode[];
70 | selectClauseTypeOfElse?: CstNode[];
71 | }
72 |
73 | export interface SelectClauseIdentifierContext extends WithIdentifier {
74 | field: IToken[];
75 | alias?: IToken[];
76 | }
77 |
78 | export interface SelectClauseTypeOfThenContext extends WithIdentifier {
79 | typeOfField: IToken[];
80 | field: IToken[];
81 | }
82 |
83 | export interface SelectClauseTypeOfElseContext extends WithIdentifier {
84 | field: IToken[];
85 | }
86 |
87 | export interface usingScopeClauseContext {
88 | UsingScopeEnumeration: IToken[];
89 | }
90 |
91 | export interface WhereClauseContext {
92 | conditionExpression: CstNode[];
93 | }
94 |
95 | export interface WhereClauseSubqueryContext {
96 | selectStatement: CstNode[];
97 | }
98 |
99 | export interface ConditionExpressionContext {
100 | logicalOperator?: IToken[];
101 | expressionNegation?: CstNode[];
102 | expression: CstNode[];
103 | }
104 |
105 | export type WithClauseContext = WithSecurityEnforcedClauseContext | WithAccessLevelClauseContext | WithDataCategoryClauseContext;
106 |
107 | export interface WithSecurityEnforcedClauseContext {
108 | withSecurityEnforced: CstNode[];
109 | withAccessLevel?: never;
110 | withDataCategory?: never;
111 | }
112 |
113 | export interface WithAccessLevelClauseContext {
114 | withSecurityEnforced?: never;
115 | withAccessLevel: IToken[];
116 | withDataCategory?: never;
117 | }
118 |
119 | export interface WithDataCategoryClauseContext {
120 | withSecurityEnforced?: never;
121 | withAccessLevel?: never;
122 | withDataCategory: CstNode[];
123 | }
124 |
125 | export interface WithDateCategoryContext {
126 | withDataCategoryArr: CstNode[];
127 | }
128 |
129 | export interface WithDateCategoryConditionContext {
130 | dataCategoryGroupName: IToken[];
131 | filteringSelector: IToken[];
132 | dataCategoryName: IToken[];
133 | }
134 |
135 | export interface GroupByClauseContext {
136 | groupBy: (CstNode | IToken)[];
137 | havingClause: CstNode[];
138 | }
139 |
140 | export interface GroupByFieldListContext {
141 | field: IToken[];
142 | }
143 |
144 | export interface HavingClauseContext {
145 | conditionExpression: CstNode[];
146 | }
147 |
148 | export interface OrderByClauseContext {
149 | orderByExpressionOrFn: CstNode[];
150 | }
151 |
152 | export interface OrderByExpressionContext extends WithIdentifier {
153 | field: IToken[];
154 | order?: IToken[];
155 | nulls?: IToken[];
156 | }
157 |
158 | export interface OrderByGroupingFunctionExpressionContext extends WithIdentifier {
159 | fn: IToken[];
160 | order?: IToken[];
161 | nulls?: IToken[];
162 | }
163 |
164 | export interface OrderBySpecialFunctionExpressionContext {
165 | aggregateFunction?: CstNode[];
166 | dateFunction?: CstNode[];
167 | locationFunction?: CstNode[];
168 | order?: IToken[];
169 | nulls?: IToken[];
170 | }
171 |
172 | export interface ValueContext {
173 | value: IToken[];
174 | }
175 |
176 | export interface OperatorContext {
177 | operator: IToken[];
178 | }
179 |
180 | export type OperatorOrNotInContext = OperatorWithoutNotInContext | OperatorNotInContext;
181 |
182 | export interface OperatorWithoutNotInContext extends OperatorContext {
183 | notIn?: never;
184 | }
185 |
186 | export interface OperatorNotInContext {
187 | operator?: never;
188 | notIn: CstNode[];
189 | }
190 |
191 | export interface BooleanContext {
192 | boolean: IToken[];
193 | }
194 |
195 | export interface DateLiteralContext {
196 | dateLiteral: IToken[];
197 | }
198 |
199 | export interface DateNLiteralContext {
200 | dateNLiteral: IToken[];
201 | variable: IToken[];
202 | }
203 |
204 | export interface FieldFunctionContext {
205 | [value: string]: any;
206 | functionExpression?: CstNode[];
207 | fn: IToken[];
208 | }
209 |
210 | export interface FieldsFunctionContext {
211 | fn: IToken[];
212 | params: IToken[];
213 | }
214 |
215 | export interface LocationFunctionContext {
216 | location1: IToken[];
217 | location2: IToken[] | CstNode[];
218 | unit: IToken[];
219 | }
220 |
221 | export interface GeoLocationFunctionContext {
222 | latitude: IToken[];
223 | longitude: IToken[];
224 | }
225 |
226 | export interface ExpressionContext {
227 | lhs: IToken[] | CstNode[];
228 | operator: CstNode[]; // ExpressionOperatorContext
229 | L_PAREN?: IToken[];
230 | R_PAREN?: IToken[];
231 | }
232 |
233 | export interface ApexBindVariableExpressionContext {
234 | apex: CstNode[];
235 | COLON: IToken[];
236 | DECIMAL?: IToken[];
237 | }
238 |
239 | export interface ApexBindVariableIdentifierContext {
240 | Identifier: IToken[];
241 | apexBindVariableFunctionArrayAccessor?: CstNode[];
242 | }
243 |
244 | export interface ApexBindVariableNewInstantiationContext {
245 | new: IToken[];
246 | function: IToken[];
247 | apexBindVariableGeneric?: CstNode[];
248 | apexBindVariableFunctionParams: CstNode[];
249 | apexBindVariableFunctionArrayAccessor?: CstNode[];
250 | }
251 |
252 | export interface ApexBindVariableFunctionCallContext {
253 | function: IToken[];
254 | apexBindVariableFunctionParams: CstNode[];
255 | apexBindVariableFunctionArrayAccessor?: CstNode[];
256 | }
257 |
258 | export interface ApexBindVariableGenericContext {
259 | COMMA: IToken[];
260 | GREATER_THAN: IToken[];
261 | LESS_THAN: IToken[];
262 | parameter: IToken[];
263 | }
264 |
265 | export interface ApexBindVariableFunctionParamsContext {
266 | L_PAREN: IToken[];
267 | R_PAREN: IToken[];
268 | parameter?: IToken[];
269 | }
270 |
271 | export interface ApexBindVariableFunctionArrayAccessorContext {
272 | L_SQUARE_BRACKET: IToken[];
273 | R_SQUARE_BRACKET: IToken[];
274 | value: IToken[];
275 | }
276 |
277 | export type ExpressionOperatorContext = ExpressionOperatorRhsContext & ExpressionWithRelationalOrSetOperatorContext;
278 |
279 | export interface ExpressionOperatorRhsContext {
280 | rhs: CstNode[];
281 | }
282 |
283 | type ExpressionWithRelationalOrSetOperatorContext = ExpressionWithRelationalOperatorContext | ExpressionWithSetOperatorOperatorContext;
284 |
285 | export interface ExpressionWithRelationalOperatorContext {
286 | relationalOperator: CstNode[];
287 | setOperator?: never;
288 | }
289 |
290 | export interface ExpressionWithSetOperatorOperatorContext {
291 | relationalOperator?: never;
292 | setOperator: CstNode[];
293 | }
294 |
295 | export interface FromClauseContext extends WithIdentifier {
296 | alias?: IToken[];
297 | }
298 |
299 | export interface FunctionExpressionContext {
300 | params?: (CstNode | IToken)[];
301 | }
302 |
303 | export interface AtomicExpressionContext {
304 | apexBindVariableExpression?: CstNode[];
305 | NumberIdentifier?: IToken[];
306 | UnsignedInteger?: IToken[];
307 | SignedInteger?: IToken[];
308 | RealNumber?: IToken[];
309 | CurrencyPrefixedDecimal?: IToken[];
310 | CurrencyPrefixedInteger?: IToken[];
311 | DateIdentifier?: IToken[];
312 | DateTime?: IToken[];
313 | date?: IToken[];
314 | NULL?: IToken[];
315 | StringIdentifier?: IToken[];
316 | Identifier?: IToken[];
317 | booleanValue?: CstNode[];
318 | DateLiteral?: IToken[];
319 | dateNLiteral?: CstNode[];
320 | arrayExpression?: CstNode[];
321 | whereClauseSubqueryIdentifier?: CstNode[];
322 | DateToken?: IToken[];
323 | }
324 |
325 | export interface ExpressionWithAggregateFunctionContext {
326 | lhs: IToken[] | CstNode[];
327 | rhs: CstNode[];
328 | relationalOperator?: CstNode[];
329 | setOperator?: CstNode[];
330 | }
331 |
--------------------------------------------------------------------------------
/src/utils.ts:
--------------------------------------------------------------------------------
1 | import { IToken } from 'chevrotain';
2 | import {
3 | FieldFunctionExpression,
4 | FieldRelationshipWithAlias,
5 | FieldSubquery,
6 | FieldWithAlias,
7 | GroupByFieldClause,
8 | GroupByFnClause,
9 | HavingClauseWithRightCondition,
10 | LiteralType,
11 | NegationCondition,
12 | Operator,
13 | OrderByFieldClause,
14 | OrderByFnClause,
15 | Query,
16 | Subquery,
17 | ValueCondition,
18 | ValueFunctionCondition,
19 | ValueQueryCondition,
20 | ValueWithDateLiteralCondition,
21 | ValueWithDateNLiteralCondition,
22 | WhereClauseWithRightCondition,
23 | } from './api/api-models';
24 | import { ComposeField, ComposeFieldFunction, ComposeFieldRelationship, ComposeFieldSubquery, ComposeFieldTypeof } from './api/public-utils';
25 |
26 | export function isToken(val: any): val is IToken[] | IToken {
27 | val = Array.isArray(val) ? val[0] : val;
28 | return val.image && true;
29 | }
30 |
31 | export function isSubqueryFromFlag(val: any, isSubquery: boolean): val is Subquery {
32 | return isSubquery;
33 | }
34 |
35 | export function isString(val: any): val is string {
36 | return typeof val === 'string';
37 | }
38 |
39 | export function isStringArray(val: any[]): val is string[] {
40 | if (!val) {
41 | return false;
42 | }
43 | return val.every(item => isString(item));
44 | }
45 |
46 | export function isNumber(val: any): val is number {
47 | return Number.isFinite(val);
48 | }
49 |
50 | export function isBoolean(val: any): val is boolean {
51 | return typeof val === typeof true;
52 | }
53 |
54 | export function isObject(val: any): val is any {
55 | return val instanceof Object;
56 | }
57 |
58 | export function isNil(val: any): val is null | undefined {
59 | return val === null || val === undefined;
60 | }
61 |
62 | export function get(val: string | null | undefined, suffix?: string, prefix?: string): string {
63 | return isNil(val) ? '' : `${prefix || ''}${val}${suffix || ''}`;
64 | }
65 |
66 | export function getIfTrue(val: boolean | null | undefined, returnStr: string): string {
67 | return isBoolean(val) && val ? returnStr : '';
68 | }
69 |
70 | export function getLastItem(arr: T[]): T {
71 | return arr[arr.length - 1];
72 | }
73 |
74 | export function getAsArrayStr(val: string | string[], alwaysParens: boolean = false): string {
75 | if (Array.isArray(val)) {
76 | if (val.length > 0) {
77 | return `(${val.join(', ')})`;
78 | } else {
79 | return alwaysParens ? '()' : '';
80 | }
81 | } else {
82 | return alwaysParens ? `(${val || ''})` : val || '';
83 | }
84 | }
85 |
86 | export function pad(val: string, len: number, left: number = 0): string {
87 | let leftPad = left > 0 ? new Array(left).fill(' ').join('') : '';
88 | if (val.length > len) {
89 | return `${leftPad}${val}`;
90 | } else {
91 | return `${leftPad}${val}${new Array(len - val.length).fill(' ').join('')}`;
92 | }
93 | }
94 |
95 | export function generateParens(count: number, character: '(' | ')', joinCharacter = '') {
96 | return isNumber(count) && count > 0 ? new Array(count).fill(character).join(joinCharacter) : '';
97 | }
98 |
99 | /**
100 | * Gets params from a FieldFunctionExpression. If there are multiple nested functions as multiple parameters
101 | * within another function, only the first argument will be considered.
102 | * @param functionFieldExp
103 | * @returns params
104 | */
105 | export function getParams(functionFieldExp: FieldFunctionExpression): string[] {
106 | if (!functionFieldExp.parameters || functionFieldExp.parameters.length === 0) {
107 | return [];
108 | }
109 | if (isStringArray(functionFieldExp.parameters)) {
110 | return functionFieldExp.parameters;
111 | }
112 | if (isString(functionFieldExp.parameters[0])) {
113 | return [functionFieldExp.parameters[0]];
114 | }
115 | return getParams(functionFieldExp.parameters[0] as FieldFunctionExpression);
116 | }
117 |
118 | export function isNestedParamAggregateFunction(functionFieldExp: FieldFunctionExpression): boolean {
119 | if (!functionFieldExp.parameters || functionFieldExp.parameters.length === 0) {
120 | return false;
121 | }
122 | const parameter = functionFieldExp.parameters[0];
123 | if (isString(parameter)) {
124 | return false;
125 | }
126 | return !!parameter.isAggregateFn;
127 | }
128 |
129 | export function hasAlias(value: any): value is FieldWithAlias | FieldRelationshipWithAlias {
130 | return value && !isNil(value.alias);
131 | }
132 |
133 | export function isComposeField(input: any): input is ComposeField {
134 | return isString(input.field) && !Array.isArray(input.relationships) && !Array.isArray(input.conditions);
135 | }
136 | export function isComposeFieldFunction(input: any): input is ComposeFieldFunction {
137 | return !isNil(input.functionName || input.fn);
138 | }
139 | export function isComposeFieldRelationship(input: any): input is ComposeFieldRelationship {
140 | return isString(input.field) && Array.isArray(input.relationships);
141 | }
142 | export function isComposeFieldSubquery(input: any): input is ComposeFieldSubquery {
143 | return !isNil(input.subquery);
144 | }
145 | export function isComposeFieldTypeof(input: any): input is ComposeFieldTypeof {
146 | return isString(input.field) && Array.isArray(input.conditions);
147 | }
148 |
149 | export function isSubquery(query: Query | Subquery): query is Subquery {
150 | return isString((query as any).relationshipName);
151 | }
152 |
153 | export function isFieldSubquery(value: any): value is FieldSubquery {
154 | return !!value && !!value.type && value.type === 'FieldSubquery';
155 | }
156 |
157 | export function isWhereClauseWithRightCondition(value: any): value is WhereClauseWithRightCondition {
158 | return !!value && !!value.operator && !!value.right;
159 | }
160 |
161 | export function isHavingClauseWithRightCondition(value: any): value is HavingClauseWithRightCondition {
162 | return !!value && !!value.operator && !!value.right;
163 | }
164 |
165 | export function isWhereOrHavingClauseWithRightCondition(
166 | value: any,
167 | ): value is WhereClauseWithRightCondition | HavingClauseWithRightCondition {
168 | return !!value && !!value.operator && !!value.right;
169 | }
170 |
171 | export function isValueCondition(value: any): value is ValueCondition {
172 | return value && isString(value.field) && isString(value.operator) && !isNil(value.value);
173 | }
174 |
175 | export function isValueWithDateLiteralCondition(value: any): value is ValueWithDateLiteralCondition {
176 | return (
177 | value &&
178 | isString(value.field) &&
179 | isString(value.operator) &&
180 | !isNil(value.value) &&
181 | (value.literalType === 'DATE_LITERAL' || (Array.isArray(value.literalType) && value.literalType[0] === 'DATE_LITERAL'))
182 | );
183 | }
184 |
185 | export function isValueWithDateNLiteralCondition(value: any): value is ValueWithDateNLiteralCondition {
186 | return value && isString(value.field) && isString(value.operator) && !isNil(value.value) && !isNil(value.dateLiteralVariable);
187 | }
188 |
189 | export function isValueFunctionCondition(value: any): value is ValueFunctionCondition {
190 | return value && !isNil(value.fn) && isString(value.operator) && !isNil(value.value);
191 | }
192 |
193 | export function isNegationCondition(value: any): value is NegationCondition {
194 | return value && isNumber(value.openParen) && isNil(value.operator) && isNil(value.field) && isNil(value.fn) && isNil(value.closeParen);
195 | }
196 |
197 | export function isValueQueryCondition(value: any): value is ValueQueryCondition {
198 | return value && isString(value.field) && isString(value.operator) && !isNil(value.valueQuery) && isNil(value.value);
199 | }
200 |
201 | export function isOrderByField(value: any): value is OrderByFieldClause {
202 | return value && !isNil(value.field);
203 | }
204 |
205 | export function isOrderByFn(value: any): value is OrderByFnClause {
206 | return value && !isNil(value.fn);
207 | }
208 |
209 | export function isGroupByField(value: any): value is GroupByFieldClause {
210 | return value && !isNil(value.field);
211 | }
212 |
213 | export function isGroupByFn(value: any): value is GroupByFnClause {
214 | return value && !isNil(value.fn);
215 | }
216 |
217 | export function isArrayOperator(operator: Operator) {
218 | return ['IN', 'NOT IN', 'INCLUDES', 'EXCLUDES'].includes(operator);
219 | }
220 |
221 | export function getWhereValue(value: any | any[], literalType?: LiteralType | LiteralType[], operator?: Operator): any {
222 | if (isNil(literalType)) {
223 | return value;
224 | }
225 |
226 | // Ensure that we process as an array for array type operators
227 | if (operator && literalType !== 'APEX_BIND_VARIABLE' && isArrayOperator(operator) && !Array.isArray(value)) {
228 | value = [value];
229 | literalType = Array.isArray(literalType) ? literalType : [literalType];
230 | }
231 |
232 | if (Array.isArray(literalType) && Array.isArray(value)) {
233 | return value.map((val, i) => {
234 | return whereValueHelper(val, literalType?.[i] as LiteralType);
235 | });
236 | } else {
237 | // This path should never hit, but on the off chance that literal type is an array and value is a string
238 | // then the first literal type is considered
239 | if (Array.isArray(literalType)) {
240 | literalType = literalType[0];
241 | }
242 |
243 | switch (literalType) {
244 | case 'STRING': {
245 | if (Array.isArray(value)) {
246 | return value.filter(Boolean).map(val => (isString(val) && val.startsWith("'") ? val : `'${val ?? ''}'`));
247 | } else {
248 | value = String(value ?? '');
249 | return isString(value) && value.startsWith("'") ? value : `'${value ?? ''}'`;
250 | }
251 | }
252 | case 'APEX_BIND_VARIABLE': {
253 | return `:${value}`;
254 | }
255 | default: {
256 | return value;
257 | }
258 | }
259 | }
260 | }
261 |
262 | function whereValueHelper(value: any, literalType?: LiteralType) {
263 | switch (literalType) {
264 | case 'STRING': {
265 | return isString(value) && value.startsWith("'") ? value : `'${value ?? ''}'`;
266 | }
267 | default: {
268 | return value;
269 | }
270 | }
271 | }
272 |
--------------------------------------------------------------------------------
/tasks/copy-test-cases-to-docs.ts:
--------------------------------------------------------------------------------
1 | import { testCases, TestCase } from '../test/test-cases';
2 | import { writeFileSync } from 'fs';
3 | import { join } from 'path';
4 |
5 | const outputPath = join(__dirname, '../docs/static/sample-queries-json.json');
6 |
7 | console.log('copying test-cases to docs');
8 |
9 | writeFileSync(
10 | outputPath,
11 | JSON.stringify(
12 | testCases.map((tc: TestCase) => tc.soql),
13 | null,
14 | 2,
15 | ),
16 | );
17 |
--------------------------------------------------------------------------------
/test/performance-test.spec.ts:
--------------------------------------------------------------------------------
1 | import * as chalk from 'chalk';
2 | import { performance } from 'perf_hooks';
3 | import { describe, it } from 'vitest';
4 | import testCases from './test-cases';
5 |
6 | // SKIPPED -
7 | // describe.only('parse queries', () => {
8 | describe.skip('parse queries', () => {
9 | it('should run performance tests', async () => {
10 | const numIterations = 1000;
11 |
12 | console.log(chalk.whiteBright(`Importing SOQL Parser library.`));
13 |
14 | const startImport = performance.now();
15 | const { parseQuery } = await import('../src');
16 | const endImport = performance.now();
17 | const importDuration = endImport - startImport;
18 |
19 | console.log(chalk.whiteBright(`Duration: ${chalk.greenBright(Number(importDuration).toFixed(4))} milliseconds`));
20 |
21 | console.log(
22 | chalk.whiteBright(`Parser testing: ${testCases.length} X ${numIterations} = ${testCases.length * numIterations} iterations.`),
23 | );
24 | const start = performance.now();
25 | for (let i = 0; i < numIterations; i++) {
26 | testCases.forEach(testCase => {
27 | try {
28 | parseQuery(testCase.soql, { allowApexBindVariables: true, logErrors: true, ...testCase.options });
29 | } catch (ex) {
30 | console.log('Exception on TC', testCase.testCase, testCase.soql);
31 | console.log(ex);
32 | throw ex;
33 | }
34 | });
35 | }
36 | const end = performance.now();
37 | const duration = end - start;
38 | console.log(chalk.whiteBright(`Duration: ${chalk.greenBright(Number(duration / 1000).toFixed(4))} seconds`));
39 | console.log(
40 | chalk.whiteBright(
41 | `Average of ${chalk.greenBright(Number(duration / (testCases.length * numIterations)).toFixed(4))} milliseconds per query`,
42 | ),
43 | );
44 | return;
45 | });
46 | });
47 |
48 | export {};
49 |
--------------------------------------------------------------------------------
/test/public-utils.spec.ts:
--------------------------------------------------------------------------------
1 | import { describe, expect, it } from 'vitest';
2 | import { FieldSubquery } from '../src';
3 | import * as utils from '../src/api/public-utils';
4 | import { testCases } from './public-utils-test-data';
5 | const lodashGet = require('lodash.get');
6 |
7 | describe('getField', () => {
8 | it('Should compose Field', () => {
9 | expect(utils.getField('Id')).toEqual({ type: 'Field', field: 'Id' });
10 | expect(utils.getField({ field: 'Id' })).toEqual({
11 | type: 'Field',
12 | field: 'Id',
13 | objectPrefix: undefined,
14 | });
15 | expect(utils.getField({ field: 'Id', objectPrefix: 'a' })).toEqual({
16 | type: 'Field',
17 | field: 'Id',
18 | objectPrefix: 'a',
19 | });
20 | });
21 | it('Should compose FieldFunctionExpression', () => {
22 | expect(utils.getField({ functionName: 'COUNT' })).toEqual({
23 | type: 'FieldFunctionExpression',
24 | functionName: 'COUNT',
25 | parameters: [],
26 | alias: undefined,
27 | });
28 | expect(utils.getField({ functionName: 'FORMAT', parameters: ['Amount'] })).toEqual({
29 | type: 'FieldFunctionExpression',
30 | functionName: 'FORMAT',
31 | parameters: ['Amount'],
32 | alias: undefined,
33 | });
34 | expect(utils.getField({ functionName: 'FORMAT', parameters: 'Amount' })).toEqual({
35 | type: 'FieldFunctionExpression',
36 | functionName: 'FORMAT',
37 | parameters: ['Amount'],
38 | alias: undefined,
39 | });
40 | expect(utils.getField({ functionName: 'FORMAT', parameters: ['Amount'], alias: 'amt' })).toEqual({
41 | type: 'FieldFunctionExpression',
42 | functionName: 'FORMAT',
43 | parameters: ['Amount'],
44 | alias: 'amt',
45 | });
46 | expect(
47 | utils.getField({
48 | functionName: 'FORMAT',
49 | parameters: [
50 | {
51 | type: 'FieldFunctionExpression',
52 | functionName: 'convertCurrency',
53 | parameters: ['amount'],
54 | },
55 | ],
56 | alias: 'convertedCurrency',
57 | }),
58 | ).toEqual({
59 | type: 'FieldFunctionExpression',
60 | functionName: 'FORMAT',
61 | parameters: [
62 | {
63 | type: 'FieldFunctionExpression',
64 | functionName: 'convertCurrency',
65 | parameters: ['amount'],
66 | },
67 | ],
68 | alias: 'convertedCurrency',
69 | });
70 | expect(
71 | utils.getField({
72 | functionName: 'FORMAT',
73 | parameters: {
74 | type: 'FieldFunctionExpression',
75 | functionName: 'convertCurrency',
76 | parameters: ['amount'],
77 | },
78 | alias: 'convertedCurrency',
79 | }),
80 | ).toEqual({
81 | type: 'FieldFunctionExpression',
82 | functionName: 'FORMAT',
83 | parameters: [
84 | {
85 | type: 'FieldFunctionExpression',
86 | functionName: 'convertCurrency',
87 | parameters: ['amount'],
88 | },
89 | ],
90 | alias: 'convertedCurrency',
91 | });
92 | });
93 | it('Should compose FieldRelationship', () => {
94 | expect(utils.getField({ field: 'Id', relationships: ['Account', 'User'] })).toEqual({
95 | type: 'FieldRelationship',
96 | field: 'Id',
97 | relationships: ['Account', 'User'],
98 | objectPrefix: undefined,
99 | });
100 | expect(utils.getField({ field: 'Id', objectPrefix: 'c', relationships: ['Account', 'User'] })).toEqual({
101 | type: 'FieldRelationship',
102 | field: 'Id',
103 | relationships: ['Account', 'User'],
104 | objectPrefix: 'c',
105 | });
106 | });
107 | it('Should compose FieldSubquery', () => {
108 | expect(
109 | utils.getField({
110 | subquery: {
111 | fields: [
112 | {
113 | type: 'FieldRelationship',
114 | field: 'LastName',
115 | relationships: ['Contact'],
116 | rawValue: 'Contact.LastName',
117 | },
118 | ],
119 | relationshipName: 'Bars',
120 | sObjectPrefix: ['Account', 'Contact', 'Foo'],
121 | },
122 | }),
123 | ).toEqual({
124 | type: 'FieldSubquery',
125 | subquery: {
126 | fields: [
127 | {
128 | type: 'FieldRelationship',
129 | field: 'LastName',
130 | relationships: ['Contact'],
131 | rawValue: 'Contact.LastName',
132 | },
133 | ],
134 | relationshipName: 'Bars',
135 | sObjectPrefix: ['Account', 'Contact', 'Foo'],
136 | },
137 | });
138 | });
139 | it('Should compose FieldTypeof', () => {
140 | expect(
141 | utils.getField({
142 | field: 'What',
143 | conditions: [
144 | {
145 | type: 'WHEN',
146 | objectType: 'Account',
147 | fieldList: ['Phone', 'NumberOfEmployees'],
148 | },
149 | {
150 | type: 'WHEN',
151 | objectType: 'Opportunity',
152 | fieldList: ['Amount', 'CloseDate'],
153 | },
154 | {
155 | type: 'ELSE',
156 | fieldList: ['Name', 'Email'],
157 | },
158 | ],
159 | }),
160 | ).toEqual({
161 | type: 'FieldTypeof',
162 | field: 'What',
163 | conditions: [
164 | {
165 | type: 'WHEN',
166 | objectType: 'Account',
167 | fieldList: ['Phone', 'NumberOfEmployees'],
168 | },
169 | {
170 | type: 'WHEN',
171 | objectType: 'Opportunity',
172 | fieldList: ['Amount', 'CloseDate'],
173 | },
174 | {
175 | type: 'ELSE',
176 | fieldList: ['Name', 'Email'],
177 | },
178 | ],
179 | });
180 | });
181 | it('Should fail with invalid combination of data', () => {
182 | expect(() => utils.getField({})).toThrow();
183 | expect(() => utils.getField({ objectPrefix: 'foo' } as any)).toThrow(TypeError);
184 | expect(() => utils.getField({ parameters: 'foo' } as any)).toThrow(TypeError);
185 | expect(() => utils.getField({ parameters: ['foo'] } as any)).toThrow(TypeError);
186 | expect(() => utils.getField({ alias: 'foo' } as any)).toThrow(TypeError);
187 | expect(() => utils.getField({ relationships: ['foo'] } as any)).toThrow(TypeError);
188 | expect(() => utils.getField({ conditions: [] } as any)).toThrow(TypeError);
189 | expect(() =>
190 | utils.getField({
191 | conditions: [
192 | {
193 | type: 'WHEN',
194 | objectType: 'Account',
195 | fieldList: ['Phone', 'NumberOfEmployees'],
196 | },
197 | ],
198 | } as any),
199 | ).toThrow(TypeError);
200 | });
201 | });
202 |
203 | describe('getFlattenedFields', () => {
204 | testCases.forEach(testCase => {
205 | it(`Should create fields from query - Test Case: ${testCase.testCase}`, () => {
206 | const fields = utils.getFlattenedFields(testCase.query);
207 | expect(fields).toEqual(testCase.expectedFields);
208 | fields.forEach(field => {
209 | expect(lodashGet(testCase.sfdcObj, field)).not.toBeUndefined();
210 | });
211 | });
212 | });
213 |
214 | it(`Should allow a FieldSubquery to be passed in`, () => {
215 | const fieldSubquery: FieldSubquery = {
216 | type: 'FieldSubquery',
217 | subquery: { fields: [{ type: 'Field', field: 'LastName' }], relationshipName: 'Contacts' },
218 | };
219 | const fields = utils.getFlattenedFields(fieldSubquery);
220 | expect(fields).toEqual(['LastName']);
221 | });
222 |
223 | it(`Should allow a Subquery to be passed in`, () => {
224 | const fieldSubquery: FieldSubquery = {
225 | type: 'FieldSubquery',
226 | subquery: { fields: [{ type: 'Field', field: 'LastName' }], relationshipName: 'Contacts' },
227 | };
228 | const fields = utils.getFlattenedFields(fieldSubquery.subquery);
229 | expect(fields).toEqual(['LastName']);
230 | });
231 |
232 | it(`Should not blow up with invalid input`, () => {
233 | expect(utils.getFlattenedFields({})).toEqual([]);
234 | expect(utils.getFlattenedFields(null as any)).toEqual([]);
235 | expect(utils.getFlattenedFields({ fields: [] })).toEqual([]);
236 | });
237 | });
238 |
--------------------------------------------------------------------------------
/test/test-cases-compose.ts:
--------------------------------------------------------------------------------
1 | import { Query } from '../src/api/api-models';
2 | import { ParseQueryConfig } from '../src/parser/parser';
3 |
4 | export interface TestCase {
5 | testCase: number;
6 | soql: string;
7 | options?: ParseQueryConfig;
8 | input: Query;
9 | }
10 |
11 | // Most compose test-cases are included in `test-cases.ts`
12 | // These tests include special compose cases where bad/unexpected data may be passed in
13 | export const testCases: TestCase[] = [
14 | {
15 | testCase: 1,
16 | soql: "SELECT Id FROM Account WHERE Foo IN ('1', '2')",
17 | input: {
18 | fields: [{ type: 'Field', field: 'Id' }],
19 | sObject: 'Account',
20 | where: {
21 | left: { field: 'Foo', operator: 'IN', value: ["'1'", "'2'", undefined as any], literalType: 'STRING' },
22 | },
23 | },
24 | },
25 | {
26 | testCase: 2,
27 | soql: "SELECT Id FROM Account WHERE Foo = ''",
28 | input: {
29 | fields: [{ type: 'Field', field: 'Id' }],
30 | sObject: 'Account',
31 | where: {
32 | left: { field: 'Foo', operator: '=', value: undefined as any, literalType: 'STRING' },
33 | },
34 | },
35 | },
36 | {
37 | testCase: 3,
38 | soql: "SELECT Id FROM Account WHERE Id IN ('foo')",
39 | input: {
40 | fields: [{ type: 'Field', field: 'Id' }],
41 | sObject: 'Account',
42 | where: {
43 | left: {
44 | operator: 'IN',
45 | field: 'Id',
46 | value: 'foo',
47 | literalType: 'STRING',
48 | },
49 | },
50 | },
51 | },
52 | {
53 | testCase: 4,
54 | soql: "SELECT Id FROM Account WHERE Id IN ('foo')",
55 | input: {
56 | fields: [{ type: 'Field', field: 'Id' }],
57 | sObject: 'Account',
58 | where: {
59 | left: {
60 | operator: 'IN',
61 | field: 'Id',
62 | value: 'foo',
63 | literalType: ['STRING'],
64 | },
65 | },
66 | },
67 | },
68 | {
69 | testCase: 5,
70 | soql: "SELECT Id FROM Account WHERE Id IN ('foo')",
71 | input: {
72 | fields: [{ type: 'Field', field: 'Id' }],
73 | sObject: 'Account',
74 | where: {
75 | left: {
76 | operator: 'IN',
77 | field: 'Id',
78 | value: ['foo'],
79 | literalType: 'STRING',
80 | },
81 | },
82 | },
83 | },
84 | {
85 | testCase: 6,
86 | soql: "SELECT Id, Fax FROM Account WHERE Fax IN ('55', 'foo')",
87 | input: {
88 | sObject: 'Account',
89 | fields: [
90 | {
91 | type: 'Field',
92 | field: 'Id',
93 | },
94 | {
95 | type: 'Field',
96 | field: 'Fax',
97 | },
98 | ],
99 | where: {
100 | left: {
101 | field: 'Fax',
102 | operator: 'IN',
103 | value: [55, null, undefined, 'foo'],
104 | literalType: 'STRING',
105 | },
106 | },
107 | },
108 | },
109 | {
110 | testCase: 7,
111 | soql: "SELECT Id, Fax FROM Account WHERE Fax IN ('55')",
112 | input: {
113 | sObject: 'Account',
114 | fields: [
115 | {
116 | type: 'Field',
117 | field: 'Id',
118 | },
119 | {
120 | type: 'Field',
121 | field: 'Fax',
122 | },
123 | ],
124 | where: {
125 | left: {
126 | field: 'Fax',
127 | operator: 'IN',
128 | value: 55 as any,
129 | literalType: 'STRING',
130 | },
131 | },
132 | },
133 | },
134 | {
135 | testCase: 7,
136 | soql: "SELECT Id, Fax FROM Account WHERE Fax = '55'",
137 | input: {
138 | sObject: 'Account',
139 | fields: [
140 | {
141 | type: 'Field',
142 | field: 'Id',
143 | },
144 | {
145 | type: 'Field',
146 | field: 'Fax',
147 | },
148 | ],
149 | where: {
150 | left: {
151 | field: 'Fax',
152 | operator: '=',
153 | value: 55 as any,
154 | literalType: 'STRING',
155 | },
156 | },
157 | },
158 | },
159 | ];
160 |
161 | export default testCases;
162 |
--------------------------------------------------------------------------------
/test/test-cases-for-partial-parse.ts:
--------------------------------------------------------------------------------
1 | import { Query } from '../src';
2 |
3 | export interface TestCase {
4 | testCase: number;
5 | soql: string;
6 | soqlComposed?: string; // used if the composed is known to be different from input
7 | output: Query;
8 | }
9 |
10 | export const testCases: TestCase[] = [
11 | {
12 | testCase: 1,
13 | soql: "WHERE Name LIKE 'A%' AND MailingCity = 'California'",
14 | output: {
15 | where: {
16 | left: { field: 'Name', operator: 'LIKE', value: "'A%'", literalType: 'STRING' },
17 | operator: 'AND',
18 | right: { left: { field: 'MailingCity', operator: '=', value: "'California'", literalType: 'STRING' } },
19 | },
20 | },
21 | },
22 | {
23 | testCase: 2,
24 | soql: 'FROM Account ORDER BY Name DESC NULLS LAST',
25 | output: { sObject: 'Account', orderBy: [{ field: 'Name', order: 'DESC', nulls: 'LAST' }] },
26 | },
27 | {
28 | testCase: 3,
29 | soql: "SELECT Name FROM Account WHERE Industry = 'media' LIMIT 125",
30 | output: {
31 | fields: [{ type: 'Field', field: 'Name' }],
32 | sObject: 'Account',
33 | where: { left: { field: 'Industry', operator: '=', value: "'media'", literalType: 'STRING' } },
34 | limit: 125,
35 | },
36 | },
37 | {
38 | testCase: 4,
39 | soql: "WHERE Industry = 'media' ORDER BY BillingPostalCode ASC NULLS LAST LIMIT 125",
40 | output: {
41 | where: { left: { field: 'Industry', operator: '=', value: "'media'", literalType: 'STRING' } },
42 | orderBy: [{ field: 'BillingPostalCode', order: 'ASC', nulls: 'LAST' }],
43 | limit: 125,
44 | },
45 | },
46 | { testCase: 5, soql: 'FROM Lead GROUP BY LeadSource', output: { sObject: 'Lead', groupBy: [{ field: 'LeadSource' }] } },
47 | {
48 | testCase: 6,
49 | soql: 'GROUP BY Name HAVING COUNT(Id) > 1',
50 | output: {
51 | groupBy: [{ field: 'Name' }],
52 | having: {
53 | left: {
54 | operator: '>',
55 | value: '1',
56 | literalType: 'INTEGER',
57 | fn: { rawValue: 'COUNT(Id)', functionName: 'COUNT', parameters: ['Id'] },
58 | },
59 | },
60 | },
61 | },
62 | { testCase: 7, soql: 'ORDER BY Name OFFSET 100', output: { orderBy: [{ field: 'Name' }], offset: 100 } },
63 | {
64 | testCase: 8,
65 | soql: 'SELECT Name, (SELECT LastName FROM Contacts)',
66 | output: {
67 | fields: [
68 | { type: 'Field', field: 'Name' },
69 | { type: 'FieldSubquery', subquery: { fields: [{ type: 'Field', field: 'LastName' }], relationshipName: 'Contacts' } },
70 | ],
71 | },
72 | },
73 | {
74 | testCase: 9,
75 | soql: "WHERE Mother_of_Child__r.LastName__c LIKE 'C%'",
76 | output: { where: { left: { field: 'Mother_of_Child__r.LastName__c', operator: 'LIKE', value: "'C%'", literalType: 'STRING' } } },
77 | },
78 | {
79 | testCase: 10,
80 | soql: 'FROM LoginHistory WHERE LoginTime > 2010-09-20T22:16:30.000Z AND LoginTime < 2010-09-21 GROUP BY UserId',
81 | output: {
82 | sObject: 'LoginHistory',
83 | where: {
84 | left: { field: 'LoginTime', operator: '>', value: '2010-09-20T22:16:30.000Z', literalType: 'DATETIME' },
85 | operator: 'AND',
86 | right: { left: { field: 'LoginTime', operator: '<', value: '2010-09-21', literalType: 'DATE' } },
87 | },
88 | groupBy: [{ field: 'UserId' }],
89 | },
90 | },
91 | {
92 | testCase: 11,
93 | soql: "WHERE (((Name = '1' OR Name = '2') AND Name = '3')) AND (((Description = '123') OR (Id = '1' AND Id = '2'))) AND Id = '1'",
94 | output: {
95 | where: {
96 | left: { openParen: 3, field: 'Name', operator: '=', value: "'1'", literalType: 'STRING' },
97 | operator: 'OR',
98 | right: {
99 | left: { field: 'Name', operator: '=', value: "'2'", literalType: 'STRING', closeParen: 1 },
100 | operator: 'AND',
101 | right: {
102 | left: { field: 'Name', operator: '=', value: "'3'", literalType: 'STRING', closeParen: 2 },
103 | operator: 'AND',
104 | right: {
105 | left: { openParen: 3, field: 'Description', operator: '=', value: "'123'", literalType: 'STRING', closeParen: 1 },
106 | operator: 'OR',
107 | right: {
108 | left: { openParen: 1, field: 'Id', operator: '=', value: "'1'", literalType: 'STRING' },
109 | operator: 'AND',
110 | right: {
111 | left: { field: 'Id', operator: '=', value: "'2'", literalType: 'STRING', closeParen: 3 },
112 | operator: 'AND',
113 | right: { left: { field: 'Id', operator: '=', value: "'1'", literalType: 'STRING' } },
114 | },
115 | },
116 | },
117 | },
118 | },
119 | },
120 | },
121 | },
122 | ];
123 | export default testCases;
124 |
--------------------------------------------------------------------------------
/test/test-partial-query.spec.ts:
--------------------------------------------------------------------------------
1 | import { describe, expect, it } from 'vitest';
2 | import { composeQuery, parseQuery, Query, WhereClause } from '../src';
3 | import { isValueQueryCondition, isWhereClauseWithRightCondition } from '../src/api/public-utils';
4 | import testCases from './test-cases-for-partial-parse';
5 |
6 | const replacements = [{ matching: / last /i, replace: ' LAST ' }];
7 |
8 | describe('parse queries', () => {
9 | testCases.forEach(testCase => {
10 | it(`should correctly parse test case ${testCase.testCase} - ${testCase.soql}`, () => {
11 | const soqlQuery = parseQuery(testCase.soql, { allowPartialQuery: true });
12 | expect(testCase.output).toEqual(soqlQuery);
13 | });
14 | });
15 | });
16 |
17 | describe('compose queries', () => {
18 | testCases.forEach(testCase => {
19 | it(`should compose correctly - test case ${testCase.testCase} - ${testCase.soql}`, () => {
20 | const soqlQuery = composeQuery(removeComposeOnlyFields(parseQuery(testCase.soql, { allowPartialQuery: true })));
21 | let soql = testCase.soqlComposed || testCase.soql;
22 | replacements.forEach(replacement => (soql = soql.replace(replacement.matching, replacement.replace)));
23 | expect(soqlQuery).toEqual(soql);
24 | });
25 | });
26 | });
27 |
28 | function removeComposeOnlyFields(query: Partial): Partial {
29 | (query.fields || []).forEach(removeComposeOnlyField);
30 | (query.fields || []).forEach(field => {
31 | if (field.type === 'FieldSubquery') {
32 | field.subquery.fields.forEach(removeComposeOnlyField);
33 | removeFieldsFromWhere(field.subquery.where);
34 | }
35 | });
36 | removeFieldsFromWhere(query.where);
37 | return query;
38 | }
39 |
40 | function removeFieldsFromWhere(where?: WhereClause) {
41 | if (!where) {
42 | return;
43 | }
44 |
45 | if (isValueQueryCondition(where.left)) {
46 | where.left.valueQuery.fields.forEach(removeComposeOnlyField);
47 | }
48 |
49 | if (isWhereClauseWithRightCondition(where)) {
50 | removeFieldsFromWhere(where.right);
51 | }
52 | }
53 |
54 | function removeComposeOnlyField(field: any) {
55 | delete field.isAggregateFn;
56 | delete field.rawValue;
57 | delete field.from;
58 | }
59 |
--------------------------------------------------------------------------------
/test/test.spec.ts:
--------------------------------------------------------------------------------
1 | import { describe, expect, it } from 'vitest';
2 | import { Compose, composeQuery, formatQuery, parseQuery, Query, WhereClause } from '../src';
3 | import { isValueQueryCondition, isWhereClauseWithRightCondition } from '../src/api/public-utils';
4 | import { isQueryValid } from '../src/parser/visitor';
5 | import testCases from './test-cases';
6 | import testCasesForComposeStandAlone from './test-cases-compose';
7 | import testCasesForFormat from './test-cases-for-format';
8 | import testCasesForIsValid from './test-cases-for-is-valid';
9 |
10 | const replacements = [{ matching: / last /i, replace: ' LAST ' }];
11 |
12 | // Uncomment these to easily test one specific query - useful for troubleshooting/bug-fixing
13 |
14 | // describe.only('parse queries', () => {
15 | // const testCase = testCases.find(tc => tc.testCase === 118);
16 | // it(`should correctly parse test case ${testCase.testCase} - ${testCase.soql}`, () => {
17 | // const soqlQuery = parseQuery(testCase.soql, testCase.options);
18 | // console.log(soqlQuery);
19 | // const soqlQueryWithoutUndefinedProps = JSON.parse(JSON.stringify(soqlQuery));
20 | // expect(testCase.output).toEqual(soqlQueryWithoutUndefinedProps);
21 | // });
22 | // });
23 |
24 | // describe.only('compose queries', () => {
25 | // const testCase = testCases.find(tc => tc.testCase === 104);
26 | // it(`should compose correctly - test case ${testCase.testCase} - ${testCase.soql}`, () => {
27 | // const soqlQuery = composeQuery(removeComposeOnlyFields(parseQuery(testCase.soql, testCase.options)));
28 | // let soql = testCase.soqlComposed || testCase.soql;
29 | // replacements.forEach(replacement => (soql = soql.replace(replacement.matching, replacement.replace)));
30 | // expect(soqlQuery).toEqual(soql);
31 | // });
32 | // });
33 |
34 | // describe.only('compose queries - standalone', () => {
35 | // testCasesForComposeStandAlone.forEach(testCase => {
36 | // it(`should correctly compose test case ${testCase.testCase} - ${testCase.soql}`, () => {
37 | // const soqlQuery = composeQuery(parseQuery(testCase.soql, testCase.options));
38 | // expect(testCase.soql).toEqual(soqlQuery);
39 | // });
40 | // });
41 | // });
42 |
43 | // describe.only('Test valid queries', () => {
44 | // testCasesForIsValid
45 | // .filter(testCase => testCase.isValid)
46 | // .forEach(testCase => {
47 | // it(`should identify valid queries - test case ${testCase.testCase} - ${testCase.soql}`, () => {
48 | // const isValid = isQueryValid(testCase.soql, testCase.options);
49 | // expect(parseQuery(testCase.soql, testCase.options)).to.not.throw;
50 | // expect(isValid).toEqual(testCase.isValid);
51 | // });
52 | // });
53 |
54 | // testCasesForIsValid
55 | // .filter(testCase => !testCase.isValid)
56 | // .forEach(testCase => {
57 | // it(`should identify invalid queries - test case ${testCase.testCase} - ${testCase.soql}`, () => {
58 | // const isValid = isQueryValid(testCase.soql, testCase.options);
59 | // expect(isValid).toEqual(testCase.isValid);
60 | // });
61 | // });
62 | // });
63 |
64 | // describe.only('format queries', () => {
65 | // const testCase = testCasesForFormat.find(tc => tc.testCase === 17);
66 | // it(`should format query - test case ${testCase.testCase} - ${testCase.soql}`, () => {
67 | // const formattedQuery = formatQuery(testCase.soql, testCase.formatOptions);
68 | // expect(formattedQuery).toEqual(testCase.formattedSoql);
69 | // });
70 | // });
71 |
72 | describe('parse queries', () => {
73 | testCases.forEach(testCase => {
74 | it(`should correctly parse test case ${testCase.testCase} - ${testCase.soql}`, () => {
75 | const soqlQuery = parseQuery(testCase.soql, testCase.options);
76 | expect(testCase.output).toEqual(soqlQuery);
77 | });
78 | });
79 | });
80 |
81 | describe('compose queries', () => {
82 | testCases.forEach(testCase => {
83 | it(`should compose correctly - test case ${testCase.testCase} - ${testCase.soql}`, () => {
84 | const soqlQuery = composeQuery(removeComposeOnlyFields(parseQuery(testCase.soql, testCase.options)));
85 | let soql = testCase.soqlComposed || testCase.soql;
86 | replacements.forEach(replacement => (soql = soql.replace(replacement.matching, replacement.replace)));
87 | expect(soqlQuery).toEqual(soql);
88 | });
89 | it(`should have valid composed queries - test case ${testCase.testCase} - ${testCase.soql}`, () => {
90 | const soqlQuery = composeQuery(removeComposeOnlyFields(parseQuery(testCase.soql, testCase.options)));
91 | expect(isQueryValid(soqlQuery, testCase.options)).toEqual(true);
92 | });
93 | });
94 | it('Should add single quotes to WHERE clause if not already exists', () => {
95 | const query: Query = {
96 | fields: [
97 | {
98 | type: 'Field',
99 | field: 'Id',
100 | },
101 | ],
102 | sObject: 'Account',
103 | where: {
104 | left: {
105 | field: 'Foo',
106 | operator: 'IN',
107 | value: ['1', '2', '3'],
108 | literalType: 'STRING',
109 | },
110 | operator: 'OR',
111 | right: {
112 | left: {
113 | field: 'Bar',
114 | operator: '=',
115 | value: 'foo',
116 | literalType: 'STRING',
117 | },
118 | },
119 | },
120 | };
121 | const soqlQuery = composeQuery(query);
122 | expect(soqlQuery).toEqual(`SELECT Id FROM Account WHERE Foo IN ('1', '2', '3') OR Bar = 'foo'`);
123 | });
124 | it('Should not add extraneous order by clauses', () => {
125 | const query: Query = {
126 | fields: [
127 | {
128 | type: 'Field',
129 | field: 'Id',
130 | },
131 | ],
132 | sObject: 'Account',
133 | orderBy: [],
134 | };
135 | const soqlQuery = composeQuery(query);
136 | expect(soqlQuery).toEqual(`SELECT Id FROM Account`);
137 | });
138 | });
139 |
140 | describe('compose queries - standalone', () => {
141 | testCasesForComposeStandAlone.forEach(testCase => {
142 | it(`should correctly compose test case ${testCase.testCase} - ${testCase.soql}`, () => {
143 | const soqlQuery = composeQuery(testCase.input);
144 | expect(soqlQuery).toEqual(testCase.soql);
145 | });
146 | });
147 | });
148 |
149 | describe('format queries', () => {
150 | testCasesForFormat.forEach(testCase => {
151 | it(`should format query - test case ${testCase.testCase} - ${testCase.soql}`, () => {
152 | const formattedQuery = formatQuery(testCase.soql, testCase.formatOptions);
153 | expect(formattedQuery).toEqual(testCase.formattedSoql);
154 | });
155 | });
156 | });
157 |
158 | describe('validate queries', () => {
159 | testCasesForIsValid
160 | .filter(testCase => testCase.isValid)
161 | .forEach(testCase => {
162 | it(`should identify valid queries - test case ${testCase.testCase} - ${testCase.soql}`, () => {
163 | const isValid = isQueryValid(testCase.soql, testCase.options);
164 | expect(() => parseQuery(testCase.soql, testCase.options)).not.toThrow();
165 | expect(isValid).toEqual(testCase.isValid);
166 | });
167 | });
168 |
169 | testCasesForIsValid
170 | .filter(testCase => !testCase.isValid)
171 | .forEach(testCase => {
172 | it(`should identify invalid queries - test case ${testCase.testCase} - ${testCase.soql}`, () => {
173 | const isValid = isQueryValid(testCase.soql, testCase.options);
174 | expect(isValid).toEqual(testCase.isValid);
175 | });
176 | });
177 | });
178 |
179 | describe('calls individual compose methods', () => {
180 | // TODO: add more tests
181 | // We have adequate coverage of overall queries, but these are public and should have adequate coverage individually
182 | it(`Should compose the where clause properly`, () => {
183 | const soql = `SELECT Id FROM Account WHERE Name = 'Foo'`;
184 | const parsedQuery = parseQuery(soql);
185 | const composer = new Compose(parsedQuery, { autoCompose: false });
186 | const whereClause = composer.parseWhereOrHavingClause(parsedQuery.where);
187 | expect(whereClause).toEqual(`Name = 'Foo'`);
188 | });
189 | it(`Should compose the where clause properly with semi-join`, () => {
190 | const soql = `SELECT Id FROM Account WHERE Id IN (SELECT AccountId FROM Contact WHERE Name LIKE '%foo%')`;
191 | const parsedQuery = parseQuery(soql);
192 | const composer = new Compose(parsedQuery, { autoCompose: false });
193 | const whereClause = composer.parseWhereOrHavingClause(parsedQuery.where);
194 | expect(whereClause).toEqual(`Id IN (SELECT AccountId FROM Contact WHERE Name LIKE '%foo%')`);
195 | });
196 | });
197 |
198 | function removeComposeOnlyFields(query: Query): Query {
199 | query.fields.forEach(removeComposeOnlyField);
200 | query.fields.forEach(field => {
201 | if (field.type === 'FieldSubquery') {
202 | field.subquery.fields.forEach(removeComposeOnlyField);
203 | removeFieldsFromWhere(field.subquery.where);
204 | }
205 | });
206 | removeFieldsFromWhere(query.where);
207 | return query;
208 | }
209 |
210 | function removeFieldsFromWhere(where?: WhereClause) {
211 | if (!where) {
212 | return;
213 | }
214 |
215 | if (isValueQueryCondition(where.left)) {
216 | where.left.valueQuery.fields.forEach(removeComposeOnlyField);
217 | }
218 |
219 | if (isWhereClauseWithRightCondition(where)) {
220 | removeFieldsFromWhere(where.right);
221 | }
222 | }
223 |
224 | function removeComposeOnlyField(field: any) {
225 | delete field.isAggregateFn;
226 | delete field.rawValue;
227 | delete field.from;
228 | }
229 |
--------------------------------------------------------------------------------
/test/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "lib": ["dom", "esnext"],
4 | "module": "commonjs",
5 | "moduleResolution": "node",
6 | "noImplicitAny": true,
7 | "outDir": "dist",
8 | "sourceMap": true,
9 | "target": "es2015",
10 | "typeRoots": ["../node_modules/@types"]
11 | },
12 | "include": ["./*.spec.ts"]
13 | }
14 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "emitDeclarationOnly": true,
4 | "declaration": true,
5 | "importHelpers": true,
6 | "isolatedModules": true,
7 | "lib": ["esnext"],
8 | "module": "ESNext",
9 | "moduleResolution": "node",
10 | "noImplicitAny": true,
11 | "declarationDir": "dist/types",
12 | "outDir": "dist",
13 | "removeComments": true,
14 | "strict": true,
15 | "target": "ES2017",
16 | "typeRoots": ["node_modules/@types"]
17 | },
18 | "include": ["src/**/*.ts"],
19 | "exclude": ["node_modules", "test", "docs"]
20 | }
21 |
--------------------------------------------------------------------------------
/vitest.config.ts:
--------------------------------------------------------------------------------
1 | import { defineConfig } from 'vitest/config';
2 |
3 | export default defineConfig({
4 | test: {
5 | globals: false,
6 | environment: 'node',
7 | coverage: {
8 | reporter: ['text', 'json', 'html'],
9 | },
10 | },
11 | });
12 |
--------------------------------------------------------------------------------