Skip to content

Commit 0d39aeb

Browse files
authored
feat: implement support for operator precedence (#17)
BREAKING CHANGE: The `operators` option changes from an object like `{ aboutEq: '~=', ... }` into an array with custom operators, having a name and precedence, like `[{ name: 'aboutEq', op: '~=', at: '==', vararg: false, leftAssociative: true }, ...]`.
1 parent 8d0441e commit 0d39aeb

22 files changed

+729
-190
lines changed

LICENSE.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
The ISC License
22

3-
Copyright (c) 2024 by Jos de Jong
3+
Copyright (c) 2024-2025 by Jos de Jong
44

55
Permission to use, copy, modify, and/or distribute this software for any purpose with or without fee is hereby granted, provided that the above copyright notice and this permission notice appear in all copies.
66

README.md

Lines changed: 29 additions & 15 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.3 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
@@ -152,22 +152,42 @@ Here:
152152
}
153153
```
154154

155-
You can have a look at the source code of the functions in `/src/functions.ts` for more examples.
156-
- `operators` is an optional map with operators, for example `{ eq: '==' }`. 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:
155+
You can have a look at the source code of the functions in [`/src/functions.ts`](/src/functions.ts) for more examples.
156+
157+
- `operators` is an optional array definitions for custom operators. Each definition describes the new operator, the name of the function that it maps to, and the desired precedence of the operator: the same, before, or after one of the existing operators (`at`, `before`, or `after`):
158+
159+
```ts
160+
type CustomOperator =
161+
| { name: string; op: string; at: string; vararg?: boolean, leftAssociative?: boolean }
162+
| { name: string; op: string; after: string; vararg?: boolean, leftAssociative?: boolean }
163+
| { name: string; op: string; before: string; vararg?: boolean, leftAssociative?: boolean }
164+
```
165+
166+
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:
157167

158168
```js
159-
import { buildFunction } from 'jsonquery'
160-
169+
import { buildFunction } from '@jsonquerylang/jsonquery'
170+
161171
const options = {
162-
operators: {
163-
notEqual: '<>'
164-
},
172+
// Define a new function "notEqual".
165173
functions: {
166174
notEqual: buildFunction((a, b) => a !== b)
167-
}
175+
},
176+
177+
// Define a new operator "<>" which maps to the function "notEqual"
178+
// and has the same precedence as operator "==".
179+
operators: [
180+
{ name: 'aboutEq', op: '~=', at: '==' }
181+
]
168182
}
169183
```
170184

185+
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`.
186+
187+
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]`.
188+
189+
All build-in operators and their precedence are listed on the documentation page in the section [Operators](https://jsonquerylang.org/docs/#operators).
190+
171191
Here an example of using the function `jsonquery`:
172192

173193
```js
@@ -258,9 +278,6 @@ The query engine passes the raw arguments to all functions, and the functions ha
258278

