Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
51 commits
Select commit Hold shift + click to select a range
d7d2cdf
feat: implement support for operator precedence (WIP)
josdejong Mar 3, 2025
2f430f1
chore: add a fixme
josdejong Mar 3, 2025
6396c5b
chore: minor simplification
josdejong Mar 3, 2025
caa6836
feat: update docs about operators
josdejong Mar 4, 2025
c708318
feat: option `operators` overwrites the default operators instead of …
josdejong Mar 4, 2025
7354377
chore: fix linting issues
josdejong Mar 4, 2025
0ccd2dc
feat: change option `functions` to overwrite the default functions in…
josdejong Mar 4, 2025
8ea251f
docs: update the libary size in the README.md
josdejong Mar 4, 2025
a3aa78a
chore: remove a redundant `skipWhitespace()`
josdejong Mar 4, 2025
f9f5106
feat: function `stringify` only wraps an operator in parentheses when…
josdejong Mar 4, 2025
60a6693
feat: align the operator precedence with that of JavaScript
josdejong Mar 5, 2025
afa6b1b
chore: reverse the order of the operator list so it is from highest t…
josdejong Mar 5, 2025
77f949f
feat: implement API to insert custom operators
josdejong Mar 5, 2025
18903e6
chore: use a more meaningful `customFn` in a unit test
josdejong Mar 7, 2025
5d079ef
chore: move `operators` to the file `operators.ts` and rename `consta…
josdejong Mar 7, 2025
fe1c806
feat: rename `above` and `below` into `before` and `after`
josdejong Mar 7, 2025
c021d32
docs: describe the new operators API
josdejong Mar 7, 2025
b773f2e
docs: some refinements in the README.md
josdejong Mar 7, 2025
0591863
chore: change the `updated` field in the test suite to a semver `vers…
josdejong Mar 11, 2025
4abbcab
chore: write unit tests for operator precedence
josdejong Mar 11, 2025
dfb4e81
chore: improve invalid custom operator error message
josdejong Mar 11, 2025
e99718e
chore: improve invalid custom operators error message
josdejong Mar 11, 2025
b47d381
chore: fix linting issue
josdejong Mar 11, 2025
c02beaf
docs: update the year in the LICENSE.md
josdejong Mar 11, 2025
1d74f14
chore: work out more operator precedence tests
josdejong Mar 11, 2025
3fd94de
chore: fix typos in test descriptions
josdejong Mar 12, 2025
970112f
feat: implement vararg operators (WIP)
josdejong Mar 12, 2025
2e9a2d3
docs: describe support for multiple values in operators
josdejong Mar 13, 2025
9268a03
chore: write unit tests for the functions that throw a "Too many argu…
josdejong Mar 13, 2025
a337aa1
feat: support chaining for operator mod
josdejong Mar 13, 2025
8ec3c98
docs: create a table describing operator precedence and associativity
josdejong Mar 13, 2025
40ec8d5
feat: implement support for configuration which functions support vararg
josdejong Mar 13, 2025
f23f267
feat: implement configuration for operators supporting vararg
josdejong Mar 18, 2025
b4d9f2b
fix: custom functions overriding the built-in functions
josdejong Mar 25, 2025
6b76993
fix: some issues in stringifying operators with parenthesis
josdejong Mar 25, 2025
26be480
chore: move the parenthesis logic to `args.map(...)`
josdejong Mar 25, 2025
14a8f4b
fix: some fixes and additional tests for stringifying parenthesis
josdejong Apr 4, 2025
9f31731
chore: refactor determining whether to add parenthesis when stringifying
josdejong Apr 4, 2025
568bc05
feat: make the parenthesis behavior of parse and stringify simpler an…
josdejong Apr 4, 2025
04d646a
feat: create a browser test to experiment with parse/stringify
josdejong Apr 4, 2025
7408cd6
feat: implement left associative operators and a corresponding option
josdejong Apr 4, 2025
0233a2d
docs: update the function reference
josdejong Apr 4, 2025
4a6ad69
fix: pipe operator has wrong precedence
josdejong Apr 5, 2025
8a51048
Merge branch 'develop' into feat/extend-functions-and-or
josdejong Apr 8, 2025
14d1ed5
fix: specify JSON Schema version and test file version in the test suite
josdejong Apr 8, 2025
9369c81
fix: issues in stringify adding parenthesis when needed
josdejong Apr 8, 2025
e0588de
fix: move custom operator tests from Test Suite to JS tests
josdejong Apr 8, 2025
50302c1
chore: add a unit test for `and`
josdejong Apr 14, 2025
e097e1c
docs: fix a broken link
josdejong Apr 15, 2025
2271a6e
chore: make passing precedenceLevel optional
josdejong Apr 15, 2025
0f40ce0
chore: rename an argument
josdejong Apr 15, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion LICENSE.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
The ISC License

