Skip to content

Commit 7408cd6

Browse files
committed
feat: implement left associative operators and a corresponding option
1 parent 04d646a commit 7408cd6

File tree

8 files changed

+53
-84
lines changed

8 files changed

+53
-84
lines changed

README.md

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ Try it out on the online playground: <https://jsonquerylang.org>
1010

1111
## Features
1212

13-
- Small: just `3.5 kB` when minified and gzipped! The JSON query engine without parse/stringify is only `1.7 kB`.
13+
- Small: just `3.7 kB` when minified and gzipped! The JSON query engine without parse/stringify is only `1.7 kB`.
1414
- Feature rich (50+ powerful functions and operators)
1515
- Easy to interoperate with thanks to the intermediate JSON format.
1616
- Expressive
@@ -404,9 +404,9 @@ Here:
404404

405405
```ts
406406
type CustomOperator =
407-
| { name: string; op: string; at: string; vararg?: boolean }
408-
| { name: string; op: string; after: string; vararg?: boolean }
409-
| { name: string; op: string; before: string; vararg?: boolean }
407+
| { name: string; op: string; at: string; vararg?: boolean, leftAssociative?: boolean }
408+
| { name: string; op: string; after: string; vararg?: boolean, leftAssociative?: boolean }
409+
| { name: string; op: string; before: string; vararg?: boolean, leftAssociative?: boolean }
410410
```
411411

