Skip to content

Commit d7d2cdf

Browse files
committed
feat: implement support for operator precedence (WIP)
1 parent 09b7cb6 commit d7d2cdf

File tree

9 files changed

+113
-49
lines changed

9 files changed

+113
-49
lines changed

src/constants.ts

Lines changed: 32 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,24 +1,35 @@
1-
export const operators = {
2-
and: 'and',
3-
or: 'or',
4-
5-
eq: '==',
6-
gt: '>',
7-
gte: '>=',
8-
lt: '<',
9-
lte: '<=',
10-
ne: '!=',
11-
12-
add: '+',
13-
subtract: '-',
14-
multiply: '*',
15-
divide: '/',
16-
pow: '^',
17-
mod: '%',
18-
19-
in: 'in',
20-
'not in': 'not in'
21-
}
1+
export const operators = [
2+
{
3+
in: 'in',
4+
'not in': 'not in'
5+
},
6+
{
7+
or: 'or'
8+
},
9+
{
10+
and: 'and'
11+
},
12+
{
13+
eq: '==',
14+
gt: '>',
15+
gte: '>=',
16+
lt: '<',
17+
lte: '<=',
18+
ne: '!='
19+
},
20+
{
21+
add: '+',
22+
subtract: '-'
23+
},
24+
{
25+
multiply: '*',
26+
divide: '/',
27+
mod: '%'
28+
},
29+
{
30+
pow: '^'
31+
}
32+
]
2233

2334
export const unquotedPropertyRegex = /^[a-zA-Z_$][a-zA-Z\d_$]*$/
2435
export const startsWithUnquotedPropertyRegex = /^[a-zA-Z_$][a-zA-Z\d_$]*/