Copyright (c) 2024 by Jos de Jong
Copyright (c) 2024-2025 by Jos de Jong

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.

Expand Down
44 changes: 29 additions & 15 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ Try it out on the online playground: <https://jsonquerylang.org>

## Features

- Small: just `3.3 kB` when minified and gzipped! The JSON query engine without parse/stringify is only `1.7 kB`.
- Small: just `3.7 kB` when minified and gzipped! The JSON query engine without parse/stringify is only `1.7 kB`.
- Feature rich (50+ powerful functions and operators)
- Easy to interoperate with thanks to the intermediate JSON format.
- Expressive
Expand Down Expand Up @@ -152,22 +152,42 @@ Here:
}
```

You can have a look at the source code of the functions in `/src/functions.ts` for more examples.
- `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:
You can have a look at the source code of the functions in [`/src/functions.ts`](/src/functions.ts) for more examples.

- `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`):

```ts
type CustomOperator =
| { name: string; op: string; at: string; vararg?: boolean, leftAssociative?: boolean }
| { name: string; op: string; after: string; vararg?: boolean, leftAssociative?: boolean }
| { name: string; op: string; before: string; vararg?: boolean, leftAssociative?: boolean }
```

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:

```js
import { buildFunction } from 'jsonquery'
import { buildFunction } from '@jsonquerylang/jsonquery'

const options = {
operators: {
notEqual: '<>'
},
// Define a new function "notEqual".
functions: {
notEqual: buildFunction((a, b) => a !== b)
}
},

// Define a new operator "<>" which maps to the function "notEqual"
// and has the same precedence as operator "==".
operators: [
{ name: 'aboutEq', op: '~=', at: '==' }
]
}
```

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`.

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]`.

