+729
-190
lines changedFilter options
+729
-190
lines changed Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
1
1
The ISC License
2
2
3
-
Copyright (c) 2024 by Jos de Jong
3
+
Copyright (c) 2024-2025 by Jos de Jong
4
4
5
5
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.
6
6
Original file line number Diff line number Diff line change
@@ -10,7 +10,7 @@ Try it out on the online playground: <https://jsonquerylang.org>
10
10
11
11
## Features
12
12
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`.
14
14
- Feature rich (50+ powerful functions and operators)
15
15
- Easy to interoperate with thanks to the intermediate JSON format.
16
16
- Expressive
@@ -152,22 +152,42 @@ Here:
152
152
}
153
153
```
154
154
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:
157
167
158
168
```js
159
-
import { buildFunction } from 'jsonquery'
160
-
169
+
import { buildFunction } from '@jsonquerylang/jsonquery'
170
+
161
171
const options = {
162
-
operators: {
163
-
notEqual: '<>'
164
-
},
172
+
// Define a new function "notEqual".
165
173
functions: {
166
174
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
+
]
168
182
}
169
183
```
170
184
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
+
171
191
Here an example of using the function `jsonquery`:
172
192
173
193
```js
@@ -258,9 +278,6 @@ The query engine passes the raw arguments to all functions, and the functions ha
258
278
259
279
```ts
260
280
const options = {
261
-
operators: {
262
-
notEqual: '<>'
263
-
},
264
281
functions: {
265
282
notEqual: (a: JSONQuery, b: JSONQuery) => {
266
283
const aCompiled = compile(a)
@@ -286,9 +303,6 @@ To automatically compile and evaluate the arguments of the function, the helper
286
303
import { jsonquery, buildFunction } from '@jsonquerylang/jsonquery'
287
304
288
305
const options = {
289
-
operators: {
290
-
notEqual: '<>'
291
-
},
292
306
functions: {
293
307
notEqual: buildFunction((a: number, b: number) => a !== b)
294
308
}
Original file line number Diff line number Diff line change
@@ -1,12 +1,16 @@
1
1
import Ajv from 'ajv'
2
2
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'
4
4
import suite from '../test-suite/compile.test.json'
5
5
import schema from '../test-suite/compile.test.schema.json'
6
6
import { compile } from './compile'
7
7
import { buildFunction } from './functions'
8
8
import type { JSONQuery, JSONQueryCompileOptions } from './types'
9
9
10
+
function isTestException(test: unknown): test is CompileTestException {
11
+
return !!test && typeof (test as Record<string, unknown>).throws === 'string'
12
+
}
13
+
10
14
const data = [
11
15
{ name: 'Chris', age: 23, city: 'New York' },
12
16
{ name: 'Emily', age: 19, city: 'Atlanta' },
@@ -31,13 +35,20 @@ const testsByCategory = groupByCategory(suite.tests) as Record<string, CompileTe
31
35
for (const [category, tests] of Object.entries(testsByCategory)) {
32
36
describe(category, () => {
33
37
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
+
}
41
52
}
42
53
})
43
54
}
Original file line number Diff line number Diff line change
@@ -247,8 +247,8 @@ export const functions: FunctionBuildersMap = {
247
247
248
248
max: () => (data: number[]) => Math.max(...data),
249
249
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))),
252
252
not: buildFunction((a: unknown) => !a),
253
253
254
254
exists: (queryGet: JSONQueryFunction) => {
@@ -297,8 +297,9 @@ export const functions: FunctionBuildersMap = {
297
297
subtract: buildFunction((a: number, b: number) => a - b),
298
298
multiply: buildFunction((a: number, b: number) => a * b),
299
299
divide: buildFunction((a: number, b: number) => a / b),
300
-
pow: buildFunction((a: number, b: number) => a ** b),
301
300
mod: buildFunction((a: number, b: number) => a % b),
301
+
pow: buildFunction((a: number, b: number) => a ** b),
302
+
302
303
abs: buildFunction(Math.abs),
303
304
round: buildFunction((value: number, digits = 0) => {
304
305
const num = Math.round(Number(`${value}e${digits}`))
Original file line number Diff line number Diff line change
@@ -41,25 +41,26 @@ describe('jsonquery', () => {
41
41
42
42
test('should execute a JSON query with custom operators', () => {
43
43
const options: JSONQueryOptions = {
44
-
operators: {
45
-
aboutEq: '~='
44
+
functions: {
45
+
aboutEq: buildFunction((a: string, b: string) => a.toLowerCase() === b.toLowerCase())
46
46
}
47
47
}
48
48
49
-
expect(jsonquery({ name: 'Joe' }, ['get', 'name'], options)).toEqual('Joe')
49
+
expect(jsonquery({ name: 'Joe' }, ['aboutEq', ['get', 'name'], 'joe'], options)).toEqual(true)
50
50
})
51
51
52
52
test('should execute a text query with custom operators', () => {
53
53
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())
56
57
}
57
58
}
58
59
59
-
expect(jsonquery({ name: 'Joe' }, '.name', options)).toEqual('Joe')
60
+
expect(jsonquery({ name: 'Joe' }, '.name ~= "joe"', options)).toEqual(true)
60
61
})
61
62
62
-
test('have exported all documented functions', () => {
63
+
test('have exported all documented functions and objects', () => {
63
64
expect(jsonquery).toBeTypeOf('function')
64
65
expect(parse).toBeTypeOf('function')
65
66
expect(stringify).toBeTypeOf('function')
Original file line number Diff line number Diff 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
+
})
Original file line number Diff line number Diff 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
+
}
Original file line number Diff line number Diff line change
@@ -43,18 +43,70 @@ for (const [category, testGroups] of Object.entries(testsByCategory)) {
43
43
describe('customization', () => {
44
44
test('should parse a custom function', () => {
45
45
const options: JSONQueryParseOptions = {
46
-
functions: { customFn: true }
46
+
functions: { customFn: () => () => 42 }
47
47
}
48
48
49
49
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])
50
53
})
51
54
52
-
test('should parse a custom operator', () => {
55
+
test('should parse a custom operator without vararg', () => {
53
56
const options: JSONQueryParseOptions = {
54
-
operators: { aboutEq: '~=' }
57
+
operators: [{ name: 'aboutEq', op: '~=', at: '==' }]
55
58
}
56
59
57
60
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')
58
110
})
59
111
})
60
112
You can’t perform that action at this time.
RetroSearch is an open source project built by @garambo | Open a GitHub Issue
Search and Browse the WWW like it's 1997 | Search results from DuckDuckGo
HTML:
3.2
| Encoding:
UTF-8
| Version:
0.7.4