src/jsonquery.test.ts

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -41,19 +41,23 @@ describe('jsonquery', () => {
4141

4242
test('should execute a JSON query with custom operators', () => {
4343
const options: JSONQueryOptions = {
44-
operators: {
45-
aboutEq: '~='
46-
}
44+
operators: [
45+
{
46+
aboutEq: '~='
47+
}
48+
]
4749
}
4850

4951
expect(jsonquery({ name: 'Joe' }, ['get', 'name'], options)).toEqual('Joe')
5052
})
5153

5254
test('should execute a text query with custom operators', () => {
5355
const options: JSONQueryOptions = {
54-
operators: {
55-
aboutEq: '~='
56-
}
56+
operators: [
57+
{
58+
aboutEq: '~='
59+
}
60+
]
5761
}
5862

5963
expect(jsonquery({ name: 'Joe' }, '.name', options)).toEqual('Joe')

src/parse.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,7 @@ describe('customization', () => {
5151

5252
test('should parse a custom operator', () => {
5353
const options: JSONQueryParseOptions = {
54-
operators: { aboutEq: '~=' }
54+
operators: [{ aboutEq: '~=' }]
5555
}
5656

5757
expect(parse('.score ~= 8', options)).toEqual(['aboutEq', ['get', 'score'], 8])

src/parse.ts

Lines changed: 33 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -26,12 +26,12 @@ import type { JSONQuery, JSONQueryParseOptions } from './types'
2626
* // ]
2727
*/
2828
export function parse(query: string, options?: JSONQueryParseOptions): JSONQuery {
29-
const allOperators = { ...operators, ...options?.operators }
30-
const sortedOperatorNames = Object.keys(allOperators).sort((a, b) => b.length - a.length)
29+
// FIXME: user must be able to specify precedence
30+
const allOperators = [...operators, ...(options?.operators ?? [])]
3131

3232
const parsePipe = () => {
3333
skipWhitespace()
34-
const first = parseOperator()
34+
const first = parseOperator(0)
3535
skipWhitespace()
3636

3737
if (query[i] === '|') {
@@ -41,7 +41,7 @@ export function parse(query: string, options?: JSONQueryParseOptions): JSONQuery
4141
i++
4242
skipWhitespace()
4343

44-
pipe.push(parseOperator())
44+
pipe.push(parseOperator(0))
4545
}
4646

4747
return ['pipe', ...pipe]
@@ -50,24 +50,47 @@ export function parse(query: string, options?: JSONQueryParseOptions): JSONQuery
5050
return first
5151
}
5252

53-
const parseOperator = () => {
54-
const left = parseParenthesis()
53+
const parseOperator = (precedenceLevel: number) => {
54+
const currentOperators = allOperators[precedenceLevel]
55+
if (!currentOperators) {
56+
return parseParenthesis()
57+
}
58+
59+
let left = parseOperator(precedenceLevel + 1)
5560

5661
skipWhitespace()
5762

63+
while (true) {
64+
const name = parseOperatorName(currentOperators)
65+
if (!name) {
66+
break
67+
}
68+
69+
const right = parseOperator(precedenceLevel + 1)
70+
left = [name, left, right]
71+
72+
skipWhitespace()
73+
}
74+
75+
return left
76+
}
77+
78+
const parseOperatorName = (currentOperators: Record<string, string>): string | undefined => {
5879
// we sort the operators from longest to shortest, so we first handle "<=" and next "<"
80+
const sortedOperatorNames = Object.keys(currentOperators).sort((a, b) => b.length - a.length)
81+
5982
for (const name of sortedOperatorNames) {
60-
const op = allOperators[name]
83+
const op = currentOperators[name]
6184
if (query.substring(i, i + op.length) === op) {
6285
i += op.length
86+
6387
skipWhitespace()
64-
const right = parseParenthesis()
6588

66-
return [name, left, right]
89+
return name
6790
}
6891
}
6992

70-
return left
93+
return undefined
7194
}
7295

7396
const parseParenthesis = () => {

src/stringify.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,10 @@ const DEFAULT_INDENTATION = ' '
3333
*/
3434
export const stringify = (query: JSONQuery, options?: JSONQueryStringifyOptions) => {
3535
const space = options?.indentation ?? DEFAULT_INDENTATION
36+
const allOperators =
37+
// biome-ignore lint/performance/noAccumulatingSpread: <explanation>
38+
[...operators, ...(options?.operators ?? [])].reduce((all, ops) => ({ ...all, ...ops }), {}) ??
39+
{}
3640

3741
const _stringify = (query: JSONQuery, indent: string) =>
3842
isArray(query) ? stringifyFunction(query as JSONQueryFunction, indent) : JSON.stringify(query) // value (string, number, boolean, null)
@@ -64,7 +68,7 @@ export const stringify = (query: JSONQuery, options?: JSONQueryStringifyOptions)
6468
}
6569

6670
// operator like ".age >= 18"
67-
const op = options?.operators?.[name] ?? operators[name]
71+
const op = allOperators[name]
6872
if (op && args.length === 2) {
6973
const [left, right] = args
7074
const leftStr = _stringify(left, indent)

src/types.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,22 +10,22 @@ export type JSONQueryProperty = ['get', path?: string | JSONPath]
1010

1111
export interface JSONQueryOptions {
1212
functions?: FunctionBuildersMap
13-
operators?: Record<string, string>
13+
operators?: Record<string, string>[]
1414
}
1515

1616
export interface JSONQueryCompileOptions {
1717
functions?: FunctionBuildersMap
1818
}
1919

2020
export interface JSONQueryStringifyOptions {
21-
operators?: Record<string, string>
21+
operators?: Record<string, string>[]
2222
maxLineLength?: number
2323
indentation?: string
2424
}
2525

2626
export interface JSONQueryParseOptions {
2727
functions?: Record<string, boolean> | FunctionBuildersMap
28-
operators?: Record<string, string>
28+
operators?: Record<string, string>[]
2929
}
3030

3131
export type Fun = (data: unknown) => unknown

test-suite/parse.test.json

Lines changed: 24 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -163,14 +163,33 @@
163163
},
164164
{
165165
"category": "operator",
166-
"description": "should throw an error when using multiple operators without parenthesis",
166+
"description": "should parse operators and and or with more than two arguments",
167167
"tests": [
168-
{ "input": ".a == \"A\" and .b == \"B\"", "throws": "Unexpected part 'and .b == \"B\"'" },
169168
{
170-
"input": "2 and 3 and 4",
171-
"throws": "Unexpected part 'and 4' (pos: 8)"
169+
"input": "1 and 2 and 3",
170+
"output": ["and", ["and", 1, 2], 3]
172171
},
173-
{ "input": ".a + 2 * 3", "throws": "Unexpected part '* 3' (pos: 7)" }
172+
{
173+
"input": "1 or 2 or 3",
174+
"output": ["or", ["or", 1, 2], 3]
175+
},
176+
{
177+
"input": "1 or 2 or 3 or 4",
178+
"output": ["or", ["or", ["or", 1, 2], 3], 4]
179+
}
180+
]
181+
},
182+
{
183+
"category": "operator",
184+
"description": "should parse operators with the right precedence",
185+
"tests": [
186+
{ "input": "1 + 2 * 3", "output": ["add", 1, ["multiply", 2, 3]] },
187+
{ "input": "2 * 3 + 1", "output": ["add", ["multiply", 2, 3], 1] },
188+
{ "input": "1 * 2 / 3", "output": ["divide", ["multiply", 1, 2], 3] },
189+
{ "input": "1 + 2 - 3", "output": ["subtract", ["add", 1, 2], 3] },
190+
{ "input": "1 and 2 and 3", "output": ["and", ["and", 1, 2], 3] },
191+
{ "input": "1 and 2 or 3", "output": ["or", ["and", 1, 2], 3] },
192+
{ "input": "3 or 1 and 2", "output": ["or", 3, ["and", 1, 2]] }
174193
]
175194
},
176195
{

test-suite/stringify.test.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,7 @@
4848
"category": "operator",
4949
"description": "should stringify a custom operator",
5050
"options": {
51-
"operators": { "aboutEq": "~=" }
51+
"operators": [{ "aboutEq": "~=" }]
5252
},
5353
"tests": [
5454
{ "input": ["aboutEq", 2, 3], "output": "(2 ~= 3)" },

test-suite/stringify.test.schema.json

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,10 @@
1818
"properties": {
1919
"indentation": { "type": "string" },
2020
"maxLineLength": { "type": "number" },
21-
"operators": { "type": "object" }
21+
"operators": {
22+
"type": "array",
23+
"items": { "type": "object" }
24+
}
2225
}
2326
},
2427
"tests": {

0 commit comments

Comments
 (0)