412412
The defined operators can be used in a text query. Only operators with both a left and right hand side are supported, like `a == b`. They can only be executed when there is a corresponding function. For example:
@@ -428,8 +428,10 @@ Here:
428428
}
429429
```
430430

431-
When the function of the operator supports more than two arguments, like `and(a, b, c, ...)`, the option `vararg` can be set `true` to allow using a chain of multiple operators without parenthesis, like `a and b and c`. When `vararg` is not set `true`, this would throw an exception, which can be solved by using parenthesis like `(a and b) and c`.
431+
To allow using a chain of multiple operators without parenthesis, like `a and b and c`, the option `leftAssociative` can be set `true`. Without this, an exception will be thrown, which can be solved by using parenthesis like `(a and b) and c`.
432432

433+
When the function of the operator supports more than two arguments, like `and(a, b, c, ...)`, the option `vararg` can be set `true`. In that case, a chain of operators like `a and b and c` will be parsed into the JSON Format `["and", a, b, c, ...]`. Operators that do not support variable arguments, like `1 + 2 + 3`, will be parsed into a nested JSON Format like `["add", ["add", 1, 2], 3]`.
434+
433435
All build-in operators and their precedence are listed in the section [Operators](#operators).
434436

435437
Here an example of using the function `jsonquery`:

src/functions.ts

Lines changed: 7 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -27,10 +27,6 @@ export function buildFunction(fn: (...args: unknown[]) => unknown): FunctionBuil
2727
}
2828
}
2929

30-
const reduceArgs = (fn: (...args: unknown[]) => unknown) => {
31-
return (...args: unknown[]) => args.reduce(fn)
32-
}
33-
3430
export const functions: FunctionBuildersMap = {
3531
pipe: (...entries: JSONQuery[]) => {
3632
const _entries = entries.map((entry) => compile(entry))
@@ -251,8 +247,8 @@ export const functions: FunctionBuildersMap = {
251247

252248
max: () => (data: number[]) => Math.max(...data),
253249

254-
and: buildFunction(reduceArgs((a, b) => !!(a && b))),
255-
or: buildFunction(reduceArgs((a, b) => !!(a || b))),
250+
and: buildFunction((...args) => args.reduce((a, b) => !!(a && b))),
251+
or: buildFunction((...args) => args.reduce((a, b) => !!(a || b))),
256252
not: buildFunction((a: unknown) => !a),
257253

258254
exists: (queryGet: JSONQueryFunction) => {
@@ -297,11 +293,11 @@ export const functions: FunctionBuildersMap = {
297293
lte: buildFunction((a, b) => a <= b),
298294
ne: buildFunction((a, b) => a !== b),
299295

300-
add: buildFunction(reduceArgs((a: number, b: number) => a + b)),
301-
subtract: buildFunction(reduceArgs((a: number, b: number) => a - b)),
302-
multiply: buildFunction(reduceArgs((a: number, b: number) => a * b)),
303-
divide: buildFunction(reduceArgs((a: number, b: number) => a / b)),
304-
mod: buildFunction(reduceArgs((a: number, b: number) => a % b)),
296+
add: buildFunction((a: number, b: number) => a + b),
297+
subtract: buildFunction((a: number, b: number) => a - b),
298+
multiply: buildFunction((a: number, b: number) => a * b),
299+
divide: buildFunction((a: number, b: number) => a / b),
300+
mod: buildFunction((a: number, b: number) => a % b),
305301
pow: buildFunction((a: number, b: number) => a ** b),
306302

307303
abs: buildFunction(Math.abs),

src/operators.ts

Lines changed: 2 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,8 @@ export const operators: OperatorGroup[] = [
1313
{ or: 'or' }
1414
]
1515

16-
export const varargOperators = ['|', '*', '/', '%', '+', '-', 'and', 'or']
16+
export const varargOperators = ['|', 'and', 'or']
17+
export const leftAssociativeOperators = ['|', 'and', 'or', '*', '/', '%', '+', '-']
1718

1819
export function extendOperators(operators: OperatorGroup[], customOperators: CustomOperator[]) {
1920
// backward compatibility error with v4 where `operators` was an object
@@ -43,15 +44,3 @@ function extendOperator(
4344

4445
throw new Error('Invalid custom operator')
4546
}
46-
47-
export function extendVarargOperators(
48-
varargOperators: string[],
49-
customOperators: CustomOperator[]
50-
): string[] {
51-
return [
52-
...varargOperators,
53-
...customOperators
54-
.filter((customOperator) => customOperator.vararg)
55-
.map((customOperator) => customOperator.op)
56-
]
57-
}

src/parse.test.ts

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -65,12 +65,28 @@ describe('customization', () => {
6565
expect(() => parse('2 ~= 3 ~= 4', options)).toThrow("Unexpected part '~= 4'")
6666
})
6767

68-
test('should parse a custom operator with vararg', () => {
68+
test('should parse a custom operator with vararg without leftAssociative', () => {
6969
const options: JSONQueryParseOptions = {
7070
operators: [{ name: 'aboutEq', op: '~=', at: '==', vararg: true }]
7171
}
7272

7373
expect(parse('2 and 3 and 4', options)).toEqual(['and', 2, 3, 4])
74+
expect(parse('2 ~= 3', options)).toEqual(['aboutEq', 2, 3])
75+
expect(parse('2 ~= 3 and 4', options)).toEqual(['and', ['aboutEq', 2, 3], 4])
76+
expect(parse('2 and 3 ~= 4', options)).toEqual(['and', 2, ['aboutEq', 3, 4]])
77+
expect(parse('2 == 3 ~= 4', options)).toEqual(['aboutEq', ['eq', 2, 3], 4])
78+
expect(parse('2 ~= 3 == 4', options)).toEqual(['eq', ['aboutEq', 2, 3], 4])
79+
expect(() => parse('2 ~= 3 ~= 4', options)).toThrow("Unexpected part '~= 4'")
80+
expect(() => parse('2 == 3 == 4', options)).toThrow("Unexpected part '== 4'")
81+
})
82+
83+
test('should parse a custom operator with vararg with leftAssociative', () => {
84+
const options: JSONQueryParseOptions = {
85+
operators: [{ name: 'aboutEq', op: '~=', at: '==', vararg: true, leftAssociative: true }]
86+
}
87+
88+
expect(parse('2 and 3 and 4', options)).toEqual(['and', 2, 3, 4])
89+
expect(parse('2 ~= 3', options)).toEqual(['aboutEq', 2, 3])
7490
expect(parse('2 ~= 3 ~= 4', options)).toEqual(['aboutEq', 2, 3, 4])
7591
expect(() => parse('2 == 3 == 4', options)).toThrow("Unexpected part '== 4'")
7692
})

src/parse.ts

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { functions } from './functions'
2-
import { extendOperators, extendVarargOperators, operators, varargOperators } from './operators'
2+
import { extendOperators, leftAssociativeOperators, operators, varargOperators } from './operators'
33
import {
44
startsWithIntRegex,
55
startsWithKeywordRegex,
@@ -30,7 +30,12 @@ export function parse(query: string, options?: JSONQueryParseOptions): JSONQuery
3030
const allFunctions = { ...functions, ...options?.functions }
3131
const allOperators = extendOperators(operators, customOperators)
3232
const allOperatorsMap = Object.assign({}, ...allOperators)
33-
const allVarargOperators = extendVarargOperators(varargOperators, customOperators)
33+
const allVarargOperators = varargOperators.concat(
34+
customOperators.filter((op) => op.vararg).map((op) => op.op)
35+
)
36+
const allLeftAssociativeOperators = leftAssociativeOperators.concat(
37+
customOperators.filter((op) => op.leftAssociative).map((op) => op.op)
38+
)
3439

3540
const parseOperator = (precedenceLevel: number) => {
3641
const currentOperators = allOperators[precedenceLevel]
@@ -54,12 +59,15 @@ export function parse(query: string, options?: JSONQueryParseOptions): JSONQuery
5459

5560
const childName = left[0]
5661
const chained = name === childName && !leftParenthesis
57-
if (chained && !allVarargOperators.includes(allOperatorsMap[name])) {
62+
if (chained && !allLeftAssociativeOperators.includes(allOperatorsMap[name])) {
5863
i = start
5964
break
6065
}
6166

62-
left = chained ? [...left, right] : [name, left, right]
67+
left =
68+
chained && allVarargOperators.includes(allOperatorsMap[name])
69+
? [...left, right]
70+
: [name, left, right]
6371
}
6472

6573
return left

src/types.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -34,9 +34,9 @@ export type FunctionBuildersMap = Record<string, FunctionBuilder>
3434
export type Getter = [key: string, Fun]
3535
export type OperatorGroup = Record<string, string>
3636
export type CustomOperator =
37-
| { name: string; op: string; at: string; vararg?: boolean }
38-
| { name: string; op: string; after: string; vararg?: boolean }
39-
| { name: string; op: string; before: string; vararg?: boolean }
37+
| { name: string; op: string; at: string; vararg?: boolean; leftAssociative?: boolean }
38+
| { name: string; op: string; after: string; vararg?: boolean; leftAssociative?: boolean }
39+
| { name: string; op: string; before: string; vararg?: boolean; leftAssociative?: boolean }
4040

4141
export interface Entry<T> {
4242
key: string

test-suite/compile.test.json

Lines changed: 0 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -948,13 +948,6 @@
948948
"query": ["and", true, true],
949949
"output": true
950950
},
951-
{
952-
"category": "and",
953-
"description": "should calculate and with more than two arguments",
954-
"input": null,
955-
"query": ["and", true, true, false],
956-
"output": false
957-
},
958951

959952
{
960953
"category": "or",
@@ -1282,13 +1275,6 @@
12821275
"query": ["add", "is:", true],
12831276
"output": "is:true"
12841277
},
1285-
{
1286-
"category": "add",
1287-
"description": "should add more than two arguments",
1288-
"input": null,
1289-
"query": ["add", 6, 2, 3],
1290-
"output": 11
1291-
},
12921278

12931279
{
12941280
"category": "subtract",
@@ -1304,13 +1290,6 @@
13041290
"query": ["subtract", 6, 2],
13051291
"output": 4
13061292
},
1307-
{
1308-
"category": "subtract",
1309-
"description": "should subtract more than two arguments",
1310-
"input": null,
1311-
"query": ["subtract", 6, 2, 1],
1312-
"output": 3
1313-
},
13141293

13151294
{
13161295
"category": "multiply",
@@ -1326,13 +1305,6 @@
13261305
"query": ["multiply", 6, 2],
13271306
"output": 12
13281307
},
1329-
{
1330-
"category": "multiply",
1331-
"description": "should multiply more than two arguments",
1332-
"input": null,
1333-
"query": ["multiply", 6, 2, 3],
1334-
"output": 36
1335-
},
13361308

13371309
{
13381310
"category": "divide",
@@ -1348,13 +1320,6 @@
13481320
"query": ["divide", 6, 2],
13491321
"output": 3
13501322
},
1351-
{
1352-
"category": "divide",
1353-
"description": "should divide more than two arguments",
1354-
"input": null,
1355-
"query": ["divide", 6, 2, 3],
1356-
"output": 1
1357-
},
13581323

13591324
{
13601325
"category": "pow",
@@ -1392,13 +1357,6 @@
13921357
"query": ["mod", 8, 3],
13931358
"output": 2
13941359
},
1395-
{
1396-
"category": "mod",
1397-
"description": "should calculate the modulus for more than two arguments",
1398-
"input": null,
1399-
"query": ["mod", 20, 12, 3],
1400-
"output": 2
1401-
},
14021360

14031361
{
14041362
"category": "abs",

test-suite/parse.test.json

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -167,11 +167,11 @@
167167
"tests": [
168168
{ "input": "1 and 2 and 3", "output": ["and", 1, 2, 3] },
169169
{ "input": "1 or 2 or 3", "output": ["or", 1, 2, 3] },
170-
{ "input": "1 * 2 * 3", "output": ["multiply", 1, 2, 3] },
171-
{ "input": "1 / 2 / 3", "output": ["divide", 1, 2, 3] },
172-
{ "input": "1 % 2 % 3", "output": ["mod", 1, 2, 3] },
173-
{ "input": "1 + 2 + 3", "output": ["add", 1, 2, 3] },
174-
{ "input": "1 - 2 - 3", "output": ["subtract", 1, 2, 3] }
170+
{ "input": "1 * 2 * 3", "output": ["multiply", ["multiply", 1, 2], 3] },
171+
{ "input": "1 / 2 / 3", "output": ["divide", ["divide", 1, 2], 3] },
172+
{ "input": "1 % 2 % 3", "output": ["mod", ["mod", 1, 2], 3] },
173+
{ "input": "1 + 2 + 3", "output": ["add", ["add", 1, 2], 3] },
174+
{ "input": "1 - 2 - 3", "output": ["subtract", ["subtract", 1, 2], 3] }
175175
]
176176
},
177177
{

0 commit comments

Comments
 (0)