Skip to content
Merged
Show file tree
Hide file tree
Changes from 10 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
94 changes: 71 additions & 23 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.4 kB` when minified and gzipped! The JSON query engine without parse/stringify is only `1.9 kB`.
- Feature rich (50+ powerful functions and operators)
- Easy to interoperate with thanks to the intermediate JSON format.
- Expressive
Expand Down Expand Up @@ -92,10 +92,11 @@ jsonquery(data, [
The build in functions can be extended with custom functions, like `times` in the following example:

```js
import { jsonquery } from '@jsonquerylang/jsonquery'
import { jsonquery, functions } from '@jsonquerylang/jsonquery'

const options = {
functions: {
...functions,
times: (value) => (data) => data.map((item) => item * value)
}
}
Expand Down Expand Up @@ -136,7 +137,7 @@ The following table gives an overview of the JSON query text format:
| Type | Syntax | Example |
|-------------------------|------------------------------------------------------------------------------------|-----------------------------------------------------------------------------------------|
| [Function](#functions) | `name(argument1, argument2, ...)` | `sort(.age, "asc")` |
| [Operator](#operators) | `(left operator right)` | `filter(.age >= 18)` |
| [Operator](#operators) | `left operator right` | `filter(.age >= 18)` |
| [Pipe](#pipes) | <code>query1 &#124; query2 &#124; ...</code> | <code>sort(.age) &#124; pick(.name, .age)</code> |
| [Object](#objects) | `{ prop1: query1, prop2: query2, ... }` | `{ names: map(.name), total: sum() }` |
| [Array](#arrays) | `[ item1, item2, ... ]` | `[ "New York", "Atlanta" ]` |
Expand Down Expand Up @@ -184,16 +185,16 @@ See section [Function reference](reference/functions.md) for a detailed overview

### Operators

JSON Query supports all basic operators. Operators must be wrapped in parentheses `(...)`, must have both a left and right hand side, and do not have precedence since parentheses are required. The syntax is:
JSON Query supports all basic operators. Operators must have both a left and right hand side. To override the default precedence, an operator can be wrapped in parentheses `(...)`. The syntax is:

```text
(left operator right)
left operator right
```

The following example tests whether a property `age` is greater than or equal to `18`:

```text
(.age >= 18)
.age >= 18
```

Operators are for example used to specify filter conditions:
Expand All @@ -202,12 +203,22 @@ Operators are for example used to specify filter conditions:
filter(.age >= 18)
```

When composing multiple operators, it is necessary to use parentheses:
When using multiple operators, they will be evaluated according to their precedence (highest first):

```text
filter((.age >= 18) and (.age <= 65))
filter(.age >= 18 and .age <= 65)
```

The operators have the following precedence, from lowest to highest:

- `in`, `not in`
- `or`
- `and`
- `==`, `>`, `>=`, `<`, `<=`, `!=`
- `+`, `-`
- `*`, `/`, `%`
- `^`

See section [Function reference](reference/functions.md) for a detailed overview of all available functions and operators.

### Pipes
Expand Down Expand Up @@ -358,22 +369,36 @@ Here:
- `data` is the JSON document that will be queried, often an array with objects.
- `query` is a JSON document containing a JSON query, either the text format or the parsed JSON format.
- `options` is an optional object that can contain the following properties:
- `functions` is an optional map with custom function creators. A function creator has optional arguments as input and must return a function that can be used to process the query data. For example:
- `functions` is an optional map with function creators. A function creator has optional arguments as input and must return a function that can be used to process the query data. For example:

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

const options = {
functions: {
// keep all built-in functions
...functions,

// define a new custom function "times"
// usage example: 'times(3)'
times: (value) => (data) => data.map((item) => item * value)
}
}
```

Note that configuring the option `functions` will overwrite the default functions. In order to extend the existing functions it is necessary to import the build-in functions and extend the custom `functions` object with them as in the example above.

If the parameters are not a static value but can be a query themselves, the function `compile` can be used to compile them. For example, the actual implementation of the function `filter` is the following:

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

const options = {
functions: {
// keep all built-in functions
...functions,

// overwrite function "filter" with our own implementation
// usage example: 'filter(.age > 20)'
filter: (predicate) => {
const _predicate = compile(predicate)
Expand All @@ -384,18 +409,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:

- `operators` is an optional array with maps of operators having the same precedence, ordered from lowest to highest precedence. The default array with operators is the following, with the precedence going from lowest to highest:

```js
const operators = [
{ in: 'in', 'not in': 'not in' },
{ or: 'or' },
{ and: 'and' },
{ eq: '==', gt: '>', gte: '>=', lt: '<', lte: '<=', ne: '!=' },
{ add: '+', subtract: '-' },
{ multiply: '*', divide: '/', mod: '%' },
{ pow: '^' }
]
```

When extending the built-in operators with a custom operator, the built-in operators can be imported via `import { operators } from '@jsonquerylang/jsonquery'` and then extended by mapping over them and adding the custom operator to the group with the right precedence level (see example below), or adding a new precedence level if needed.

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, functions, operators } from '@jsonquerylang/jsonquery'

const options = {
operators: {
notEqual: '<>'
},
// Define a new function "notEqual".
functions: {
...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: operators.map((group) => {
return Object.values(group).includes('==')
? { ...group, notEqual: '<>' }
: group
})
}
```

Expand Down Expand Up @@ -488,11 +537,12 @@ The function `buildFunction` is a helper function to create a custom function. I
The query engine passes the raw arguments to all functions, and the functions have to compile the arguments themselves when they are dynamic. For example:

```ts
import { functions } from '@jsonquerylang/jsonquery'

const options = {
operators: {
notEqual: '<>'
},
functions: {
...functions,

notEqual: (a: JSONQuery, b: JSONQuery) => {
const aCompiled = compile(a)
const bCompiled = compile(b)
Expand All @@ -514,13 +564,11 @@ const result = jsonquery(data, '(.x + .y) <> 6', options) // true
To automatically compile and evaluate the arguments of the function, the helper function `buildFunction` can be used:

```ts
import { jsonquery, buildFunction } from '@jsonquerylang/jsonquery'
import { jsonquery, functions, buildFunction } from '@jsonquerylang/jsonquery'

const options = {
operators: {
notEqual: '<>'
},
functions: {
...functions,
notEqual: buildFunction((a: number, b: number) => a !== b)
}
}
Expand Down
2 changes: 1 addition & 1 deletion src/compile.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import type {
const functionsStack: FunctionBuildersMap[] = []

export function compile(query: JSONQuery, options?: JSONQueryCompileOptions): Fun {
functionsStack.unshift({ ...functions, ...functionsStack[0], ...options?.functions })
functionsStack.unshift({ ...functionsStack[0], ...(options?.functions ?? functions) })

try {
const exec = isArray(query)
Expand Down
31 changes: 10 additions & 21 deletions src/constants.ts
Original file line number Diff line number Diff line change
@@ -1,24 +1,13 @@
export const operators = {
and: 'and',
or: 'or',

eq: '==',
gt: '>',
gte: '>=',
lt: '<',
lte: '<=',
ne: '!=',

add: '+',
subtract: '-',
multiply: '*',
divide: '/',
pow: '^',
mod: '%',

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

export const unquotedPropertyRegex = /^[a-zA-Z_$][a-zA-Z\d_$]*$/
export const startsWithUnquotedPropertyRegex = /^[a-zA-Z_$][a-zA-Z\d_$]*/
Expand Down
19 changes: 13 additions & 6 deletions src/jsonquery.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,9 @@ import {
type JSONQueryOptions,
buildFunction,
compile,
functions,
jsonquery,
operators,
parse,
stringify
} from './jsonquery'
Expand Down Expand Up @@ -32,6 +34,7 @@ describe('jsonquery', () => {
test('should execute a text query with custom functions', () => {
const options: JSONQueryOptions = {
functions: {
...functions,
customFn: () => (_data: unknown) => 42
}
}
Expand All @@ -41,22 +44,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: operators.map((group) => {
return Object.values(group).includes('==') ? { ...group, aboutEq: '~=' } : group
}),
functions: {
...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', () => {
Expand Down
3 changes: 2 additions & 1 deletion src/jsonquery.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ export function jsonquery(
export { compile } from './compile'
export { stringify } from './stringify'
export { parse } from './parse'
export { buildFunction } from './functions'
export { buildFunction, functions } from './functions'
export { operators } from './constants'

export * from './types'
6 changes: 5 additions & 1 deletion src/parse.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import type { ParseTestException, ParseTestSuite } from '../test-suite/parse.tes
import suite from '../test-suite/parse.test.json'
import schema from '../test-suite/parse.test.schema.json'
import { compile } from './compile'
import { operators } from './constants'
import { parse } from './parse'
import type { JSONQueryParseOptions } from './types'

Expand Down Expand Up @@ -51,10 +52,13 @@ describe('customization', () => {

test('should parse a custom operator', () => {
const options: JSONQueryParseOptions = {
operators: { aboutEq: '~=' }
operators: operators.map((group) =>
Object.values(group).includes('==') ? { ...group, aboutEq: '~=' } : group
)
}

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

Expand Down
Loading