259279
```ts
260280
const options = {
261-
operators: {
262-
notEqual: '<>'
263-
},
264281
functions: {
265282
notEqual: (a: JSONQuery, b: JSONQuery) => {
266283
const aCompiled = compile(a)
@@ -286,9 +303,6 @@ To automatically compile and evaluate the arguments of the function, the helper
286303
import { jsonquery, buildFunction } from '@jsonquerylang/jsonquery'
287304

288305
const options = {
289-
operators: {
290-
notEqual: '<>'
291-
},
292306
functions: {
293307
notEqual: buildFunction((a: number, b: number) => a !== b)
294308
}

src/compile.test.ts

Lines changed: 19 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,16 @@
11
import Ajv from 'ajv'
22
import { describe, expect, test } from 'vitest'
3-
import type { CompileTestSuite } from '../test-suite/compile.test'
3+
import type { CompileTestException, CompileTestSuite } from '../test-suite/compile.test'
44
import suite from '../test-suite/compile.test.json'
55
import schema from '../test-suite/compile.test.schema.json'
66
import { compile } from './compile'
77
import { buildFunction } from './functions'
88
import type { JSONQuery, JSONQueryCompileOptions } from './types'
99

10+
function isTestException(test: unknown): test is CompileTestException {
11+
return !!test && typeof (test as Record<string, unknown>).throws === 'string'
12+
}
13+
1014
const data = [
1115
{ name: 'Chris', age: 23, city: 'New York' },
1216
{ name: 'Emily', age: 19, city: 'Atlanta' },
@@ -31,13 +35,20 @@ const testsByCategory = groupByCategory(suite.tests) as Record<string, CompileTe
3135
for (const [category, tests] of Object.entries(testsByCategory)) {
3236
describe(category, () => {
3337
for (const currentTest of tests) {
34-
const { description, input, query, output } = currentTest
35-
36-
test(description, () => {
37-
const actualOutput = compile(query)(input)
38-
39-
expect({ input, query, output: actualOutput }).toEqual({ input, query, output })
40-
})
38+
if (isTestException(currentTest)) {
39+
test(currentTest.description, () => {
40+
const { input, query, throws } = currentTest
41+
42+
expect(() => compile(query)(input)).toThrow(throws)
43+
})
44+
} else {
45+
test(currentTest.description, () => {
46+
const { input, query, output } = currentTest
47+
const actualOutput = compile(query)(input)
48+
49+
expect({ input, query, output: actualOutput }).toEqual({ input, query, output })
50+
})
51+
}
4152
}
4253
})
4354
}

src/functions.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -247,8 +247,8 @@ export const functions: FunctionBuildersMap = {
247247

248248
max: () => (data: number[]) => Math.max(...data),
249249

250-
and: buildFunction((a, b) => !!(a && b)),
251-
or: buildFunction((a, b) => !!(a || b)),
250+
and: buildFunction((...args) => args.reduce((a, b) => !!(a && b))),
251+
or: buildFunction((...args) => args.reduce((a, b) => !!(a || b))),
252252
not: buildFunction((a: unknown) => !a),
253253

254254
exists: (queryGet: JSONQueryFunction) => {
@@ -297,8 +297,9 @@ export const functions: FunctionBuildersMap = {
297297
subtract: buildFunction((a: number, b: number) => a - b),
298298
multiply: buildFunction((a: number, b: number) => a * b),
299299
divide: buildFunction((a: number, b: number) => a / b),
300-
pow: buildFunction((a: number, b: number) => a ** b),
301300
mod: buildFunction((a: number, b: number) => a % b),
301+
pow: buildFunction((a: number, b: number) => a ** b),
302+
302303
abs: buildFunction(Math.abs),
303304
round: buildFunction((value: number, digits = 0) => {
304305
const num = Math.round(Number(`${value}e${digits}`))

src/jsonquery.test.ts

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -41,25 +41,26 @@ describe('jsonquery', () => {
4141

4242
test('should execute a JSON query with custom operators', () => {
4343
const options: JSONQueryOptions = {
44-
operators: {
45-
aboutEq: '~='
44+
functions: {
45+
aboutEq: buildFunction((a: string, b: string) => a.toLowerCase() === b.toLowerCase())
4646
}
4747
}
4848

49-
expect(jsonquery({ name: 'Joe' }, ['get', 'name'], options)).toEqual('Joe')
49+
expect(jsonquery({ name: 'Joe' }, ['aboutEq', ['get', 'name'], 'joe'], options)).toEqual(true)
5050
})
5151

5252
test('should execute a text query with custom operators', () => {
5353
const options: JSONQueryOptions = {
54-
operators: {
55-
aboutEq: '~='
54+
operators: [{ name: 'aboutEq', op: '~=', at: '==' }],
55+
functions: {
56+
aboutEq: buildFunction((a: string, b: string) => a.toLowerCase() === b.toLowerCase())
5657
}
5758
}
5859

59-
expect(jsonquery({ name: 'Joe' }, '.name', options)).toEqual('Joe')
60+
expect(jsonquery({ name: 'Joe' }, '.name ~= "joe"', options)).toEqual(true)
6061
})
6162

62-
test('have exported all documented functions', () => {
63+
test('have exported all documented functions and objects', () => {
6364
expect(jsonquery).toBeTypeOf('function')
6465
expect(parse).toBeTypeOf('function')
6566
expect(stringify).toBeTypeOf('function')

src/operators.test.ts

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
import { describe, expect, test } from 'vitest'
2+
import { extendOperators } from './operators'
3+
4+
describe('operators', () => {
5+
test('should extend operators (at)', () => {
6+
const ops = [{ add: '+', subtract: '-' }, { eq: '==' }]
7+
8+
expect(extendOperators(ops, [{ name: 'aboutEq', op: '~=', at: '==' }])).toEqual([
9+
{ add: '+', subtract: '-' },
10+
{ eq: '==', aboutEq: '~=' }
11+
])
12+
})
13+
14+
test('should extend operators (after)', () => {
15+
const ops = [{ add: '+', subtract: '-' }, { eq: '==' }]
16+
17+
expect(extendOperators(ops, [{ name: 'aboutEq', op: '~=', after: '+' }])).toEqual([
18+
{ add: '+', subtract: '-' },
19+
{ aboutEq: '~=' },
20+
{ eq: '==' }
21+
])
22+
})
23+
24+
test('should extend operators (before)', () => {
25+
const ops = [{ add: '+', subtract: '-' }, { eq: '==' }]
26+
27+
expect(extendOperators(ops, [{ name: 'aboutEq', op: '~=', before: '==' }])).toEqual([
28+
{ add: '+', subtract: '-' },
29+
{ aboutEq: '~=' },
30+
{ eq: '==' }
31+
])
32+
})
33+
34+
test('should extend operators (multiple consecutive)', () => {
35+
const ops = [{ add: '+', subtract: '-' }, { eq: '==' }]
36+
37+
expect(
38+
extendOperators(ops, [
39+
{ name: 'first', op: 'op1', before: '==' },
40+
{ name: 'second', op: 'op2', before: 'op1' }
41+
])
42+
).toEqual([{ add: '+', subtract: '-' }, { second: 'op2' }, { first: 'op1' }, { eq: '==' }])
43+
})
44+
})

src/operators.ts

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
import { isArray } from './is'
2+
import type { CustomOperator, OperatorGroup } from './types'
3+
4+
// operator precedence from highest to lowest
5+
export const operators: OperatorGroup[] = [
6+
{ pow: '^' },
7+
{ multiply: '*', divide: '/', mod: '%' },
8+
{ add: '+', subtract: '-' },
9+
{ gt: '>', gte: '>=', lt: '<', lte: '<=', in: 'in', 'not in': 'not in' },
10+
{ eq: '==', ne: '!=' },
11+
{ and: 'and' },
12+
{ or: 'or' },
13+
{ pipe: '|' }
14+
]
15+
16+
export const varargOperators = ['|', 'and', 'or']
17+
export const leftAssociativeOperators = ['|', 'and', 'or', '*', '/', '%', '+', '-']
18+
19+
export function extendOperators(operators: OperatorGroup[], customOperators: CustomOperator[]) {
20+
// backward compatibility error with v4 where `operators` was an object
21+
if (!isArray(customOperators)) {
22+
throw new Error('Invalid custom operators')
23+
}
24+
25+
return customOperators.reduce(extendOperator, operators)
26+
}
27+
28+
function extendOperator(
29+
operators: OperatorGroup[],
30+
// @ts-expect-error Inside the function we will check whether at, below, and above are defined
31+
{ name, op, at, after, before }: CustomOperator
32+
): OperatorGroup[] {
33+
if (at) {
34+
return operators.map((group) => {
35+
return Object.values(group).includes(at) ? { ...group, [name]: op } : group
36+
})
37+
}
38+
39+
const searchOp = after ?? before
40+
const index = operators.findIndex((group) => Object.values(group).includes(searchOp))
41+
if (index !== -1) {
42+
return operators.toSpliced(index + (after ? 1 : 0), 0, { [name]: op })
43+
}
44+
45+
throw new Error('Invalid custom operator')
46+
}

src/parse.test.ts

Lines changed: 55 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -43,18 +43,70 @@ for (const [category, testGroups] of Object.entries(testsByCategory)) {
4343
describe('customization', () => {
4444
test('should parse a custom function', () => {
4545
const options: JSONQueryParseOptions = {
46-
functions: { customFn: true }
46+
functions: { customFn: () => () => 42 }
4747
}
4848

4949
expect(parse('customFn(.age, "desc")', options)).toEqual(['customFn', ['get', 'age'], 'desc'])
50+
51+
// built-in functions should still be available
52+
expect(parse('add(2, 3)', options)).toEqual(['add', 2, 3])
5053
})
5154

52-
test('should parse a custom operator', () => {
55+
test('should parse a custom operator without vararg', () => {
5356
const options: JSONQueryParseOptions = {
54-
operators: { aboutEq: '~=' }
57+
operators: [{ name: 'aboutEq', op: '~=', at: '==' }]
5558
}
5659

5760
expect(parse('.score ~= 8', options)).toEqual(['aboutEq', ['get', 'score'], 8])
61+
62+
// built-in operators should still be available
63+
expect(parse('.score == 8', options)).toEqual(['eq', ['get', 'score'], 8])
64+
65+
expect(() => parse('2 ~= 3 ~= 4', options)).toThrow("Unexpected part '~= 4'")
66+
})
67+
68+
test('should parse a custom operator with vararg without leftAssociative', () => {
69+
const options: JSONQueryParseOptions = {
70+
operators: [{ name: 'aboutEq', op: '~=', at: '==', vararg: true }]
71+
}
72+
73+
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])
90+
expect(parse('2 ~= 3 ~= 4', options)).toEqual(['aboutEq', 2, 3, 4])
91+
expect(() => parse('2 == 3 == 4', options)).toThrow("Unexpected part '== 4'")
92+
})
93+
94+
test('should throw an error in case of an invalid custom operator', () => {
95+
const options: JSONQueryParseOptions = {
96+
// @ts-ignore
97+
operators: [{}]
98+
}
99+
100+
expect(() => parse('.score > 8', options)).toThrow('Invalid custom operator')
101+
})
102+
103+
test('should throw an error in case of an invalid custom operator (2)', () => {
104+
const options: JSONQueryParseOptions = {
105+
// @ts-ignore
106+
operators: {}
107+
}
108+
109+
expect(() => parse('.score > 8', options)).toThrow('Invalid custom operators')
58110
})
59111
})
60112

0 commit comments

Comments
 (0)