All build-in operators and their precedence are listed on the documentation page in the section [Operators](https://jsonquerylang.org/docs/#operators).

Here an example of using the function `jsonquery`:

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

```ts
const options = {
operators: {
notEqual: '<>'
},
functions: {
notEqual: (a: JSONQuery, b: JSONQuery) => {
const aCompiled = compile(a)
Expand All @@ -286,9 +303,6 @@ To automatically compile and evaluate the arguments of the function, the helper
import { jsonquery, buildFunction } from '@jsonquerylang/jsonquery'

const options = {
operators: {
notEqual: '<>'
},
functions: {
notEqual: buildFunction((a: number, b: number) => a !== b)
}
Expand Down
27 changes: 19 additions & 8 deletions src/compile.test.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,16 @@
import Ajv from 'ajv'
import { describe, expect, test } from 'vitest'
import type { CompileTestSuite } from '../test-suite/compile.test'
import type { CompileTestException, CompileTestSuite } from '../test-suite/compile.test'
import suite from '../test-suite/compile.test.json'
import schema from '../test-suite/compile.test.schema.json'
import { compile } from './compile'
import { buildFunction } from './functions'
import type { JSONQuery, JSONQueryCompileOptions } from './types'

function isTestException(test: unknown): test is CompileTestException {
return !!test && typeof (test as Record<string, unknown>).throws === 'string'
}

const data = [
{ name: 'Chris', age: 23, city: 'New York' },
{ name: 'Emily', age: 19, city: 'Atlanta' },
Expand All @@ -31,13 +35,20 @@ const testsByCategory = groupByCategory(suite.tests) as Record<string, CompileTe
for (const [category, tests] of Object.entries(testsByCategory)) {
describe(category, () => {
for (const currentTest of tests) {
const { description, input, query, output } = currentTest

test(description, () => {
const actualOutput = compile(query)(input)

expect({ input, query, output: actualOutput }).toEqual({ input, query, output })
})
if (isTestException(currentTest)) {
test(currentTest.description, () => {
const { input, query, throws } = currentTest

expect(() => compile(query)(input)).toThrow(throws)
})
} else {
test(currentTest.description, () => {
const { input, query, output } = currentTest
const actualOutput = compile(query)(input)

expect({ input, query, output: actualOutput }).toEqual({ input, query, output })
})
}
}
})
}
Expand Down
7 changes: 4 additions & 3 deletions src/functions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -247,8 +247,8 @@ export const functions: FunctionBuildersMap = {

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

and: buildFunction((a, b) => !!(a && b)),
or: buildFunction((a, b) => !!(a || b)),
and: buildFunction((...args) => args.reduce((a, b) => !!(a && b))),
or: buildFunction((...args) => args.reduce((a, b) => !!(a || b))),
not: buildFunction((a: unknown) => !a),

exists: (queryGet: JSONQueryFunction) => {
Expand Down Expand Up @@ -297,8 +297,9 @@ export const functions: FunctionBuildersMap = {
subtract: buildFunction((a: number, b: number) => a - b),
multiply: buildFunction((a: number, b: number) => a * b),
divide: buildFunction((a: number, b: number) => a / b),
pow: buildFunction((a: number, b: number) => a ** b),
mod: buildFunction((a: number, b: number) => a % b),
pow: buildFunction((a: number, b: number) => a ** b),

abs: buildFunction(Math.abs),
round: buildFunction((value: number, digits = 0) => {
const num = Math.round(Number(`${value}e${digits}`))
Expand Down
15 changes: 8 additions & 7 deletions src/jsonquery.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,25 +41,26 @@ describe('jsonquery', () => {

test('should execute a JSON query with custom operators', () => {
const options: JSONQueryOptions = {
operators: {
aboutEq: '~='
functions: {
aboutEq: buildFunction((a: string, b: string) => a.toLowerCase() === b.toLowerCase())
}
}

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

test('should execute a text query with custom operators', () => {
const options: JSONQueryOptions = {
operators: {
aboutEq: '~='
operators: [{ name: 'aboutEq', op: '~=', at: '==' }],
functions: {
aboutEq: buildFunction((a: string, b: string) => a.toLowerCase() === b.toLowerCase())
}
}

expect(jsonquery({ name: 'Joe' }, '.name', options)).toEqual('Joe')
expect(jsonquery({ name: 'Joe' }, '.name ~= "joe"', options)).toEqual(true)
})

test('have exported all documented functions', () => {
test('have exported all documented functions and objects', () => {
expect(jsonquery).toBeTypeOf('function')
expect(parse).toBeTypeOf('function')
expect(stringify).toBeTypeOf('function')
Expand Down
44 changes: 44 additions & 0 deletions src/operators.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import { describe, expect, test } from 'vitest'
import { extendOperators } from './operators'

describe('operators', () => {
test('should extend operators (at)', () => {
const ops = [{ add: '+', subtract: '-' }, { eq: '==' }]

expect(extendOperators(ops, [{ name: 'aboutEq', op: '~=', at: '==' }])).toEqual([
{ add: '+', subtract: '-' },
{ eq: '==', aboutEq: '~=' }
])
})

test('should extend operators (after)', () => {
const ops = [{ add: '+', subtract: '-' }, { eq: '==' }]

expect(extendOperators(ops, [{ name: 'aboutEq', op: '~=', after: '+' }])).toEqual([
{ add: '+', subtract: '-' },
{ aboutEq: '~=' },
{ eq: '==' }
])
})

test('should extend operators (before)', () => {
const ops = [{ add: '+', subtract: '-' }, { eq: '==' }]

expect(extendOperators(ops, [{ name: 'aboutEq', op: '~=', before: '==' }])).toEqual([
{ add: '+', subtract: '-' },
{ aboutEq: '~=' },
{ eq: '==' }
])
})

test('should extend operators (multiple consecutive)', () => {
const ops = [{ add: '+', subtract: '-' }, { eq: '==' }]

expect(
extendOperators(ops, [
{ name: 'first', op: 'op1', before: '==' },
{ name: 'second', op: 'op2', before: 'op1' }
])
).toEqual([{ add: '+', subtract: '-' }, { second: 'op2' }, { first: 'op1' }, { eq: '==' }])
})
})
46 changes: 46 additions & 0 deletions src/operators.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import { isArray } from './is'
import type { CustomOperator, OperatorGroup } from './types'

// operator precedence from highest to lowest
export const operators: OperatorGroup[] = [
{ pow: '^' },
{ multiply: '*', divide: '/', mod: '%' },
{ add: '+', subtract: '-' },
{ gt: '>', gte: '>=', lt: '<', lte: '<=', in: 'in', 'not in': 'not in' },
{ eq: '==', ne: '!=' },
{ and: 'and' },
{ or: 'or' },
{ pipe: '|' }
]

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

export function extendOperators(operators: OperatorGroup[], customOperators: CustomOperator[]) {
// backward compatibility error with v4 where `operators` was an object
if (!isArray(customOperators)) {
throw new Error('Invalid custom operators')
}

return customOperators.reduce(extendOperator, operators)
}

function extendOperator(
operators: OperatorGroup[],
// @ts-expect-error Inside the function we will check whether at, below, and above are defined
{ name, op, at, after, before }: CustomOperator
): OperatorGroup[] {
if (at) {
return operators.map((group) => {
return Object.values(group).includes(at) ? { ...group, [name]: op } : group
})
}

const searchOp = after ?? before
const index = operators.findIndex((group) => Object.values(group).includes(searchOp))
if (index !== -1) {
return operators.toSpliced(index + (after ? 1 : 0), 0, { [name]: op })
}

throw new Error('Invalid custom operator')
}
58 changes: 55 additions & 3 deletions src/parse.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,18 +43,70 @@ for (const [category, testGroups] of Object.entries(testsByCategory)) {
describe('customization', () => {
test('should parse a custom function', () => {
const options: JSONQueryParseOptions = {
functions: { customFn: true }
functions: { customFn: () => () => 42 }
}

expect(parse('customFn(.age, "desc")', options)).toEqual(['customFn', ['get', 'age'], 'desc'])

// built-in functions should still be available
expect(parse('add(2, 3)', options)).toEqual(['add', 2, 3])
})

test('should parse a custom operator', () => {
test('should parse a custom operator without vararg', () => {
const options: JSONQueryParseOptions = {
operators: { aboutEq: '~=' }
operators: [{ name: 'aboutEq', op: '~=', at: '==' }]
}

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

// built-in operators should still be available
expect(parse('.score == 8', options)).toEqual(['eq', ['get', 'score'], 8])

expect(() => parse('2 ~= 3 ~= 4', options)).toThrow("Unexpected part '~= 4'")
})

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

expect(parse('2 and 3 and 4', options)).toEqual(['and', 2, 3, 4])
expect(parse('2 ~= 3', options)).toEqual(['aboutEq', 2, 3])
expect(parse('2 ~= 3 and 4', options)).toEqual(['and', ['aboutEq', 2, 3], 4])
expect(parse('2 and 3 ~= 4', options)).toEqual(['and', 2, ['aboutEq', 3, 4]])
expect(parse('2 == 3 ~= 4', options)).toEqual(['aboutEq', ['eq', 2, 3], 4])
expect(parse('2 ~= 3 == 4', options)).toEqual(['eq', ['aboutEq', 2, 3], 4])
expect(() => parse('2 ~= 3 ~= 4', options)).toThrow("Unexpected part '~= 4'")
expect(() => parse('2 == 3 == 4', options)).toThrow("Unexpected part '== 4'")
})

test('should parse a custom operator with vararg with leftAssociative', () => {
const options: JSONQueryParseOptions = {
operators: [{ name: 'aboutEq', op: '~=', at: '==', vararg: true, leftAssociative: true }]
}

expect(parse('2 and 3 and 4', options)).toEqual(['and', 2, 3, 4])
expect(parse('2 ~= 3', options)).toEqual(['aboutEq', 2, 3])
expect(parse('2 ~= 3 ~= 4', options)).toEqual(['aboutEq', 2, 3, 4])
expect(() => parse('2 == 3 == 4', options)).toThrow("Unexpected part '== 4'")
})

test('should throw an error in case of an invalid custom operator', () => {
const options: JSONQueryParseOptions = {
// @ts-ignore
operators: [{}]
}

expect(() => parse('.score > 8', options)).toThrow('Invalid custom operator')
})

test('should throw an error in case of an invalid custom operator (2)', () => {
const options: JSONQueryParseOptions = {
// @ts-ignore
operators: {}
}

expect(() => parse('.score > 8', options)).toThrow('Invalid custom operators')
})
})

Expand Down
Loading