diff --git a/.gitignore b/.gitignore index 8c71477..ee5776d 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ node_modules lib +coverage .idea \ No newline at end of file diff --git a/.prettierignore b/.prettierignore deleted file mode 100644 index 32dd0d7..0000000 --- a/.prettierignore +++ /dev/null @@ -1,2 +0,0 @@ -README.md -reference \ No newline at end of file diff --git a/.prettierrc b/.prettierrc deleted file mode 100644 index 568b426..0000000 --- a/.prettierrc +++ /dev/null @@ -1,8 +0,0 @@ -{ - "useTabs": false, - "tabWidth": 2, - "singleQuote": true, - "semi": false, - "trailingComma": "none", - "printWidth": 100 -} diff --git a/README.md b/README.md index aab20c8..30b61d3 100644 --- a/README.md +++ b/README.md @@ -6,14 +6,13 @@ A small, flexible, and expandable JSON query language. Try it out on the online playground: -![JSON Query Overview](https://jsonquerylang.org/jsonquery-overview.svg) +![JSON Query Overview](docs/jsonquery-overview.svg) ## Features -- Small (just `1.4 kB` when minified and gzipped!) -- Feature rich (40+ powerful functions and operators) -- Serializable (it is JSON) -- Easy to parse +- Small: just `2.9 kB` when minified and gzipped! The JSON query engine without parse/stringify is only `1.3 kB`. +- Feature rich (40+ powerful functions) +- Easy to interoperate with thanks to the intermediate JSON format. - Expressive - Expandable @@ -24,6 +23,7 @@ On this page: - [Installation](#installation) - [Usage](#usage) - [Syntax](#syntax) +- [JSON Format](#json-format) - [JavaScript API](#javascript-api) - [Gotchas](#gotchas) - [Development](#development) @@ -36,7 +36,7 @@ External pages: ## Installation -``` +```text npm install @jsonquerylang/jsonquery ``` @@ -59,12 +59,12 @@ const data = { // get the array containing the friends from the object, filter the friends that live in New York, // sort them by age, and pick just the name and age out of the objects. -const names = jsonquery(data, [ - ["get", "friends"], - ["filter", ["eq", ["get", "city"], "New York"]], - ["sort", ["get", "age"]], - ["pick", ["get", "name"], ["get", "age"]] -]) +const names = jsonquery(data, ` + .friends + | filter(.city == "New York") + | sort(.age) + | pick(.name, .age) +`) // names = [ // { "name": "Chris", "age": 23 }, // { "name": "Sarah", "age": 31 }, @@ -75,17 +75,13 @@ const names = jsonquery(data, [ // properties `names`, `count`, and `averageAge` containing the results of their query: // a list with names, the total number of array items, and the average value of the // properties `age` in all items. -const result = jsonquery(data, [ - ["get", "friends"], - { - "names": ["map", ["get", "name"]], - "count": ["size"], - "averageAge": [ - ["map", ["get", "age"]], - ["average"] - ] +const result = jsonquery(data, ` + .friends | { + names: map(.name), + count: size(), + averageAge: map(.age) | average() } -]) +`) // result = { // "names": ["Chris", "Emily", "Joe", "Kevin", "Michelle", "Robert", "Sarah"], // "count": 7, @@ -97,10 +93,7 @@ const shoppingCart = [ { "name": "bread", "price": 2.5, "quantity": 2 }, { "name": "milk", "price": 1.2, "quantity": 3 } ] -const totalPrice = jsonquery(shoppingCart, [ - ["map", ["multiply", ["get", "price"], ["get", "quantity"]]], - ["sum"] -]) +const totalPrice = jsonquery(shoppingCart, 'map(.price * .quantity) | sum()') // totalPrice = 8.6 ``` @@ -109,18 +102,42 @@ The build in functions can be extended with custom functions, like `times` in th ```js import { jsonquery } from '@jsonquerylang/jsonquery' -const customFunctions = { - times: (value) => (data) => data.map((item) => item * value) +const options = { + functions: { + times: (value) => (data) => data.map((item) => item * value) + } } const data = [1, 2, 3] -const result = jsonquery(data, ["times", 3], customFunctions) +const result = jsonquery(data, 'times(3)', options) // [3, 6, 9] ``` ## Syntax -The `jsonquery` query language is written in JSON and has the following building blocks: _functions_, _pipes_, and _objects_. When writing a JSON Query, you compose a ["pipe"](https://medium.com/@efeminella/the-pipe-operator-a-glimpse-into-the-future-of-functional-javascript-7ebb578887a4) or a ["chain"](https://en.wikipedia.org/wiki/Method_chaining) of operations to be applied to the data. It resembles chaining like in [Lodash](https://lodash.com/docs/4.17.15#chain) or just [in JavaScript](https://medium.com/backticks-tildes/understanding-method-chaining-in-javascript-647a9004bd4f) itself using methods like `map` and `filter`. +The `jsonquery` language looks quite similar to JavaScript and other JSON query languages. This makes it easy to learn. When writing a query, you compose a ["pipe"](https://medium.com/@efeminella/the-pipe-operator-a-glimpse-into-the-future-of-functional-javascript-7ebb578887a4) or a ["chain"](https://en.wikipedia.org/wiki/Method_chaining) of operations to be applied to the data. It resembles chaining like in [Lodash](https://lodash.com/docs/4.17.15#chain) or just [in JavaScript](https://medium.com/backticks-tildes/understanding-method-chaining-in-javascript-647a9004bd4f) itself using methods like `map` and `filter`. + +Queries are written in a plain text format which is compact and easy to read for humans. The text format is parsed into an intermediate JSON format which is easy to operate on programmatically. This JSON format is executed by the query engine. + +The text format has functions, operators, property getters, pipes to execute multiple queries in series, and objects to execute multiple queries in parallel or transform the input. For example: + +```text +filter(.age >= 18) | sort(.age) +``` + +The text format can be converted (back and forth) into a JSON format consisting purely of composed function calls. A function call is described by an array containing the function name followed by its arguments, like `[name, arg1, arg2, ...]`. Here is the JSON equivalent of the previous example: + +```json +[ + "pipe", + ["filter", ["gte", ["get", "age"], 18]], + ["sort", ["get", "age"]] +] +``` + +The JSON format is mostly used under the hood. It allows for easy integrations like a GUI or executing the query in a different environment or language without having to implement a parser for the text format. Read more in the [JSON Format](#json-format) section. + +### Syntax overview The examples in the following section are based on querying the following data: @@ -135,102 +152,216 @@ The examples in the following section are based on querying the following data: { "name": "Sarah", "age": 31, "address": { "city": "New York" } } ] ``` -Syntax overview: -| Category | Syntax | Example | -|------------------------|-------------------------------------------|------------------------------------------------| -| [Function](#functions) | `[name, argument1, argument2, ...]` | `["sort", ["get", "age"], "asc"]` | -| [Pipe](#pipes) | `[query1, query1, ...]` | `[["sort", "age"], ["pick", "name", "age"]]` | -| [Object](#objects) | `{"prop1": query1, "prop2": query2, ...}` | `{"names": ["map", "name"], "total": ["sum"]}` | +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)` | +| [Pipe](#pipes) | query1 | query2 | ... | sort(.age) | pick(.name, .age) | +| [Object](#objects) | `{ prop1: query1, prop2: query2, ... }` | `{ names: map(.name), total: sum() }` | +| [Array](#arrays) | `[ item1, item2, ... ]` | `[ "New York", "Atlanta" ]` | +| [Property](#properties) | `.prop1`
`.prop1.prop2`
`."prop1"` | `.age`
`.address.city`
`."first name"` | +| [String](#values) | `"string"` | `"Hello world"` | +| [Number](#values) | A floating point number | `2.4` | +| [Boolean](#values) | `true` or `false` | `true` | +| [null](#values) | `null` | `null` | -The following sections explain the syntax in more detail. +The syntax is explained in details in the following sections. ### Functions -At the core of the query language, we have a _function_ call which described by an array with the function name as first item followed by optional function arguments. The following example will look up the `sort` function and then call it like `sort(data, (item) => item.age, 'asc')`. Here, `data` is the input and should be an array with objects which will be sorted in ascending by the property `age`: +Function calls have the same syntax as in most programming languages: -```json -["sort", ["get", "age"], "asc"] +```text +name(argument1, argument2, ...) ``` -An important function is the function `get`. It allows to get a property from an object: +The following example will `sort` the data in ascending order, sorted by the property `age`. -```json -["get", "age"] +```text +sort(.age, "asc") ``` -A nested property can be retrieved by specifying multiple properties. The following path for example describes the value of a nested property `city` inside an object `address`: +Important to understand is that the functions are executed as a method in a chain: the sorting is applied to the data input, and forwarded to the next method in the chain (if any). The following example first filters the data, and next sorts it: -```json -["get", "address", "city"] +```text +filter(.age >= 21) | sort(.age, "asc") ``` -To get the current value itself, just specify `["get"]` without properties: +See section [Function reference](reference/functions.md) for a detailed overview of all available functions and operators. -```json -["multiply", ["get"], 2] +### 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: + +```text +(left operator right) +``` + +The following example tests whether a property `age` is greater than or equal to `18`: + +```text +(.age >= 18) ``` -See section [Function reference](reference/functions.md) for a detailed overview of all available functions. +Operators are for example used to specify filter conditions: + +```text +filter(.age >= 18) +``` + +When composing multiple operators, it is necessary to use parentheses: + +```text +filter((.age >= 18) and (.age <= 65)) +``` + +See section [Function reference](reference/functions.md) for a detailed overview of all available functions and operators. ### Pipes -A _pipe_ is an array containing a series of _functions_, _objects_, or _pipes_. The entries in the pipeline are executed one by one, and the output of the first is the input for the next. The following example will first filter the items of an array that have a nested property `city` in the object `address` with the value `"New York"`, and next, sort the filtered items by the property `age`: +A _pipe_ is a series of multiple query operations separated by a pipe character `|`. The syntax is: -```json -[ - ["filter", ["eq", ["get" ,"address", "city"], "New York"]], - ["sort", ["get" ,"age"]] -] +```text +query1 | query2 | ... +``` + +The entries in the pipeline are executed one by one, and the output of the first is the input for the next. The following example will first filter the items of an array that have a nested property `city` in the object `address` with the value `"New York"`, and next, sort the filtered items by the property `age`: + +```text +filter(.address.city == "New York") | sort(.age) ``` ### Objects -An _object_ is defined as a regular JSON object with a property name as key, and a _function_, _pipe_, or _object_ as value. Objects can be used to transform data or to execute multiple query pipelines in parallel. +An _object_ is defined as a regular JSON object with a property name as key, and query as value. Objects can be used to transform data or to execute multiple queries in parallel. -The following example will map over the items of the array and create a new object with properties `firstName` and `city` for every item: +```text +{ prop1: query1, prop2: query2, ... } +``` -```json -["map", { - "firstName": ["get", "name"], - "city": ["get", "address", "city"] -}] +The following example will transform the data by mapping over the items of the array and creating a new object with properties `firstName` and `city` for every item: + +```text +map({ + firstName: .name, + city: .address.city +}) ``` -The following example will output an object with properties `names`, `count`, and `averageAge` containing the results of their query: a list with names, the total number of array items, and the average value of the properties `age` in all items: +The following example runs multiple queries in parallel. It outputs an object with properties `names`, `count`, and `averageAge` containing the results of their query: a list with names, the total number of array items, and the average value of the properties `age` in all items: -```json +```text { - "names": ["map", ["get", "name"]], - "count": ["size"], - "averageAge": [ - ["map", ["get", "age"]], - ["average"] - ] + names: map(.name), + count: size(), + averageAge: map(.age) | average() } ``` +A property can be unquoted when it only contains characters `a-z`, `A-Z`, `_` and `$`, and all but the first character can be a number `0-9`. When the property contains other characters, like spaces, it needs to be enclosed in double quotes and escaped like JSON keys: + +```text +{ + "first name": map(.name) +} +``` + +### Arrays + +Arrays are defined like JSON arrays: enclosed in square brackets, with items separated by a comma: + +```text +[query1, query2, ...] +``` + +Arrays can for example be used for the operators `in` and `not in`: + +```text +filter(.city in ["New York", "Atlanta"]) +``` + +### Properties + +An important feature is the property getter. It allows to get a property from an object: + +```text +.age +``` + +A nested property can be retrieved by specifying multiple properties. The following path for example describes the value of a nested property `city` inside an object `address`: + +```text +.address.city +``` + +A property can be unquoted when it only contains characters `a-z`, `A-Z`, `_` and `$`, and all but the first character can be a number `0-9`. When the property contains other characters, like spaces, it needs to be enclosed in double quotes and escaped like JSON keys: + +```text +."first name" +``` + +To get the current value itself, use the function `get()` without arguments. + +### Values + +JSON Query supports the following primitive values, the same as in [JSON](https://www.json.org): `string`, `number`, `boolean`, `null`. + +| Type | Example | +|---------|-------------------------------------------------------------------| +| string | `"Hello world"`
`"Multi line text\nwith \"quoted\" contents"` | +| number | `42`
`2.74`
`-1.2e3`
| +| boolean | `true`
`false` | +| null | `null` | + +## JSON format + +The text format describe above can be converted into an intermediate JSON format consisting purely of composed function calls and vice versa. A function call is described by an array containing the function name followed by its arguments, like `[name, arg1, arg2, ...]`. The following table gives an overview of the text format and the equivalent JSON format. + +| Type | Text format | JSON format | +|----------|----------------------------------------------|---------------------------------------------------------------------------| +| Function | `name(argument1, argument2, ...)` | `["name", argument1, argument2, ...]` | +| Operator | `(left operator right)` | `["operator", left, right]` | +| Pipe | query1 | query2 | ... | `["pipe", query1, query2, ...]` | +| Object | `{ prop1: query1, prop2: query2, ... }` | `["object", { "prop1": query1, "prop2": query2, ... }]` | +| Array | `[ item1, item2, ... ]` | `["array", item1, item2, ... ]` | +| Property | `.prop1`
`.prop1.prop2`
`."prop1"` | `["get", "prop1"]`
`["get", "prop1", "prop2"]`
`["get", "prop1"]` | +| String | `"string"` | `"string"` | +| Number | A floating point number | A floating point number | +| Boolean | `true` or `false` | `true` or `false` | +| null | `null` | `null` | + ## JavaScript API +The library exports the following functions: + +- [`jsonquery`](#jsonquery) is the core function of the library, which parses, compiles, and evaluates a query in one go. +- [`compile`](#compile) to compile and evaluate a query. +- [`parse`](#parse) to parse a query in text format into JSON. +- [`stringify`](#stringify) to convert a query in JSON into the text format. +- [`buildFunction`](#buildfunction) a helper function to create a custom function. + ### jsonquery -The `jsonquery` library has one core function where you pass the data, the query, and optionally an object with custom functions to extend the built-in functions: +The function `jsonquery` allows to pass data and a query in one go and parse, compile and execute it: -``` -jsonquery(data: JSON, query: JSONQuery, options: JSONQueryOptions) : JSON +```text +jsonquery(data: JSON, query: string | JSONQuery, options: JSONQueryOptions) : JSON ``` Here: - `data` is the JSON document that will be queried, often an array with objects. -- `query` is a JSON document containing a JSON query as described in the section below. +- `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: ```js const options = { functions: { - // usage example: ["times", 3] + // usage example: 'times(3)' times: (value) => (data) => data.map((item) => item * value) } } @@ -241,16 +372,30 @@ Here: ```js const options = { functions: { - // usage example: ["filter", ["age", ">", 20 ]] + // usage example: 'filter(.age > 20)' filter: (predicate) => { const _predicate = compile(predicate) return (data) => data.filter(_predicate) } } - } + } ``` 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: + + ```js + import { buildFunction } from 'jsonquery' + + const options = { + operators: { + notEqual: '<>' + }, + functions: { + notEqual: buildFunction((a, b) => a !== b) + } + } + ``` Here an example of using the function `jsonquery`: @@ -272,9 +417,9 @@ const result = jsonquery(data, ["filter", ["gt", ["get", "age"], 20]]) ### compile -The JavaScript library also exports a `compile` function: +The compile function compiles and executes a query in JSON format. Function `parse` can be used to parse a text query into JSON before passing it to `compile`. -``` +```text compile(query: JSONQuery, options: JSONQueryOptions) => (data: JSON) => JSON ``` @@ -298,6 +443,90 @@ const result = queryIt(data) // ] ``` +### parse + +Function `parse` parses a query in text format into JSON. Function `stringify` can be used to do the opposite. + +```text +parse(query: text, options: JSONQueryParseOptions) : JSONQuery +``` + +Example: + +```js +import { parse } from '@jsonquerylang/jsonquery' + +const text = 'filter(.age > 20)' +const json = parse(text) +// json = ["filter", ["gt", ["get", "age"], 20]] +``` + +### stringify + +Function `stringify` turns a query in JSON format into the equivalent text format. Function `parse` can be used to parse the text into JSON again. + +```text +stringify(query: JSONQuery, options: JSONQueryStringifyOptions) : string +``` + +Example: + +```js +import { stringify } from '@jsonquerylang/jsonquery' + +const json = ["filter", ["gt", ["get", "age"], 20]] +const text = stringify(json) +// text = 'filter(.age > 20)' +``` + +### buildFunction + +The function `buildFunction` is a helper function to create a custom function. It can only be used for functions (mostly operators), not for methods that need access the previous data as input. + +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 +const options = { + operators: { + notEqual: '<>' + }, + functions: { + notEqual: (a: JSONQuery, b: JSONQuery) => { + const aCompiled = compile(a) + const bCompiled = compile(b) + + return (data: unknown) => { + const aEvaluated = aCompiled(data) + const bEvaluated = bCompiled(data) + + return aEvaluated !== bEvaluated + } + } + } +} + +const data = { x: 2, y: 3} +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' + +const options = { + operators: { + notEqual: '<>' + }, + functions: { + notEqual: buildFunction((a: number, b: number) => a !== b) + } +} + +const data = { x: 2, y: 3} +const result = jsonquery(data, '(.x + .y) <> 6', options) // true +``` + ### error handling When executing a query throws an error, the library attaches a stack to the error message which can give insight in what went wrong. The stack can be found at the property `error.jsonquery` and has type `Array<{ data: unknown, query: JSONQuery }>`. @@ -355,32 +584,18 @@ try { ## Gotchas -The JSON Query language has some gotchas. What can be confusing at first is to understand how data is piped through the query. A traditional function call is for example `max(myValues)`, so you may expect to have to write this in JSON Query like `["max", "myValues"]`. However, JSON Query has a functional approach where we create a pipeline like: `data -> max -> result`. So, you will have to write a pipe first getting this property and then calling abs: `[["get", "myValues"], ["max"]]"`. - -It's easy to forget to specify a property getter and instead, just specify a string with the property name, like: - -```js -const data = [ - {"name": "Chris", "age": 23, "city": "New York"}, - {"name": "Emily", "age": 19, "city": "Atlanta"}, - {"name": "Joe", "age": 16, "city": "New York"} -] - -const result = jsonquery(data, ["filter", ["eq", "city", "New York"]]) -// result: empty array -// expecteed: an array with two items -// solution: specify "city" as a getter like ["filter", ["eq", ["get" "city"], "New York"]] -``` +The JSON Query language has some gotchas. What can be confusing at first is to understand how data is piped through the query. A traditional function call is for example `max(myValues)`, so you may expect to have to write this in JSON Query like `["max", "myValues"]`. However, JSON Query has a functional approach where we create a pipeline like: `data -> max -> result`. So, you will have to write a pipe which first gets this property and next calls the function max: `.myValues | max()`. ## Development To develop, check out the repo, install dependencies once, and then use the following scripts: -``` +```text npm run test npm run test-ci npm run lint npm run format +npm run coverage npm run build npm run build-and-test ``` @@ -404,7 +619,7 @@ There are many powerful query languages out there, so why the need to develop `j The expressiveness of most query languages is limited. Since a long time, my favorite JSON query language is JavaScript+Lodash because it is so flexible. The downside however is that it is not safe to store or share queries written in JavaScript: executing arbitrary JavaScript can be a security risk. 4. **Parsable** - + When a query language is simple to parse, it is easy to write integrations and adapters for it. For example, it is possible to write a visual user interface to write queries, and the query language can be implemented in various environments (frontend, backend). The `jsonquery` language is inspired by [JavaScript+Lodash](https://jsoneditoronline.org/indepth/query/10-best-json-query-languages/#javascript), [JSON Patch](https://jsonpatch.com/), and [MongoDB aggregates](https://www.mongodb.com/docs/manual/aggregation/). It is basically a JSON notation to describe making a series of function calls. It has no magic syntax except for the need to be familiar with JSON, making it flexible and easy to understand. The library is extremely small thanks to smartly utilizing built-in JavaScript functions and the built-in JSON parser, requiring very little code to make the query language work. diff --git a/biome.json b/biome.json new file mode 100644 index 0000000..e1bd668 --- /dev/null +++ b/biome.json @@ -0,0 +1,30 @@ +{ + "$schema": "https://biomejs.dev/schemas/1.9.1/schema.json", + "vcs": { "enabled": false, "clientKind": "git", "useIgnoreFile": false }, + "files": { "ignoreUnknown": false, "ignore": ["lib", "coverage"] }, + "formatter": { + "enabled": true, + "useEditorconfig": true, + "formatWithErrors": false, + "indentStyle": "space", + "indentWidth": 2, + "lineEnding": "lf", + "lineWidth": 100, + "attributePosition": "auto", + "bracketSpacing": true + }, + "organizeImports": { "enabled": true }, + "linter": { "enabled": true, "rules": { "recommended": true } }, + "javascript": { + "formatter": { + "quoteProperties": "asNeeded", + "trailingCommas": "none", + "semicolons": "asNeeded", + "arrowParentheses": "always", + "bracketSameLine": false, + "quoteStyle": "single", + "attributePosition": "auto", + "bracketSpacing": true + } + } +} diff --git a/docs/index.html b/docs/index.html index 88912a4..c35fc12 100644 --- a/docs/index.html +++ b/docs/index.html @@ -1,52 +1,48 @@ - - - JSON Query - a small, flexible, and expandable JSON query language - - - - - - - -
-
-
- - JSON Query logo -
-

JSON Query

-

A small, flexible, and expandable JSON query language.

-

- Documentation: - - https://github.com/jsonquerylang/jsonquery - -

-
+ + + + JSON Query - a small, flexible, and expandable JSON query language + + + + + + + + +
+
+
+ + JSON Query logo +
+

JSON Query

+

A small, flexible, and expandable JSON query language.

+

+ Documentation: + + https://github.com/jsonquerylang/jsonquery + +

-
-
- - - - - -
+
+
+
+ + + + +
-
-
- - -
-
- - -
-
- - - +} +
+
+
+
+
+ + + +
+
+ + +
+
-
- -
-
- - Function - [name, argument1, argument2, ...] - -
-

- A function is an array with the function name as first item followed by - optional function arguments. -

-

Example: ["sort", ["get", "address", "city"], "asc"]

-

- Documentation: - Functions -

-

Method reference:

- +
+
+ + + +
+
+ +
+
+ + Function + name(argument1, argument2, ...) + +
+

+ A function is defined as a function name followed by comma separated arguments wrapped in round brackets. it is important to understand functions like filter, sort, and max are executed as a method in a chain: the operation is applied to the data input, and forwarded to the next method in the chain (if any). +

+

+ Examples: +

+
sort(.address.city, "asc")
+
filter(.age >= 21) | sort(.age, "asc")
+

+ Documentation: + Functions +

+

Function reference:

+ +
+
+
+ + Operator + (left operator right) + +
+

+ JSON Query supports all basic operators. When composing multiple operators, it is necessary to use parentheses. Operators do not have precedence since parentheses are required. +

+

Examples:

+
filter(.age >= 18)
+
filter((.age >= 18) and (.age <= 65))
+

+ Documentation: + Operators +

-

Function reference:

- -
-
-
- - Pipe - [query1, query1, ...] - -
-

- A pipe is an array containing a series of queries. The queries in the - pipeline are executed one by one, and the output of the first is the input for the - next. -

-

Example:

-
[
-  ["filter", ["gte", ["get", "age"], 18]],
-  ["sort", ["get", "name"]]
-]
-

- Documentation: - Pipes -

-
-
-
- - Object - {"prop1": query1, "prop2": query2, ...} - -
-

- An object is defined as a regular JSON object with a property name as key, - and a function, pipe, or object as value. Objects can be used - to transform data or to execute multiple query pipelines in parallel. -

-

Example:

-
{
-  "names": ["map", ["get", "name"]],
-  "numberOfNames": ["length"]
+              

Operator reference:

+ +
+
+
+ + Pipe + query2 | query2 | ... + +
+

+ A pipe is an array containing a series of queries. The queries in the + pipeline are executed one by one, and the output of the first is the input for the + next. +

+

Example:

+
filter(.age >= 18) | sort(.name)
+

+ Documentation: + Pipes +

+
+
+
+ + Object + {prop1: query1, prop2: query2, ...} + +
+

+ An object is defined as a regular JSON object with a property name as key, and a query as value. Objects can be used to transform data or to execute multiple query pipelines in parallel. +

+

Example:

+
{
+  names: map(.name),
+  numberOfNames: size()
 }
-

- Documentation: - Objects -

-
-
-
+

+ Documentation: + Objects +

+
+
+
+ + Array + [query1, query2, ...] + +
+

+ An array is defined as a regular JSON array: enclosed in square brackets, with items separated by a comma. +

+

Example:

+
filter(.city in ["New York", "Atlanta"])
+

+ Documentation: + Arrays +

+
+
+
+ + Property + .prop1.prop2 + +
+

+ A property retrieves a property from an object. Multiple consecutive properties will retrieve a nested property. +

+

Examples:

+
.age
+
.address.city
+
."first name"
+

+ Documentation: + Properties +

+
+
+
+ + Value + "string", number, boolean, null + +
+

+ JSON Query supports the following primitive values, the same as in JSON: "string", number, boolean, null. +

+

Examples:

+
"Hello world"
+
"Multi line text\nwith \"quoted\" contents"
+
42
+
2.74
+
-1.2e3
+
true
+
false
+
null
+

+ Documentation: + Values +

+
+
- -
-

Debugger

- +
+ +
+

Debugger

+ +
+
+
Error message...
+
+ Stack: + 2/4 +
-
-
Error message...
-
- Stack: - 2/4 - +
+
+ +
-
-
- - -
-
- - -
+
+ +
-
-
+
+ +
- + - go() - - diff --git a/docs/jsonquery-overview.svg b/docs/jsonquery-overview.svg index 74a0485..cf1a090 100644 --- a/docs/jsonquery-overview.svg +++ b/docs/jsonquery-overview.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/docs/style.css b/docs/style.css index 8abba74..43afda0 100644 --- a/docs/style.css +++ b/docs/style.css @@ -1,10 +1,12 @@ :root { --theme-color: #d5f3bb; + --theme-color-highlight: #e1f6ce; --background-color: #fafafa; --color: #222b00; --error-color: #f65252; --padding: 10px; --border-radius: 3px; + --tab-border-width: 5px; } html, @@ -58,14 +60,8 @@ body { linear-gradient(to top right, transparent 50.5%, var(--theme-color) 50.5%), linear-gradient(to top left, transparent 50.5%, var(--theme-color) 50.5%); background-repeat: repeat, repeat-x, repeat-x; - background-position: - 0 0, - 10px 100%, - 10px 100%; - background-size: - auto auto, - 20px 20px, - 20px 20px; + background-position: 0 0, 10px 100%, 10px 100%; + background-size: auto auto, 20px 20px, 20px 20px; background-clip: padding-box, border-box, border-box; background-origin: padding-box, border-box, border-box; } @@ -145,6 +141,69 @@ button:hover { min-height: 220px; } +.row { + flex: 1; + display: flex; + flex-direction: column; +} + +.tab-section { + flex: 1; + display: flex; + flex-direction: column; + + .tabs { + display: flex; + align-items: end; + gap: var(--tab-border-width); + padding: 0 var(--tab-border-width); + + label { + margin: 0; + } + + .query-label { + flex: 1; + margin: 0 0 2px; + } + + button { + margin: 0; + border-radius: var(--border-radius) var(--border-radius) 0 0; + background: white; + border: 1px solid var(--theme-color); + border-bottom: none; + color: var(--color); + font-family: inherit; + font-size: inherit; + padding: 2px 10px 5px; + + &:hover { + background: var(--theme-color-highlight); + } + + &.selected { + background: var(--theme-color); + padding: 5px 10px; + } + } + } + + .tab-contents { + flex: 1; + display: flex; + flex-direction: column; + border-radius: var(--border-radius); + padding: var(--tab-border-width); + margin-bottom: -5px; + background: var(--theme-color); + + textarea:not(.selected) { + display: none; + } + } +} + @media (max-width: 860px) { .playground { display: flex; @@ -155,6 +214,10 @@ button:hover { .column { display: flex; } + + .row { + min-height: 110px; + } } .separator { @@ -163,7 +226,8 @@ button:hover { label { font-weight: bold; - margin-bottom: 10px; + margin-top: var(--padding); + margin-bottom: 7px; } textarea { @@ -231,8 +295,8 @@ pre code { background: none; } -.method-reference a, -.function-reference a { +.function-reference a, +.operator-reference a { text-decoration: none; } @@ -269,9 +333,7 @@ summary .category { font-family: inherit; font-size: inherit; - transition: - width 0.1s ease-in-out, - height 0.1s ease-in-out; + transition: width 0.1s ease-in-out, height 0.1s ease-in-out; &[open] { display: flex; diff --git a/package-lock.json b/package-lock.json index ab6ebde..8066a64 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,12 +9,13 @@ "version": "0.0.0", "license": "ISC", "devDependencies": { + "@biomejs/biome": "1.9.1", + "@vitest/coverage-v8": "2.1.1", "npm-run-all": "4.1.5", - "prettier": "3.3.3", - "semantic-release": "24.1.0", - "typescript": "5.5.4", - "vite": "5.4.2", - "vitest": "2.0.5" + "semantic-release": "24.1.1", + "typescript": "5.6.2", + "vite": "5.4.6", + "vitest": "2.1.1" } }, "node_modules/@ampproject/remapping": { @@ -45,6 +46,16 @@ "node": ">=6.9.0" } }, + "node_modules/@babel/helper-string-parser": { + "version": "7.24.8", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.24.8.tgz", + "integrity": "sha512-pO9KhhRcuUyGnJWwyEgnRJTSIZHiT+vMD0kPeD+so0l7mxkMT19g3pjY9GTnHySck/hDzq+dtW/4VgnMkippsQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@babel/helper-validator-identifier": { "version": "7.24.7", "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.24.7.tgz", @@ -71,6 +82,208 @@ "node": ">=6.9.0" } }, + "node_modules/@babel/parser": { + "version": "7.25.6", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.25.6.tgz", + "integrity": "sha512-trGdfBdbD0l1ZPmcJ83eNxB9rbEax4ALFTF7fN386TMYbeCQbyme5cOEXQhbGXKebwGaB/J52w1mrklMcbgy6Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.25.6" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/types": { + "version": "7.25.6", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.25.6.tgz", + "integrity": "sha512-/l42B1qxpG6RdfYf343Uw1vmDjeNhneUXtzhojE7pDgfpEypmRhI6j1kr17XCVv4Cgl9HdAiQY2x0GwKm7rWCw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.24.8", + "@babel/helper-validator-identifier": "^7.24.7", + "to-fast-properties": "^2.0.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@bcoe/v8-coverage": { + "version": "0.2.3", + "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-0.2.3.tgz", + "integrity": "sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@biomejs/biome": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/@biomejs/biome/-/biome-1.9.1.tgz", + "integrity": "sha512-Ps0Rg0zg3B1zpx+zQHMz5b0n0PBNCAaXttHEDTVrJD5YXR6Uj3T+abTDgeS3wsu4z5i2whqcE1lZxGyWH4bZYg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT OR Apache-2.0", + "bin": { + "biome": "bin/biome" + }, + "engines": { + "node": ">=14.21.3" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/biome" + }, + "optionalDependencies": { + "@biomejs/cli-darwin-arm64": "1.9.1", + "@biomejs/cli-darwin-x64": "1.9.1", + "@biomejs/cli-linux-arm64": "1.9.1", + "@biomejs/cli-linux-arm64-musl": "1.9.1", + "@biomejs/cli-linux-x64": "1.9.1", + "@biomejs/cli-linux-x64-musl": "1.9.1", + "@biomejs/cli-win32-arm64": "1.9.1", + "@biomejs/cli-win32-x64": "1.9.1" + } + }, + "node_modules/@biomejs/cli-darwin-arm64": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-arm64/-/cli-darwin-arm64-1.9.1.tgz", + "integrity": "sha512-js0brHswq/BoeKgfSEUJYOjUOlML6p65Nantti+PsoQ61u9+YVGIZ7325LK7iUpDH8KVJT+Bx7K2b/6Q//W1Pw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-darwin-x64": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-x64/-/cli-darwin-x64-1.9.1.tgz", + "integrity": "sha512-2zVyjUg5rN0k8XrytkubQWLbp2r/AS5wPhXs4vgVjvqbLnzo32EGX8p61gzroF2dH9DCUCfskdrigCGqNdEbpg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-linux-arm64": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64/-/cli-linux-arm64-1.9.1.tgz", + "integrity": "sha512-QgxwfnG+r2aer5RNGR67Ey91Tv7xXW8E9YckHhwuyWjdLEvKWkrSJrhVG/6ub0kVvTSNkYOuT/7/jMOFBuUbRA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-linux-arm64-musl": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64-musl/-/cli-linux-arm64-musl-1.9.1.tgz", + "integrity": "sha512-L/JmXKvhsZ1lTgqOr3tWkzuY/NRppdIscHeC9aaiR72WjnBgJS94mawl9BWmGB3aWBc0q6oSDWnBS7617EMMmA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-linux-x64": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64/-/cli-linux-x64-1.9.1.tgz", + "integrity": "sha512-F0INygtzI2L2n2R1KtYHGr3YWDt9Up1zrUluwembM+iJ1dXN3qzlSb7deFUsSJm4FaIPriqs6Xa56ukdQW6UeQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-linux-x64-musl": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64-musl/-/cli-linux-x64-musl-1.9.1.tgz", + "integrity": "sha512-gY+eFLIAW45v3WicQHicvjRfA0ntMZHx7h937bXwBMFNFoKmB6rMi6+fKQ6/hiS6juhsFxZdZIz20m15s49J6A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-win32-arm64": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/@biomejs/cli-win32-arm64/-/cli-win32-arm64-1.9.1.tgz", + "integrity": "sha512-7Jahxar3OB+aTPOgXisMJmMKMsjcK+UmdlG3UIOQjzN/ZFEsPV+GT3bfrVjZDQaCw/zes0Cqd7VTWFjFTC/+MQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-win32-x64": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/@biomejs/cli-win32-x64/-/cli-win32-x64-1.9.1.tgz", + "integrity": "sha512-liSRWjWzFhyG7s1jg/Bbv9FL+ha/CEd5tFO3+dFIJNplL4TnvAivtyfRVi/tu/pNjISbV1k9JwdBewtAKAgA0w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=14.21.3" + } + }, "node_modules/@colors/colors": { "version": "1.5.0", "resolved": "https://registry.npmjs.org/@colors/colors/-/colors-1.5.0.tgz", @@ -473,6 +686,119 @@ "node": ">=12" } }, + "node_modules/@isaacs/cliui": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", + "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^5.1.2", + "string-width-cjs": "npm:string-width@^4.2.0", + "strip-ansi": "^7.0.1", + "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", + "wrap-ansi": "^8.1.0", + "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@isaacs/cliui/node_modules/ansi-regex": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.1.0.tgz", + "integrity": "sha512-7HSX4QQb4CspciLpVFwyRe79O3xsIZDDLER21kERQ71oaPodF8jL725AgJMFAYbooIqolJoRLuM81SpeUkpkvA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/@isaacs/cliui/node_modules/ansi-styles": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.1.tgz", + "integrity": "sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/@isaacs/cliui/node_modules/emoji-regex": { + "version": "9.2.2", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", + "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@isaacs/cliui/node_modules/string-width": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", + "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "eastasianwidth": "^0.2.0", + "emoji-regex": "^9.2.2", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@isaacs/cliui/node_modules/strip-ansi": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.0.tgz", + "integrity": "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/@isaacs/cliui/node_modules/wrap-ansi": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", + "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.1.0", + "string-width": "^5.0.1", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/@istanbuljs/schema": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.3.tgz", + "integrity": "sha512-ZXRY4jNvVgSVQ8DL3LTcakaAtXwTVUxE81hslsyD2AtoXW/wVob10HkOJ1X/pAlcI7D+2YoZKg5do8G/w6RYgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/@jridgewell/gen-mapping": { "version": "0.3.5", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.5.tgz", @@ -732,6 +1058,17 @@ "@octokit/openapi-types": "^22.2.0" } }, + "node_modules/@pkgjs/parseargs": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", + "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=14" + } + }, "node_modules/@pnpm/config.env-replace": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@pnpm/config.env-replace/-/config.env-replace-1.1.0.tgz", @@ -1357,15 +1694,48 @@ "dev": true, "license": "MIT" }, + "node_modules/@vitest/coverage-v8": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-2.1.1.tgz", + "integrity": "sha512-md/A7A3c42oTT8JUHSqjP5uKTWJejzUW4jalpvs+rZ27gsURsMU8DEb+8Jf8C6Kj2gwfSHJqobDNBuoqlm0cFw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@ampproject/remapping": "^2.3.0", + "@bcoe/v8-coverage": "^0.2.3", + "debug": "^4.3.6", + "istanbul-lib-coverage": "^3.2.2", + "istanbul-lib-report": "^3.0.1", + "istanbul-lib-source-maps": "^5.0.6", + "istanbul-reports": "^3.1.7", + "magic-string": "^0.30.11", + "magicast": "^0.3.4", + "std-env": "^3.7.0", + "test-exclude": "^7.0.1", + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@vitest/browser": "2.1.1", + "vitest": "2.1.1" + }, + "peerDependenciesMeta": { + "@vitest/browser": { + "optional": true + } + } + }, "node_modules/@vitest/expect": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-2.0.5.tgz", - "integrity": "sha512-yHZtwuP7JZivj65Gxoi8upUN2OzHTi3zVfjwdpu2WrvCZPLwsJ2Ey5ILIPccoW23dd/zQBlJ4/dhi7DWNyXCpA==", + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-2.1.1.tgz", + "integrity": "sha512-YeueunS0HiHiQxk+KEOnq/QMzlUuOzbU1Go+PgAsHvvv3tUkJPm9xWt+6ITNTlzsMXUjmgm5T+U7KBPK2qQV6w==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/spy": "2.0.5", - "@vitest/utils": "2.0.5", + "@vitest/spy": "2.1.1", + "@vitest/utils": "2.1.1", "chai": "^5.1.1", "tinyrainbow": "^1.2.0" }, @@ -1373,10 +1743,38 @@ "url": "https://opencollective.com/vitest" } }, + "node_modules/@vitest/mocker": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-2.1.1.tgz", + "integrity": "sha512-LNN5VwOEdJqCmJ/2XJBywB11DLlkbY0ooDJW3uRX5cZyYCrc4PI/ePX0iQhE3BiEGiQmK4GE7Q/PqCkkaiPnrA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "^2.1.0-beta.1", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.11" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@vitest/spy": "2.1.1", + "msw": "^2.3.5", + "vite": "^5.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, "node_modules/@vitest/pretty-format": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-2.0.5.tgz", - "integrity": "sha512-h8k+1oWHfwTkyTkb9egzwNMfJAEx4veaPSnMeKbVSjp4euqGSbQlm5+6VHwTr7u4FJslVVsUG5nopCaAYdOmSQ==", + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-2.1.1.tgz", + "integrity": "sha512-SjxPFOtuINDUW8/UkElJYQSFtnWX7tMksSGW0vfjxMneFqxVr8YJ979QpMbDW7g+BIiq88RAGDjf7en6rvLPPQ==", "dev": true, "license": "MIT", "dependencies": { @@ -1387,13 +1785,13 @@ } }, "node_modules/@vitest/runner": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-2.0.5.tgz", - "integrity": "sha512-TfRfZa6Bkk9ky4tW0z20WKXFEwwvWhRY+84CnSEtq4+3ZvDlJyY32oNTJtM7AW9ihW90tX/1Q78cb6FjoAs+ig==", + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-2.1.1.tgz", + "integrity": "sha512-uTPuY6PWOYitIkLPidaY5L3t0JJITdGTSwBtwMjKzo5O6RCOEncz9PUN+0pDidX8kTHYjO0EwUIvhlGpnGpxmA==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/utils": "2.0.5", + "@vitest/utils": "2.1.1", "pathe": "^1.1.2" }, "funding": { @@ -1401,14 +1799,14 @@ } }, "node_modules/@vitest/snapshot": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-2.0.5.tgz", - "integrity": "sha512-SgCPUeDFLaM0mIUHfaArq8fD2WbaXG/zVXjRupthYfYGzc8ztbFbu6dUNOblBG7XLMR1kEhS/DNnfCZ2IhdDew==", + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-2.1.1.tgz", + "integrity": "sha512-BnSku1WFy7r4mm96ha2FzN99AZJgpZOWrAhtQfoxjUU5YMRpq1zmHRq7a5K9/NjqonebO7iVDla+VvZS8BOWMw==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "2.0.5", - "magic-string": "^0.30.10", + "@vitest/pretty-format": "2.1.1", + "magic-string": "^0.30.11", "pathe": "^1.1.2" }, "funding": { @@ -1416,9 +1814,9 @@ } }, "node_modules/@vitest/spy": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-2.0.5.tgz", - "integrity": "sha512-c/jdthAhvJdpfVuaexSrnawxZz6pywlTPe84LUB2m/4t3rl2fTo9NFGBG4oWgaD+FTgDDV8hJ/nibT7IfH3JfA==", + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-2.1.1.tgz", + "integrity": "sha512-ZM39BnZ9t/xZ/nF4UwRH5il0Sw93QnZXd9NAZGRpIgj0yvVwPpLd702s/Cx955rGaMlyBQkZJ2Ir7qyY48VZ+g==", "dev": true, "license": "MIT", "dependencies": { @@ -1429,14 +1827,13 @@ } }, "node_modules/@vitest/utils": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-2.0.5.tgz", - "integrity": "sha512-d8HKbqIcya+GR67mkZbrzhS5kKhtp8dQLcmRZLGTscGVg7yImT82cIrhtn2L8+VujWcy6KZweApgNmPsTAO/UQ==", + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-2.1.1.tgz", + "integrity": "sha512-Y6Q9TsI+qJ2CC0ZKj6VBb+T8UPz593N113nnUykqwANqhgf3QkZeHFlusgKLTqrnVHbj/XDKZcDHol+dxVT+rQ==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "2.0.5", - "estree-walker": "^3.0.3", + "@vitest/pretty-format": "2.1.1", "loupe": "^3.1.1", "tinyrainbow": "^1.2.0" }, @@ -2378,6 +2775,13 @@ "readable-stream": "^2.0.2" } }, + "node_modules/eastasianwidth": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", + "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", + "dev": true, + "license": "MIT" + }, "node_modules/emoji-regex": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", @@ -2771,6 +3175,23 @@ "is-callable": "^1.1.3" } }, + "node_modules/foreground-child": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.0.tgz", + "integrity": "sha512-Ld2g8rrAyMYFXBhEqMz8ZAHBi4J4uS1i/CxGMDnjyFWddMXLVcDp051DZfu+t7+ab7Wv6SMqpWmyFIj5UbfFvg==", + "dev": true, + "license": "ISC", + "dependencies": { + "cross-spawn": "^7.0.0", + "signal-exit": "^4.0.1" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/from2": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/from2/-/from2-2.3.0.tgz", @@ -2950,6 +3371,27 @@ "traverse": "0.6.8" } }, + "node_modules/glob": { + "version": "10.4.5", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.4.5.tgz", + "integrity": "sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==", + "dev": true, + "license": "ISC", + "dependencies": { + "foreground-child": "^3.1.0", + "jackspeak": "^3.1.2", + "minimatch": "^9.0.4", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^1.11.1" + }, + "bin": { + "glob": "dist/esm/bin.mjs" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/glob-parent": { "version": "5.1.2", "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", @@ -2963,6 +3405,32 @@ "node": ">= 6" } }, + "node_modules/glob/node_modules/brace-expansion": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", + "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/glob/node_modules/minimatch": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", + "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/globalthis": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/globalthis/-/globalthis-1.0.4.tgz", @@ -3174,6 +3642,13 @@ "dev": true, "license": "ISC" }, + "node_modules/html-escaper": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", + "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", + "dev": true, + "license": "MIT" + }, "node_modules/http-proxy-agent": { "version": "7.0.2", "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", @@ -3670,41 +4145,134 @@ "dev": true, "license": "MIT", "dependencies": { - "call-bind": "^1.0.2" + "call-bind": "^1.0.2" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/isarray": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz", + "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==", + "dev": true, + "license": "MIT" + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/issue-parser": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/issue-parser/-/issue-parser-7.0.1.tgz", + "integrity": "sha512-3YZcUUR2Wt1WsapF+S/WiA2WmlW0cWAoPccMqne7AxEBhCdFeTPjfv/Axb8V2gyCgY3nRw+ksZ3xSUX+R47iAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "lodash.capitalize": "^4.2.1", + "lodash.escaperegexp": "^4.1.2", + "lodash.isplainobject": "^4.0.6", + "lodash.isstring": "^4.0.1", + "lodash.uniqby": "^4.7.0" + }, + "engines": { + "node": "^18.17 || >=20.6.1" + } + }, + "node_modules/istanbul-lib-coverage": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", + "integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=8" + } + }, + "node_modules/istanbul-lib-report": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz", + "integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "istanbul-lib-coverage": "^3.0.0", + "make-dir": "^4.0.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-lib-report/node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/istanbul-lib-report/node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "engines": { + "node": ">=8" } }, - "node_modules/isarray": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz", - "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==", + "node_modules/istanbul-lib-source-maps": { + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/istanbul-lib-source-maps/-/istanbul-lib-source-maps-5.0.6.tgz", + "integrity": "sha512-yg2d+Em4KizZC5niWhQaIomgf5WlL4vOOjZ5xGCmF8SnPE/mDWWXgvRExdcpCgh9lLRRa1/fSYp2ymmbJ1pI+A==", "dev": true, - "license": "MIT" + "license": "BSD-3-Clause", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.23", + "debug": "^4.1.1", + "istanbul-lib-coverage": "^3.0.0" + }, + "engines": { + "node": ">=10" + } }, - "node_modules/isexe": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", - "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "node_modules/istanbul-reports": { + "version": "3.1.7", + "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.1.7.tgz", + "integrity": "sha512-BewmUXImeuRk2YY0PVbxgKAysvhRPUQE0h5QRM++nVWyubKGV0l8qQ5op8+B2DOmwSe63Jivj0BjkPQVf8fP5g==", "dev": true, - "license": "ISC" + "license": "BSD-3-Clause", + "dependencies": { + "html-escaper": "^2.0.0", + "istanbul-lib-report": "^3.0.0" + }, + "engines": { + "node": ">=8" + } }, - "node_modules/issue-parser": { - "version": "7.0.1", - "resolved": "https://registry.npmjs.org/issue-parser/-/issue-parser-7.0.1.tgz", - "integrity": "sha512-3YZcUUR2Wt1WsapF+S/WiA2WmlW0cWAoPccMqne7AxEBhCdFeTPjfv/Axb8V2gyCgY3nRw+ksZ3xSUX+R47iAg==", + "node_modules/jackspeak": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz", + "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==", "dev": true, - "license": "MIT", + "license": "BlueOak-1.0.0", "dependencies": { - "lodash.capitalize": "^4.2.1", - "lodash.escaperegexp": "^4.1.2", - "lodash.isplainobject": "^4.0.6", - "lodash.isstring": "^4.0.1", - "lodash.uniqby": "^4.7.0" + "@isaacs/cliui": "^8.0.2" }, - "engines": { - "node": "^18.17 || >=20.6.1" + "funding": { + "url": "https://github.com/sponsors/isaacs" + }, + "optionalDependencies": { + "@pkgjs/parseargs": "^0.11.0" } }, "node_modules/java-properties": { @@ -3870,6 +4438,47 @@ "@jridgewell/sourcemap-codec": "^1.5.0" } }, + "node_modules/magicast": { + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/magicast/-/magicast-0.3.5.tgz", + "integrity": "sha512-L0WhttDl+2BOsybvEOLK7fW3UA0OQ0IQ2d6Zl2x/a6vVRs3bAY0ECOSHHeL5jD+SbOpOCUEi0y1DgHEn9Qn1AQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.25.4", + "@babel/types": "^7.25.4", + "source-map-js": "^1.2.0" + } + }, + "node_modules/make-dir": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz", + "integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.5.3" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/make-dir/node_modules/semver": { + "version": "7.6.3", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.3.tgz", + "integrity": "sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/marked": { "version": "12.0.2", "resolved": "https://registry.npmjs.org/marked/-/marked-12.0.2.tgz", @@ -4022,6 +4631,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/minipass": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz", + "integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, "node_modules/ms": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", @@ -6970,6 +7589,13 @@ "node": ">=4" } }, + "node_modules/package-json-from-dist": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.0.tgz", + "integrity": "sha512-dATvCeZN/8wQsGywez1mzHtTlP22H8OEfPrVMLNr4/eGa+ijtLn/6M5f0dY8UKNrC2O9UCU6SSoG3qRKnt7STw==", + "dev": true, + "license": "BlueOak-1.0.0" + }, "node_modules/parent-module": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", @@ -7061,6 +7687,23 @@ "dev": true, "license": "MIT" }, + "node_modules/path-scurry": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", + "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^10.2.0", + "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" + }, + "engines": { + "node": ">=16 || 14 >=14.18" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/path-type": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/path-type/-/path-type-3.0.0.tgz", @@ -7092,9 +7735,9 @@ } }, "node_modules/picocolors": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.0.1.tgz", - "integrity": "sha512-anP1Z8qwhkbmu7MFP5iTt+wQKXgwzf7zTyGlcdzabySa9vd0Xt392U0rVmz9poOaBj0uHJKyyo9/upk0HrEQew==", + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.0.tgz", + "integrity": "sha512-TQ92mBOW0l3LeMeyLV6mzy/kWr8lkd/hp3mTg7wYK7zJhuBStmGMBG0BdeDZS/dZx1IukaX6Bk11zcln25o1Aw==", "dev": true, "license": "ISC" }, @@ -7159,9 +7802,9 @@ } }, "node_modules/postcss": { - "version": "8.4.41", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.41.tgz", - "integrity": "sha512-TesUflQ0WKZqAvg52PWL6kHgLKP6xB6heTOdoYM0Wt2UHyxNa4K25EZZMgKns3BH1RLVbZCREPpLY0rhnNoHVQ==", + "version": "8.4.47", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.47.tgz", + "integrity": "sha512-56rxCq7G/XfB4EkXq9Egn5GCqugWvDFjafDOThIdMBsI15iqPqR5r15TfSr1YPYeEI19YeaXMCbY6u88Y76GLQ==", "dev": true, "funding": [ { @@ -7180,29 +7823,13 @@ "license": "MIT", "dependencies": { "nanoid": "^3.3.7", - "picocolors": "^1.0.1", - "source-map-js": "^1.2.0" + "picocolors": "^1.1.0", + "source-map-js": "^1.2.1" }, "engines": { "node": "^10 || ^12 || >=14" } }, - "node_modules/prettier": { - "version": "3.3.3", - "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.3.3.tgz", - "integrity": "sha512-i2tDNA0O5IrMO757lfrdQZCc2jPNDVntV0m/+4whiDfWaTKfMNgR7Qz0NAeGz/nRqF4m5/6CLzbP4/liHt12Ew==", - "dev": true, - "license": "MIT", - "bin": { - "prettier": "bin/prettier.cjs" - }, - "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/prettier/prettier?sponsor=1" - } - }, "node_modules/pretty-ms": { "version": "9.1.0", "resolved": "https://registry.npmjs.org/pretty-ms/-/pretty-ms-9.1.0.tgz", @@ -7591,9 +8218,9 @@ } }, "node_modules/semantic-release": { - "version": "24.1.0", - "resolved": "https://registry.npmjs.org/semantic-release/-/semantic-release-24.1.0.tgz", - "integrity": "sha512-FwaE2hKDHQn9G6GA7xmqsc9WnsjaFD/ppLM5PUg56Do9oKSCf+vH6cPeb3hEBV/m06n8Sh9vbVqPjHu/1onzQw==", + "version": "24.1.1", + "resolved": "https://registry.npmjs.org/semantic-release/-/semantic-release-24.1.1.tgz", + "integrity": "sha512-4Ax2GxD411jUe9IdhOjMLuN+6wAj+aKjvOGngByrpD/iKL+UKN/2puQglhyI4gxNyy9XzEBMzBwbqpnEwbXGEg==", "dev": true, "license": "MIT", "dependencies": { @@ -7612,7 +8239,7 @@ "get-stream": "^6.0.0", "git-log-parser": "^1.2.0", "hook-std": "^3.0.0", - "hosted-git-info": "^7.0.0", + "hosted-git-info": "^8.0.0", "import-from-esm": "^1.3.1", "lodash-es": "^4.17.21", "marked": "^12.0.0", @@ -7705,16 +8332,16 @@ } }, "node_modules/semantic-release/node_modules/hosted-git-info": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-7.0.2.tgz", - "integrity": "sha512-puUZAUKT5m8Zzvs72XWy3HtvVbTWljRE66cP60bxJzAqf2DgICo7lYTY2IHUmLnNpjYvw5bvmoHvPc0QO2a62w==", + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-8.0.0.tgz", + "integrity": "sha512-4nw3vOVR+vHUOT8+U4giwe2tcGv+R3pwwRidUe67DoMBTjhrfr6rZYJVVwdkBE+Um050SG+X9tf0Jo4fOpn01w==", "dev": true, "license": "ISC", "dependencies": { "lru-cache": "^10.0.1" }, "engines": { - "node": "^16.14.0 || >=18.0.0" + "node": "^18.17.0 || >=20.5.0" } }, "node_modules/semantic-release/node_modules/human-signals": { @@ -7989,9 +8616,9 @@ } }, "node_modules/source-map-js": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.0.tgz", - "integrity": "sha512-itJW8lvSA0TXEphiRoawsCksnlf8SyvmFzIhltqAHluXd88pkCd+cXJVHTDwdCr0IzwptSm035IHQktUu1QUMg==", + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", "dev": true, "license": "BSD-3-Clause", "engines": { @@ -8114,6 +8741,22 @@ "node": ">=8" } }, + "node_modules/string-width-cjs": { + "name": "string-width", + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/string.prototype.padend": { "version": "3.1.6", "resolved": "https://registry.npmjs.org/string.prototype.padend/-/string.prototype.padend-3.1.6.tgz", @@ -8198,6 +8841,20 @@ "node": ">=8" } }, + "node_modules/strip-ansi-cjs": { + "name": "strip-ansi", + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/strip-bom": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-3.0.0.tgz", @@ -8377,6 +9034,47 @@ "node": ">=10" } }, + "node_modules/test-exclude": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-7.0.1.tgz", + "integrity": "sha512-pFYqmTw68LXVjeWJMST4+borgQP2AyMNbg1BpZh9LbyhUeNkeaPF9gzfPGUAnSMV3qPYdWUwDIjjCLiSDOl7vg==", + "dev": true, + "license": "ISC", + "dependencies": { + "@istanbuljs/schema": "^0.1.2", + "glob": "^10.4.1", + "minimatch": "^9.0.4" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/test-exclude/node_modules/brace-expansion": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", + "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/test-exclude/node_modules/minimatch": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", + "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/thenify": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz", @@ -8428,9 +9126,16 @@ } }, "node_modules/tinybench": { - "version": "2.8.0", - "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.8.0.tgz", - "integrity": "sha512-1/eK7zUnIklz4JUUlL+658n58XO2hHLQfSk1Zf2LKieUjxidN16eKFEoDEfjHc3ohofSSqK3X5yO6VGb6iW8Lw==", + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.0.tgz", + "integrity": "sha512-tVGE0mVJPGb0chKhqmsoosjsS+qUnJVGJpZgsHYQcGoPlG3B51R3PouqTgEGH2Dc9jjFyOqOpix6ZHNMXp1FZg==", "dev": true, "license": "MIT" }, @@ -8455,15 +9160,25 @@ } }, "node_modules/tinyspy": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-3.0.0.tgz", - "integrity": "sha512-q5nmENpTHgiPVd1cJDDc9cVoYN5x4vCvwT3FMilvKPKneCBZAxn2YWQjDF0UMcE9k0Cay1gBiDfTMU0g+mPMQA==", + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-3.0.2.tgz", + "integrity": "sha512-n1cw8k1k0x4pgA2+9XrOkFydTerNcJ1zWCO5Nn9scWHTD+5tp8dghT2x1uduQePZTZgd3Tupf+x9BxJjeJi77Q==", "dev": true, "license": "MIT", "engines": { "node": ">=14.0.0" } }, + "node_modules/to-fast-properties": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/to-fast-properties/-/to-fast-properties-2.0.0.tgz", + "integrity": "sha512-/OaKK0xYrs3DmxRYqL/yDc+FxFUVYhDlXMhRmv3z915w2HF1tnN1omB354j8VUGO/hbRzyD6Y3sA7v7GS/ceog==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, "node_modules/to-regex-range": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", @@ -8581,9 +9296,9 @@ } }, "node_modules/typescript": { - "version": "5.5.4", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.5.4.tgz", - "integrity": "sha512-Mtq29sKDAEYP7aljRgtPOpTvOfbwRWlS6dPRzwjdE+C0R4brX/GUyhHSecbHMFLNBLcJIPt9nl9yG5TZ1weH+Q==", + "version": "5.6.2", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.6.2.tgz", + "integrity": "sha512-NW8ByodCSNCwZeghjN3o+JX5OFH0Ojg6sadjEKY4huZ52TqbJTJnDo5+Tw98lSy63NZvi4n+ez5m2u5d4PkZyw==", "dev": true, "license": "Apache-2.0", "bin": { @@ -8709,14 +9424,14 @@ } }, "node_modules/vite": { - "version": "5.4.2", - "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.2.tgz", - "integrity": "sha512-dDrQTRHp5C1fTFzcSaMxjk6vdpKvT+2/mIdE07Gw2ykehT49O0z/VHS3zZ8iV/Gh8BJJKHWOe5RjaNrW5xf/GA==", + "version": "5.4.6", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.6.tgz", + "integrity": "sha512-IeL5f8OO5nylsgzd9tq4qD2QqI0k2CQLGrWD0rCN0EQJZpBK5vJAx0I+GDkMOXxQX/OfFHMuLIx6ddAxGX/k+Q==", "dev": true, "license": "MIT", "dependencies": { "esbuild": "^0.21.3", - "postcss": "^8.4.41", + "postcss": "^8.4.43", "rollup": "^4.20.0" }, "bin": { @@ -8769,16 +9484,15 @@ } }, "node_modules/vite-node": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-2.0.5.tgz", - "integrity": "sha512-LdsW4pxj0Ot69FAoXZ1yTnA9bjGohr2yNBU7QKRxpz8ITSkhuDl6h3zS/tvgz4qrNjeRnvrWeXQ8ZF7Um4W00Q==", + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-2.1.1.tgz", + "integrity": "sha512-N/mGckI1suG/5wQI35XeR9rsMsPqKXzq1CdUndzVstBj/HvyxxGctwnK6WX43NGt5L3Z5tcRf83g4TITKJhPrA==", "dev": true, "license": "MIT", "dependencies": { "cac": "^6.7.14", - "debug": "^4.3.5", + "debug": "^4.3.6", "pathe": "^1.1.2", - "tinyrainbow": "^1.2.0", "vite": "^5.0.0" }, "bin": { @@ -8792,30 +9506,30 @@ } }, "node_modules/vitest": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/vitest/-/vitest-2.0.5.tgz", - "integrity": "sha512-8GUxONfauuIdeSl5f9GTgVEpg5BTOlplET4WEDaeY2QBiN8wSm68vxN/tb5z405OwppfoCavnwXafiaYBC/xOA==", + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-2.1.1.tgz", + "integrity": "sha512-97We7/VC0e9X5zBVkvt7SGQMGrRtn3KtySFQG5fpaMlS+l62eeXRQO633AYhSTC3z7IMebnPPNjGXVGNRFlxBA==", "dev": true, "license": "MIT", "dependencies": { - "@ampproject/remapping": "^2.3.0", - "@vitest/expect": "2.0.5", - "@vitest/pretty-format": "^2.0.5", - "@vitest/runner": "2.0.5", - "@vitest/snapshot": "2.0.5", - "@vitest/spy": "2.0.5", - "@vitest/utils": "2.0.5", + "@vitest/expect": "2.1.1", + "@vitest/mocker": "2.1.1", + "@vitest/pretty-format": "^2.1.1", + "@vitest/runner": "2.1.1", + "@vitest/snapshot": "2.1.1", + "@vitest/spy": "2.1.1", + "@vitest/utils": "2.1.1", "chai": "^5.1.1", - "debug": "^4.3.5", - "execa": "^8.0.1", - "magic-string": "^0.30.10", + "debug": "^4.3.6", + "magic-string": "^0.30.11", "pathe": "^1.1.2", "std-env": "^3.7.0", - "tinybench": "^2.8.0", + "tinybench": "^2.9.0", + "tinyexec": "^0.3.0", "tinypool": "^1.0.0", "tinyrainbow": "^1.2.0", "vite": "^5.0.0", - "vite-node": "2.0.5", + "vite-node": "2.1.1", "why-is-node-running": "^2.3.0" }, "bin": { @@ -8830,8 +9544,8 @@ "peerDependencies": { "@edge-runtime/vm": "*", "@types/node": "^18.0.0 || >=20.0.0", - "@vitest/browser": "2.0.5", - "@vitest/ui": "2.0.5", + "@vitest/browser": "2.1.1", + "@vitest/ui": "2.1.1", "happy-dom": "*", "jsdom": "*" }, @@ -8951,6 +9665,61 @@ "url": "https://github.com/chalk/wrap-ansi?sponsor=1" } }, + "node_modules/wrap-ansi-cjs": { + "name": "wrap-ansi", + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, "node_modules/wrap-ansi/node_modules/ansi-styles": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", diff --git a/package.json b/package.json index afd0951..2544e9c 100644 --- a/package.json +++ b/package.json @@ -28,31 +28,29 @@ "types": "./lib/jsonquery.d.ts" } }, - "files": [ - "lib", - "LICENSE.md", - "README.md" - ], + "files": ["lib", "LICENSE.md", "README.md"], "scripts": { "test": "vitest src", "test-ci": "vitest run src", + "coverage": "vitest run src --coverage", "build": "npm-run-all build:**", "build:esm": "vite build", "build:types": "tsc --project tsconfig-types.json", "build:validate": "vitest run test-lib", - "lint": "prettier . --check", - "format": "prettier . --write", + "lint": "biome check", + "format": "biome check --write", "format:readme": "prettier README.md --write --ignore-path notneeded", "build-and-test": "npm-run-all test-ci lint build", "prepublishOnly": "npm run build-and-test", "release": "semantic-release" }, "devDependencies": { + "@biomejs/biome": "1.9.1", + "@vitest/coverage-v8": "2.1.1", "npm-run-all": "4.1.5", - "prettier": "3.3.3", - "semantic-release": "24.1.0", - "typescript": "5.5.4", - "vite": "5.4.2", - "vitest": "2.0.5" + "semantic-release": "24.1.1", + "typescript": "5.6.2", + "vite": "5.4.6", + "vitest": "2.1.1" } } diff --git a/reference/functions.md b/reference/functions.md index 0eb8720..8720ef5 100644 --- a/reference/functions.md +++ b/reference/functions.md @@ -1,21 +1,116 @@ -# Function reference +# Functions -This reference contains two types of functions: +This reference lists all functions and operators. -- [_methods_](#methods) which are applied to a data input such as an array. For example: `map`, `filter`, `sort`. -- [_functions_](#functions) which only execute the arguments provided in the query. For example: `eq`, `lt`, `add`, `subtract`. +## pipe (`|`) -## Methods +The pipe operator executes a series of query operations one by one, and the output of the first is the input for the next. -### get +```text +query1 | query2 | ... +pipe(query1, query2, ...) +``` -Get a path from an object. +Examples: + +```js +const data = [ + { "name": "Chris", "age": 23, "address": { "city": "New York" } }, + { "name": "Emily", "age": 19, "address": { "city": "Atlanta" } }, + { "name": "Michelle", "age": 27, "address": { "city": "Los Angeles" } } +] + +jsonquery(data, 'sort(.age) | pick(.name, .age)') +// [ +// { "name": "Emily", "age": 19 }, +// { "name": "Chris", "age": 23 }, +// { "name": "Michelle", "age": 27 } +// ] +``` + +## object + +Create an object. + +```text +{ prop1: query1, prop2: query2, ...} +object({ prop1: query1, prop2: query2, ...}) +``` + +Examples: + +```js +const data = [ + { "name": "Chris", "age": 23, "address": { "city": "New York" } }, + { "name": "Emily", "age": 19, "address": { "city": "Atlanta" } }, + { "name": "Michelle", "age": 27, "address": { "city": "Los Angeles" } } +] + +jsonquery(data, '{ names: map(.name), total: size() }') +// { +// "names": ["Chris", "Emily", "Michelle"], +// "total" 3 +// } + + +jsonquery(data, 'map({ firstName: .name, city: .address.city})') +// [ +// { "firstName": "Chris", "city": "New York" }, +// { "firstName": "Emily", "city": "Atlanta" }, +// { "firstName": "Michelle", "city": "Los Angeles" } +// ] +``` + +## array + +Create an array + +```text +[query1, query2, ...] +array(query2, query2, ...) +``` + +Examples: ```js -["get", ...props] +const data = [ + { "name": "Chris", "age": 16 }, + { "name": "Emily", "age": 32 }, + { "name": "Joe", "age": 18 } +] + +jsonquery(data, 'filter(.age in [16, 18])') +// [ +// { "name": "Chris", "age": 16 }, +// { "name": "Joe", "age": 18 } +// ] + +const locations: [ + {"latitude": 52.33, "longitude": 4.01}, + {"latitude": 52.18, "longitude": 3.99}, + {"latitude": 51.97, "longitude": 4.05} +] + +jsonquery(locations, 'map([.latitude, .longitude])') +// [ +// [52.33, 4.01], +// [52.18, 3.99], +// [51.97, 4.05] +// ] +``` + +## get + +Get a path from an object. + +```text +.prop1 +.prop1.prop2 +."prop1" +get(prop1, prop2, ...) ``` -For example `["get", "age"]` gets the property `age` from an object, and `["get", "address", "city"]` gets a nested property `city` inside an object `address`. To get the current value or object itself, just specify `["get"]` without properties. +For example `.age` gets the property `age` from an object, and `.address.city` gets a nested property `city` inside an object `address`. To get the current value or object itself use function `get()` without properties. Examples: @@ -28,16 +123,16 @@ const data = { } } -jsonquery(data, ["get", "name"]) // "Joe" -jsonquery(data, ["get", "address", "city"]) // "New York" +jsonquery(data, '.name') // "Joe" +jsonquery(data, '.address.city') // "New York" ``` -### filter +## filter Filter a list with objects or values. -```js -["filter", condition] +```text +filter(condition) ``` Examples: @@ -53,39 +148,35 @@ const data = [ { "name": "Sarah", "age": 31, "address": { "city": "New York" } } ] -jsonquery(data, ["filter", ["gt", ["get", "age"], 30]]) +jsonquery(data, 'filter(.age > 30)') // [ // { "name": "Joe", "age": 32, "address": { "city": "New York" } }, // { "name": "Robert", "age": 45, "address": { "city": "Manhattan" } }, // { "name": "Sarah", "age": 31, "address": { "city": "New York" } } // ] -jsonquery(data, ["filter", ["eq", ["get", "address", "city"], "New York"]]) +jsonquery(data, 'filter(.address.city == "new York")') // [ // { "name": "Chris", "age": 23, "address": { "city": "New York" } }, // { "name": "Joe", "age": 32, "address": { "city": "New York" } }, // { "name": "Sarah", "age": 31, "address": { "city": "New York" } } // ] -jsonquery(data, ["filter", [ - "and", - ["gt", ["get", "age"], 30], - ["eq", ["get", "city"], "New York"] -]]) +jsonquery(data, 'filter((.age > 30) and (.address.city == "New York"))') // [ // { "name": "Joe", "age": 32, "address": { "city": "New York" } }, // { "name": "Sarah", "age": 31, "address": { "city": "New York" } } // ] ``` -### sort +## sort Sort a list with objects or values. -```js -["sort"] -["sort", getter] -["sort", getter, direction] +```text +sort() +sort(property) +sort(property, direction) ``` Examples: @@ -97,21 +188,21 @@ const data = [ { "name": "Michelle", "age": 27, "address": { "city": "Los Angeles" } } ] -jsonquery(data, ["sort", ["get", "age"]]) +jsonquery(data, 'sort(.age)') // [ // { "name": "Emily", "age": 19, "address": { "city": "Atlanta" } }, // { "name": "Chris", "age": 23, "address": { "city": "New York" } }, // { "name": "Michelle", "age": 27, "address": { "city": "Los Angeles" } } // ] -jsonquery(data, ["sort", ["get", "age"], "desc"]) +jsonquery(data, 'sort(.age, "desc")') // [ // { "name": "Michelle", "age": 27, "address": { "city": "Los Angeles" } }, // { "name": "Chris", "age": 23, "address": { "city": "New York" } }, // { "name": "Emily", "age": 19, "address": { "city": "Atlanta" } } // ] -jsonquery(data, ["sort", ["get", "address", "city"]]) +jsonquery(data, 'sort(.address.city)') // [ // { "name": "Emily", "age": 19, "address": { "city": "Atlanta" } }, // { "name": "Michelle", "age": 27, "address": { "city": "Los Angeles" } }, @@ -120,16 +211,16 @@ jsonquery(data, ["sort", ["get", "address", "city"]]) const values = [7, 2, 9] -jsonquery(values, ["sort"]) // [2, 7, 9] -jsonquery(values, ["sort", ["get"], "desc"]) // [9, 7, 2] +jsonquery(values, 'sort()') // [2, 7, 9] +jsonquery(values, 'sort(get(), "desc")') // [9, 7, 2] ``` -### pick +## pick Pick one or multiple properties or paths, and create a new, flat object for each of them. Can be used on both an object or an array. -```js -["pick", ...getters] +```text +pick(property1, property2, ...) ``` Examples: @@ -141,14 +232,14 @@ const data = [ { "name": "Michelle", "age": 27, "address": { "city": "Los Angeles" } } ] -jsonquery(data, ["pick", ["get", "age"]]) +jsonquery(data, 'pick(.age)') // [ // { "age": 23 }, // { "age": 19 }, // { "age": 27 } // ] -jsonquery(data, ["pick", ["get", "name"], ["get", "address", "city"]]) +jsonquery(data, 'pick(.name, .address.city)') // [ // { "name": "Chris", "city": "New York" }, // { "name": "Emily", "city": "Atlanta" }, @@ -157,15 +248,15 @@ jsonquery(data, ["pick", ["get", "name"], ["get", "address", "city"]]) const item = { "price": 25 } -jsonquery(item, ["pick", ["get", "price"]]) // 25 +jsonquery(item, 'pick(.price)') // 25 ``` -### map +## map Map over an array and apply the provided query to each of the items in the array. -```js -["map", query] +```text +map(query) ``` Examples: @@ -177,13 +268,10 @@ const data = [ { "name": "Joe", "scores": [1, 1, 5, 6] } ] -jsonquery(data, ["map", { - "firstName": ["get", "name"], - "maxScore": [ - ["get", "scores"], - ["max"] - ] -}]) +jsonquery(data, `map({ + firstName: .name, + maxScore: .scores | max() +})`) // [ // {"firstName": "Chris", "maxScore": 7}, // {"firstName": "Emily", "maxScore": 8}, @@ -194,19 +282,16 @@ const cart = [ {"name": "bread", "price": 2.5, "quantity": 2}, {"name": "milk" , "price": 1.2, "quantity": 3} ] -jsonquery(data, [ - ["map", ["multiply", ["get", "price"], ["get", "quantity"]]], - ["sum"] -]) +jsonquery(data, 'map(.price * .quantity)') // 8.6 ``` -### groupBy +## groupBy Group a list with objects grouped by the value of given path. This creates an object with the different properties as key, and an array with all items having that property as value. -```js -["groupBy", getter] +```text +groupBy(property) ``` Examples: @@ -222,7 +307,7 @@ const data = [ { "name": "Sarah", "city": "New York" } ] -jsonquery(data, ["groupBy", ["get", "city"]]) +jsonquery(data, 'groupBy(.city)') // { // "New York": [ // {"name": "Chris", "city": "New York"}, @@ -242,12 +327,12 @@ jsonquery(data, ["groupBy", ["get", "city"]]) // } ``` -### keyBy +## keyBy Turn an array with objects into an object by key. When there are multiple items with the same key, the first item will be kept. -```js -["keyBy", getter] +```text +keyBy(property) ``` Examples: @@ -259,7 +344,7 @@ const data = [ { id: 3, name: 'Chris' } ] -jsonquery(data, ["keyBy", ["get", "id"]]) +jsonquery(data, 'keyBy(.id)') // { // 1: { id: 1, name: 'Joe' }, // 2: { id: 2, name: 'Sarah' }, @@ -267,12 +352,12 @@ jsonquery(data, ["keyBy", ["get", "id"]]) // } ``` -### keys +## keys Return an array with the keys of an object. -```json -["keys"] +```text +keys() ``` Examples: @@ -286,15 +371,15 @@ const data = { } } -jsonquery(data, ["keys"]) // ["name", "age", "address"] +jsonquery(data, 'keys()') // ["name", "age", "address"] ``` -### values +## values Return the values of an object. -```json -["values"] +```text +values() ``` Examples: @@ -306,15 +391,15 @@ const data = { "city": "New York" } -jsonquery(data, ["values"]) // ["Joe", 32, "New York"] +jsonquery(data, 'values()') // ["Joe", 32, "New York"] ``` -### flatten +## flatten Flatten an array containing arrays. -```json -["flatten"] +```text +flatten() ``` Examples: @@ -322,29 +407,33 @@ Examples: ```js const data = [[1, 2], [3, 4]] -jsonquery(data, ["flatten"]) // [1, 2, 3, 4] +jsonquery(data, 'flatten()') // [1, 2, 3, 4] const data2 = [[1, 2, [3, 4]]] -jsonquery(data2, ["flatten"]) // [1, 2, [3, 4]] +jsonquery(data2, 'flatten()') // [1, 2, [3, 4]] ``` -### uniq +## uniq Create a copy of an array where all duplicates are removed. -```js -["uniq"] +```text +uniq() +``` -jsonquery([1, 5, 3, 3, 1], ["uniq"]) // [1, 3, 5] +Example: + +```js +jsonquery([1, 5, 3, 3, 1], 'uniq()') // [1, 3, 5] ``` -### uniqBy +## uniqBy Create a copy of an array where all objects with a duplicate value for the selected path are removed. In case of duplicates, the first object is kept. -```js -["uniqBy", getter] +```text +uniqBy(property) ``` Examples: @@ -360,7 +449,7 @@ const data = [ { "name": "Sarah", "age": 31, "address": { "city": "New York" } } ] -jsonquery(data, ["uniqBy", ["get", "address", "city"]]) +jsonquery(data, 'uniqBy(.address.city)') // [ // { "name": "Chris", "age": 23, "address": { "city": "New York" } }, // { "name": "Emily", "age": 19, "address": { "city": "Atlanta" } }, @@ -369,12 +458,12 @@ jsonquery(data, ["uniqBy", ["get", "address", "city"]]) // ] ``` -### limit +## limit Create a copy of an array cut off at the selected limit. -```js -["limit", size] +```text +limit(size) ``` Examples: @@ -382,108 +471,107 @@ Examples: ```js const data = [1, 2, 3, 4, 5, 6] -jsonquery(data, ["limit", 2]) // [1, 2] -jsonquery(data, ["limit", 4]) // [1, 2, 3, 4] +jsonquery(data, 'limit(2)') // [1, 2] +jsonquery(data, 'limit(4)') // [1, 2, 3, 4] ``` -### size +## size Return the size of an array. -```json -["size"] +```text +size() ``` Examples: ```js -jsonquery([1, 2], ["size"]) // 2 -jsonquery([1, 2, 3, 4], ["size"]) // 4 +jsonquery([1, 2], 'size()') // 2 +jsonquery([1, 2, 3, 4], 'size()') // 4 ``` -### sum +## sum Calculate the sum of all values in an array. -```json -["sum"] +```text +sum() ``` Examples: ```js -jsonquery([7, 4, 2], ["sum"]) // 13 -jsonquery([2.4, 5.7], ["sum"]) // 8.1 +jsonquery([7, 4, 2], 'sum()') // 13 +jsonquery([2.4, 5.7], 'sum()') // 8.1 ``` -### min +## min Return the minimum of the values in an array. -```json -["min"] +```text +min() ``` Examples: ```js -jsonquery([5, 1, 1, 6], ["min"]) // 1 -jsonquery([5, 7, 3], ["min"]) // 3 +jsonquery([5, 1, 1, 6], 'min()') // 1 +jsonquery([5, 7, 3], 'min()') // 3 ``` -### max +## max Return the maximum of the values in an array. -```json -["max"] +```text +max() ``` Examples: ```js -jsonquery([1, 1, 6, 5], ["max"]) // 6 -jsonquery([5, 7, 3], ["max"]) // 7 +jsonquery([1, 1, 6, 5], 'max()') // 6 +jsonquery([5, 7, 3], 'max()') // 7 ``` -### prod +## prod Calculate the product of the values in an array. -```json -["prod"] +```text +prod() ``` Examples: ```js -jsonquery([2, 3], ["prod"]) // 6 -jsonquery([2, 3, 2, 7, 1, 1], ["prod"]) // 84 +jsonquery([2, 3], 'prod()') // 6 +jsonquery([2, 3, 2, 7, 1, 1], 'prod()') // 84 ``` -### average +## average Calculate the average of the values in an array. -```json -["average"] +```text +average() ``` Examples: ```js -jsonquery([2, 4], ["average"]) // 3 -jsonquery([2, 3, 2, 7, 1], ["average"]) // 3 +jsonquery([2, 4], 'average()') // 3 +jsonquery([2, 3, 2, 7, 1], 'average()') // 3 ``` -## Functions - -### equal (`eq`) +## equal (`==`) Test whether two values are strictly equal. This will consider a string `"2"` and a number `2` to be _not_ equal for example since their data type differs. -```js -["eq", a, b] +```text +a == b +eq(a, b) ``` Examples: @@ -495,23 +583,25 @@ const data = [ { "name": "Kevin", "age": 18 } ] -jsonquery(data, ["filter", ["eq", ["get", "age"], 18]]) +jsonquery(data, 'filter(.age == 18)') // [ // { "name": "Emily", "age": 18 }, // { "name": "Kevin", "age": 18 } // ] -jsonquery({ a: 2 }, ["eq", ["get"], "a", 2]) // true -jsonquery({ a: 2 }, ["eq", ["get"], "a", 3]) // false -jsonquery({ a: 2 }, ["eq", ["get"], "a", "2"]) // false (since not strictly equal) +jsonquery({ a: 2 }, '.a == 2') // true +jsonquery({ a: 2 }, '.a == 3') // false +jsonquery({ a: 2 }, '.a == "2"') // false (since not strictly equal) +jsonquery({ a: 2 }, 'eq(.a, 2)') // true ``` -### greater than (`gt`) +## greater than (`>`) Test whether `a` is greater than `b`. -```js -["gt", a, b] +```text +a > b +gt(a, b) ``` Examples: @@ -523,18 +613,19 @@ const data = [ { "name": "Joe", "age": 18 } ] -jsonquery(data, ["filter", ["gt", ["get", "age"], 18]]) +jsonquery(data, 'filter(.age > 18)') // [ // { "name": "Emily", "age": 32 } // ] ``` -### greater than or equal to (`gte`) +## greater than or equal to (`>=`) Test whether `a` is greater than or equal to `b`. -```js -["gte", a, b] +```text +a >= b +gte(a, b) ``` Examples: @@ -546,19 +637,20 @@ const data = [ { "name": "Joe", "age": 18 } ] -jsonquery(data, ["filter", ["gte", ["get", "age"], 18]]) +jsonquery(data, 'filter(.age >= 18)') // [ // { "name": "Emily", "age": 32 }, // { "name": "Joe", "age": 18 } // ] ``` -### less than (`lt`) +## less than (`<`) Test whether `a` is less than `b`. -```js -["lt", a, b] +```text +a < b +lt(a, b) ``` Examples: @@ -570,18 +662,19 @@ const data = [ { "name": "Joe", "age": 18 } ] -jsonquery(data, ["filter", ["lt", ["get", "age"], 18]]) +jsonquery(data, 'filter(.age < 18)') // [ // { "name": "Chris", "age": 16 } // ] ``` -### less than or equal to (`lte`) +## less than or equal to (`<=`) Test whether `a` is less than or equal to `b`. -```js -["lte", a, b] +```text +a <= b +lte(a, b) ``` Examples: @@ -593,19 +686,20 @@ const data = [ { "name": "Joe", "age": 18 } ] -jsonquery(data, ["filter", ["lte", ["get", "age"], 18]]) +jsonquery(data, ["filter", 'filter(.age <= 18)') // [ // { "name": "Chris", "age": 16 }, // { "name": "Joe", "age": 18 } // ] ``` -### not equal (`ne`) +## not equal (`!=`) Test whether two values are unequal. This is the opposite of the strict equal function `eq`. Two values are considered unequal when their data type differs (for example one is a string and another is a number), or when the value itself is different. For example a string `"2"` and a number `2` are considered unequal, even though their mathematical value is equal. -```js -["ne", a, b] +```text +a != b +ne(a, b) ``` Examples: @@ -617,23 +711,24 @@ const data = [ { "name": "Joe", "age": 18 } ] -jsonquery(data, ["filter", ["ne", ["get", "age"], 16]]) +jsonquery(data, 'filter(.age != 16)') // [ // { "name": "Emily", "age": 32 }, // { "name": "Joe", "age": 18 } // ] -jsonquery({ a: 2 }, ["ne", ["get", "a"], 2]) // false -jsonquery({ a: 2 }, ["ne", ["get", "a"], 3]) // true -jsonquery({ a: 2 }, ["ne", ["get", "a"], "2"]) // true (since not strictly equal) +jsonquery({ a: 2 }, 'a != 2') // false +jsonquery({ a: 2 }, 'a != 3') // true +jsonquery({ a: 2 }, 'a != "2"') // true (since not strictly equal) ``` -### and +## and Test whether both values are truthy. A non-truthy value is any of `false`, `0`, `""`, `null`, or `undefined`. -```js -["and", a, b] +```text +a and b +and(a, b) ``` Examples: @@ -642,25 +737,22 @@ Examples: const data = [ { "name": "Chris", "age": 16 }, { "name": "Emily", "age": 32 }, - { "name": "Joe", "age": 18 } + { "name": "Chris", "age": 18 } ] -jsonquery(data, ["filter", [ - "and", - ["eq", ["get", "name"], "Chris"], - ["eq", ["get", "age"], 16] -]]) +jsonquery(data, 'filter((.name == "Chris") and (.age == 16))') // [ // { "name": "Chris", "age": 16 } // ] ``` -### or +## or Test whether one or both values are truthy. A non-truthy value is any of `false`, `0`, `""`, `null`, or `undefined`. -```js -["or", a, b] +```text +a or b +or(a, b) ``` Examples: @@ -672,23 +764,19 @@ const data = [ { "name": "Joe", "age": 18 } ] -jsonquery(data, ["filter", [ - "or", - ["eq", ["get", "age"], 16], - ["eq", ["get", "age"], 18], -]]) +jsonquery(data, 'filter((.age == 16) or (.age == 18))') // [ // { "name": "Chris", "age": 16 }, // { "name": "Joe", "age": 18 } // ] ``` -### not +## not Function `not` inverts the value. When the value is truthy it returns `false`, and otherwise it returns `true`. -```js -["not", value] +```text +not(value) ``` Examples: @@ -700,19 +788,19 @@ const data = [ { "name": "Joe", "age": 18 } ] -jsonquery(data, ["filter", ["not", ["eq", ["get", "age"], 18]]]) +jsonquery(data, 'filter(not(.age == 18))') // [ // { "name": "Chris", "age": 16 }, // { "name": "Emily", "age": 32 } // ] ``` -### exists +## exists Returns true if the value at the provided path exists, and returns false when it is `undefined`. -```js -["exists", path] +```text +exists(path) ``` Examples: @@ -724,7 +812,7 @@ const data = [ { "name": "Joe", "details": { "age": 18 } } ] -jsonquery(data, ["filter", ["exists", ["get", "details"]]]) +jsonquery(data, 'filter(exists(.details))') // [ // { "name": "Chris", "details": { "age": 16 } }, // { "name": "Joe", "details": { "age": 18 } } @@ -733,12 +821,13 @@ jsonquery(data, ["filter", ["exists", ["get", "details"]]]) jsonquery({ "value": null }, ["exists", "value"]) // true ``` -### in +## in Test whether the search value is one of the values of the provided list. -```js -["in", searchValue, ...values] +```text +searchValue in values +in(searchValue, values) ``` Examples: @@ -750,19 +839,19 @@ const data = [ { "name": "Joe", "age": 18 } ] -jsonquery(data, ["filter", ["in", ["get", "age"], [16, 18]]]) +jsonquery(data, 'filter(.age in [16, 18])') // [ // { "name": "Chris", "age": 16 }, // { "name": "Joe", "age": 18 } // ] ``` -### not in +## not in Test whether the search value is _not_ one of the values of the provided list. -```js -["not in", searchValue, ...values] +```text +searchValue not in values ``` Examples: @@ -774,19 +863,19 @@ const data = [ { "name": "Joe", "age": 18 } ] -jsonquery(data, ["filter", ["not in", ["get", "age"], [16, 18]]]) +jsonquery(data, ["filter", 'filter(.age not in [16, 18])') // [ // { "name": "Emily", "age": 32 } // ] ``` -### regex +## regex Test the `text` against the regular expression. -```js -["regex", text, expression] -["regex", text, expression, options] +```text +regex(text, expression) +regex(text, expression, options) ``` Here, `expression` is a string containing the regular expression like `^[a-z]+$`, and `options` are regular expression flags like `i`. @@ -801,13 +890,13 @@ const data = [ { "id": 4, "message": "We like it a lot" } ] -jsonquery(data, ["filter", ["regex", ["get", "message"], "like|awesome"]]) +jsonquery(data, 'filter(regex(.message, "like|awesome"))') // [ // { "id": 2, "message": "It is awesome!" }, // { "id": 4, "message": "We like it a lot" } // ] -jsonquery(data, ["filter", ["regex", ["get", "message"], "like|awesome", "i"]]) +jsonquery(data, 'filter(regex(.message, "like|awesome", "i"))') // [ // { "id": 1, "message": "I LIKE it!" }, // { "id": 2, "message": "It is awesome!" }, @@ -815,12 +904,13 @@ jsonquery(data, ["filter", ["regex", ["get", "message"], "like|awesome", "i"]]) // ] ``` -### add +## add (`+`) Add two values. -```js -["add", a, b] +```text +a + b +add(a, b) ``` Examples: @@ -828,15 +918,16 @@ Examples: ```js const data = { "a": 6, "b": 2 } -jsonquery(data, ["add", ["get", "a"], ["get", "b"]]) // 8 +jsonquery(data, '.a + .b') // 8 ``` -### subtract +## subtract (`-`) Subtract two values. -```js -["subtract", a, b] +```text +a - b +subtract(a, b) ``` Examples: @@ -844,15 +935,16 @@ Examples: ```js const data = { "a": 6, "b": 2 } -jsonquery(data, ["subtract", ["get", "a"], ["get", "b"]]) // 4 +jsonquery(data, '.a - .b') // 4 ``` -### multiply +## multiply (`*`) Multiply two values. -```js -["multiply", a, b] +```text +a * b +multiply(a, b) ``` Examples: @@ -860,15 +952,16 @@ Examples: ```js const data = { "a": 6, "b": 2 } -jsonquery(data, ["multiply", ["get", "a"], ["get", "b"]]) // 12 +jsonquery(data, '.a * .b') // 12 ``` -### divide +## divide (`/`) Divide two values. -```js -["divide", a, b] +```text +a / b +divide(a, b) ``` Examples: @@ -876,15 +969,16 @@ Examples: ```js const data = { "a": 6, "b": 2 } -jsonquery(data, ["divide", ["get", "a"], ["get", "b"]]) // 3 +jsonquery(data, '.a / .b') // 3 ``` -### power (`pow`) +## power (`^`) Calculate the exponent. Returns the result of raising `a` to the power of `b`, like `a^b` -```js -["pow", a, b] +```text +a ^ b +pow(a, b) ``` Examples: @@ -892,15 +986,16 @@ Examples: ```js const data = { "a": 2, "b": 3 } -jsonquery(data, ["pow", ["get", "a"], ["get", "b"]]) // 8 +jsonquery(data, '.a ^ .b') // 8 ``` -### remainder (`mod`) +## remainder (`%`) Calculate the remainder (the modulus) of `a` divided by `b`, like `a % b`. -```js -["mod", a, b] +```text +a % b +mod(a, b) ``` Examples: @@ -908,37 +1003,37 @@ Examples: ```js const data = { "a": 8, "b": 3 } -jsonquery(data, ["mod", ["get", "a"], ["get", "b"]]) // 2 +jsonquery(data, '.a % .b') // 2 ``` -### abs +## abs Calculate the absolute value. -```js -["abs", value] +```text +abs(value) ``` Examples: ```js -jsonquery({"a": -7}, ["abs", ["get", "a"]]) // 7 +jsonquery({"a": -7}, 'abs(.a)') // 7 ``` -### round +## round Round a value. When `digits` is provided, the value will be rounded to the selected number of digits. -```js -["round", value] -["round", value, digits] +```text +round(value) +round(value, digits) ``` Examples: ```js -jsonquery({"a": 23.7612 }, ["round", ["get", "a"]]) // 24 -jsonquery({"a": 23.1345 }, ["round", ["get", "a"]]) // 23 -jsonquery({"a": 23.1345 }, ["round", ["get", "a"], 2]) // 23.13 -jsonquery({"a": 23.1345 }, ["round", ["get", "a"], 3]) // 23.135 +jsonquery({"a": 23.7612 }, 'round(.a)') // 24 +jsonquery({"a": 23.1345 }, 'round(.a)') // 23 +jsonquery({"a": 23.1345 }, 'round(.a, 2)') // 23.13 +jsonquery({"a": 23.1345 }, 'round(.a, 3)') // 23.135 ``` diff --git a/src/buildFunction.ts b/src/buildFunction.ts deleted file mode 100644 index 470065d..0000000 --- a/src/buildFunction.ts +++ /dev/null @@ -1,18 +0,0 @@ -// TODO: move compileArgs to compile.ts? -import { FunctionBuilder, JSONQuery } from './types' -import { compile } from './compile' - -export function buildFunction(fn: (...args: unknown[]) => unknown): FunctionBuilder { - return (...args: JSONQuery[]) => { - const compiledArgs = args.map((arg) => compile(arg)) - - const arg0 = compiledArgs[0] - const arg1 = compiledArgs[1] - - return compiledArgs.length === 1 - ? (data: unknown) => fn(arg0(data)) - : compiledArgs.length === 2 - ? (data: unknown) => fn(arg0(data), arg1(data)) - : (data: unknown) => fn(...compiledArgs.map((arg) => arg(data))) - } -} diff --git a/src/compile.test.ts b/src/compile.test.ts new file mode 100644 index 0000000..5447b6f --- /dev/null +++ b/src/compile.test.ts @@ -0,0 +1,944 @@ +import { describe, expect, test } from 'vitest' +import { compile } from './compile' +import { buildFunction } from './functions' +import type { JSONQuery, JSONQueryCompileOptions } from './types' + +const data = [ + { name: 'Chris', age: 23, city: 'New York' }, + { name: 'Emily', age: 19, city: 'Atlanta' }, + { name: 'Joe', age: 32, city: 'New York' }, + { name: 'Kevin', age: 19, city: 'Atlanta' }, + { name: 'Michelle', age: 27, city: 'Los Angeles' }, + { name: 'Robert', age: 45, city: 'Manhattan' }, + { name: 'Sarah', age: 31, city: 'New York' } +] + +const friendsData = { + friends: data +} + +const nestedData = [ + { name: 'Chris', age: 23, address: { city: 'New York' } }, + { name: 'Emily', age: 19, address: { city: 'Atlanta' } }, + { name: 'Joe', age: 32, address: { city: 'New York' } }, + { name: 'Kevin', age: 19, address: { city: 'Atlanta' } }, + { name: 'Michelle', age: 27, address: { city: 'Los Angeles' } }, + { name: 'Robert', age: 45, address: { city: 'Manhattan' } }, + { name: 'Sarah', age: 31, address: { city: 'New York' } } +] + +const scoresData = [ + { name: 'Chris', scores: [5, 7, 3] }, + { name: 'Emily', scores: [8, 5, 2, 5] }, + { name: 'Joe', scores: [1, 1, 5, 6] } +] + +/** + * Compile and execute + */ +function go(data: unknown, query: JSONQuery, options?: JSONQueryCompileOptions) { + const exec = compile(query, options) + return exec(data) +} + +describe('compile', () => { + describe('prop', () => { + test('should get a path with a single property as string', () => { + expect(go({ name: 'Joe' }, ['get', 'name'])).toEqual('Joe') + }) + + test('should get the full object itself', () => { + expect(go({ name: 'Joe' }, ['get'])).toEqual({ name: 'Joe' }) + expect(go(2, ['get'])).toEqual(2) + }) + + test('should return undefined in case of a non existing path', () => { + expect(go({}, ['get', 'foo', 'bar'])).toEqual(undefined) + }) + + test('should get a path using function get', () => { + expect(go({ name: 'Joe' }, ['get', 'name'])).toEqual('Joe') + }) + + test('should get a path that has the same name as a function', () => { + expect(go({ sort: 'Joe' }, ['get', 'sort'])).toEqual('Joe') + }) + + test('should get a nested value that has the same name as a function', () => { + expect(go({ sort: { name: 'Joe' } }, ['get', 'sort', 'name'])).toEqual('Joe') + }) + + test('should get in item from an array', () => { + expect(go(['A', 'B', 'C'], ['get', 1])).toEqual('B') + expect(go({ arr: ['A', 'B', 'C'] }, ['get', 'arr', 1])).toEqual('B') + expect(go([{ text: 'A' }, { text: 'B' }, { text: 'C' }], ['get', 1, 'text'])).toEqual('B') + }) + }) + + test('should execute a function', () => { + expect(go([3, 1, 2], ['sort'])).toEqual([1, 2, 3]) + }) + + describe('object', () => { + test('should create an object', () => { + expect( + go({ a: 2, b: 3 }, [ + 'object', + { + aa: ['get', 'a'], + bb: 42 + } + ]) + ).toEqual({ + aa: 2, + bb: 42 + }) + }) + + test('should create a nested object', () => { + expect( + go(data, [ + 'object', + { + names: ['map', ['get', 'name']], + stats: [ + 'object', + { + count: ['size'], + averageAge: ['pipe', ['map', ['get', 'age']], ['average']] + } + ] + } + ]) + ).toEqual({ + names: ['Chris', 'Emily', 'Joe', 'Kevin', 'Michelle', 'Robert', 'Sarah'], + stats: { + count: 7, + averageAge: 28 + } + }) + }) + }) + + describe('array', () => { + test('should create an array', () => { + expect(go(null, ['array', 1, 2, 3])).toEqual([1, 2, 3]) + expect(go(null, ['array', ['add', 1, 3], 2, 4])).toEqual([4, 2, 4]) + }) + }) + + describe('pipe', () => { + test('should execute a pipeline', () => { + expect(go({ user: { name: 'Joe' } }, ['pipe', ['get', 'user'], ['get', 'name']])).toEqual( + 'Joe' + ) + }) + + test('should create an object containing pipelines', () => { + expect( + go(data, [ + 'object', + { + names: ['map', ['get', 'name']], + count: ['size'], + averageAge: ['pipe', ['map', ['get', 'age']], ['average']] + } + ]) + ).toEqual({ + names: ['Chris', 'Emily', 'Joe', 'Kevin', 'Michelle', 'Robert', 'Sarah'], + count: 7, + averageAge: 28 + }) + }) + + test('should throw a helpful error when a pipe contains a compile time error', () => { + let actualErr = undefined + try { + go(data, ['foo', 42]) + } catch (err) { + actualErr = err + } + + expect(actualErr?.message).toBe("Unknown function 'foo'") + }) + + test('should throw a helpful error when a pipe contains a runtime error', () => { + const scoreData = { + participants: [ + { name: 'Chris', age: 23, scores: [7.2, 5, 8.0] }, + { name: 'Emily', age: 19 }, + { name: 'Joe', age: 32, scores: [6.1, 8.1] } + ] + } + const query = ['pipe', ['get', 'participants'], ['map', ['pipe', ['get', 'scores'], ['sum']]]] + + let actualErr = undefined + try { + go(scoreData, query) + } catch (err) { + actualErr = err + } + + expect(actualErr?.message).toBe("Cannot read properties of undefined (reading 'reduce')") + expect(actualErr?.jsonquery).toEqual([ + { data: scoreData, query }, + { + data: scoreData.participants, + query: ['map', ['pipe', ['get', 'scores'], ['sum']]] + }, + { data: { name: 'Emily', age: 19 }, query: ['pipe', ['get', 'scores'], ['sum']] }, + { data: undefined, query: ['sum'] } + ]) + }) + }) + + describe('map', () => { + test('should map over an array', () => { + expect( + go(scoresData, [ + 'pipe', + [ + 'map', + [ + 'object', + { + name: ['get', 'name'], + maxScore: ['pipe', ['get', 'scores'], ['max']], + minScore: ['pipe', ['get', 'scores'], ['min']] + } + ] + ], + ['sort', ['get', 'maxScore'], 'desc'] + ]) + ).toEqual([ + { name: 'Emily', maxScore: 8, minScore: 2 }, + { name: 'Chris', maxScore: 7, minScore: 3 }, + { name: 'Joe', maxScore: 6, minScore: 1 } + ]) + }) + + test('should map a path', () => { + expect(go(data, ['map', ['get', 'name']])).toEqual([ + 'Chris', + 'Emily', + 'Joe', + 'Kevin', + 'Michelle', + 'Robert', + 'Sarah' + ]) + }) + + test('should map over an array using pick', () => { + expect(go(data, ['map', ['pick', ['get', 'name']]])).toEqual([ + { name: 'Chris' }, + { name: 'Emily' }, + { name: 'Joe' }, + { name: 'Kevin' }, + { name: 'Michelle' }, + { name: 'Robert' }, + { name: 'Sarah' } + ]) + }) + }) + + test('should flatten an array', () => { + expect( + go( + [ + [1, 2], + [3, 4, 5] + ], + ['flatten'] + ) + ).toEqual([1, 2, 3, 4, 5]) + }) + + test('should resolve an function', () => { + expect(go([], ['and', true, false])).toEqual(false) + expect(go([], ['or', true, false])).toEqual(true) + expect(go({ city: 'New York' }, ['eq', ['get', 'city'], 'New York'])).toEqual(true) + }) + + describe('filter', () => { + test('should filter data using equal', () => { + expect(go(data, ['filter', ['eq', ['get', 'city'], 'New York']])).toEqual([ + { name: 'Chris', age: 23, city: 'New York' }, + { name: 'Joe', age: 32, city: 'New York' }, + { name: 'Sarah', age: 31, city: 'New York' } + ]) + }) + + test('should filter nested data using equal', () => { + expect(go(nestedData, ['filter', ['eq', ['get', 'address', 'city'], 'New York']])).toEqual([ + { name: 'Chris', age: 23, address: { city: 'New York' } }, + { name: 'Joe', age: 32, address: { city: 'New York' } }, + { name: 'Sarah', age: 31, address: { city: 'New York' } } + ]) + }) + + test('should filter multiple conditions (and)', () => { + expect( + go(nestedData, [ + 'pipe', + ['filter', ['gt', ['get', 'age'], 30]], + ['filter', ['eq', ['get', 'address', 'city'], 'New York']] + ]) + ).toEqual([ + { name: 'Joe', age: 32, address: { city: 'New York' } }, + { name: 'Sarah', age: 31, address: { city: 'New York' } } + ]) + }) + + test('should filter with a condition being a function', () => { + expect(go(scoresData, ['filter', ['gte', ['pipe', ['get', 'scores'], ['max']], 7]])).toEqual([ + { name: 'Chris', scores: [5, 7, 3] }, + { name: 'Emily', scores: [8, 5, 2, 5] } + ]) + }) + + test('should filter data using ne', () => { + expect(go(data, ['filter', ['ne', ['get', 'city'], 'New York']])).toEqual([ + { name: 'Emily', age: 19, city: 'Atlanta' }, + { name: 'Kevin', age: 19, city: 'Atlanta' }, + { name: 'Michelle', age: 27, city: 'Los Angeles' }, + { name: 'Robert', age: 45, city: 'Manhattan' } + ]) + }) + + test('should filter data using gt', () => { + expect(go(data, ['filter', ['gt', ['get', 'age'], 45]])).toEqual([]) + }) + + test('should filter data using gte', () => { + expect(go(data, ['filter', ['gte', ['get', 'age'], 45]])).toEqual([ + { name: 'Robert', age: 45, city: 'Manhattan' } + ]) + }) + + test('should filter data using lt', () => { + expect(go(data, ['filter', ['lt', ['get', 'age'], 19]])).toEqual([]) + }) + + test('should filter data using lte', () => { + expect(go(data, ['filter', ['lte', ['get', 'age'], 19]])).toEqual([ + { name: 'Emily', age: 19, city: 'Atlanta' }, + { name: 'Kevin', age: 19, city: 'Atlanta' } + ]) + }) + + test('should filter data using gte and lte', () => { + expect( + go(data, [ + 'pipe', + ['filter', ['gte', ['get', 'age'], 23]], + ['filter', ['lte', ['get', 'age'], 27]] + ]) + ).toEqual([ + { name: 'Chris', age: 23, city: 'New York' }, + { name: 'Michelle', age: 27, city: 'Los Angeles' } + ]) + + expect( + go(data, ['filter', ['and', ['gte', ['get', 'age'], 23], ['lte', ['get', 'age'], 27]]]) + ).toEqual([ + { name: 'Chris', age: 23, city: 'New York' }, + { name: 'Michelle', age: 27, city: 'Los Angeles' } + ]) + }) + + test('should filter data using "in"', () => { + expect(go(data, ['filter', ['in', ['get', 'age'], ['array', ['add', 10, 9], 23]]])).toEqual([ + { name: 'Chris', age: 23, city: 'New York' }, + { name: 'Emily', age: 19, city: 'Atlanta' }, + { name: 'Kevin', age: 19, city: 'Atlanta' } + ]) + }) + + test('should filter data using "not in"', () => { + expect( + go(data, ['filter', ['not in', ['get', 'age'], ['array', ['add', 10, 9], 23]]]) + ).toEqual([ + { name: 'Joe', age: 32, city: 'New York' }, + { name: 'Michelle', age: 27, city: 'Los Angeles' }, + { name: 'Robert', age: 45, city: 'Manhattan' }, + { name: 'Sarah', age: 31, city: 'New York' } + ]) + }) + + test('should filter data using "regex"', () => { + // search for a name containing 3 to 5 letters + expect(go(data, ['filter', ['regex', ['get', 'name'], '^[A-z]{3,5}$']])).toEqual([ + { name: 'Chris', age: 23, city: 'New York' }, + { name: 'Emily', age: 19, city: 'Atlanta' }, + { name: 'Joe', age: 32, city: 'New York' }, + { name: 'Kevin', age: 19, city: 'Atlanta' }, + { name: 'Sarah', age: 31, city: 'New York' } + ]) + }) + + test('should filter data using "regex" with flags', () => { + // search for a name containing a case-insensitive character "m" + expect(go(data, ['filter', ['regex', ['get', 'name'], 'm', 'i']])).toEqual([ + { name: 'Emily', age: 19, city: 'Atlanta' }, + { name: 'Michelle', age: 27, city: 'Los Angeles' } + ]) + }) + + test('should filter multiple conditions using "and" and "or"', () => { + const item1 = { a: 1, b: 1 } + const item2 = { a: 2, b: 22 } + const item3 = { a: 3, b: 33 } + const data = [item1, item2, item3] + + expect(go(data, ['filter', ['eq', ['get', 'a'], 2]])).toEqual([item2]) + expect(go(data, ['filter', ['eq', ['get', 'a'], 3]])).toEqual([item3]) + expect(go(data, ['filter', ['eq', 3, ['get', 'a']]])).toEqual([item3]) + expect(go(data, ['filter', ['eq', 3, ['get', 'a']]])).toEqual([item3]) + + expect(go(data, ['filter', ['eq', ['get', 'a'], ['get', 'b']]])).toEqual([item1]) + expect(go(data, ['filter', ['gte', 2, ['get', 'a']]])).toEqual([item1, item2]) + + expect( + go(data, ['filter', ['and', ['eq', ['get', 'a'], 2], ['eq', ['get', 'b'], 22]]]) + ).toEqual([item2]) + expect( + go(data, ['filter', ['or', ['eq', ['get', 'a'], 1], ['eq', ['get', 'b'], 22]]]) + ).toEqual([item1, item2]) + expect( + go(data, ['filter', ['or', ['eq', ['get', 'a'], 1], ['eq', ['get', 'b'], 4]]]) + ).toEqual([item1]) + expect( + go(data, [ + 'filter', + [ + 'or', + ['and', ['eq', ['get', 'a'], 1], ['eq', ['get', 'b'], 1]], + ['and', ['eq', ['get', 'a'], 2], ['eq', ['get', 'b'], 22]] + ] + ]) + ).toEqual([item1, item2]) + // FIXME: support multiple and/or in one go? + + const dataMsg = [{ message: 'hello' }] + expect(go(dataMsg, ['filter', ['eq', ['get', 'message'], 'hello']])).toEqual(dataMsg) + expect(go(dataMsg, ['filter', ['eq', 'hello', ['get', 'message']]])).toEqual(dataMsg) + }) + }) + + describe('sort', () => { + test('should sort data (default direction)', () => { + expect(go(data, ['sort', ['get', 'age']])).toEqual([ + { name: 'Emily', age: 19, city: 'Atlanta' }, + { name: 'Kevin', age: 19, city: 'Atlanta' }, + { name: 'Chris', age: 23, city: 'New York' }, + { name: 'Michelle', age: 27, city: 'Los Angeles' }, + { name: 'Sarah', age: 31, city: 'New York' }, + { name: 'Joe', age: 32, city: 'New York' }, + { name: 'Robert', age: 45, city: 'Manhattan' } + ]) + }) + + test('should sort data (asc)', () => { + expect(go(data, ['sort', ['get', 'age'], 'asc'])).toEqual([ + { name: 'Emily', age: 19, city: 'Atlanta' }, + { name: 'Kevin', age: 19, city: 'Atlanta' }, + { name: 'Chris', age: 23, city: 'New York' }, + { name: 'Michelle', age: 27, city: 'Los Angeles' }, + { name: 'Sarah', age: 31, city: 'New York' }, + { name: 'Joe', age: 32, city: 'New York' }, + { name: 'Robert', age: 45, city: 'Manhattan' } + ]) + }) + + test('should sort data (desc)', () => { + expect(go(data, ['sort', ['get', 'age'], 'desc'])).toEqual([ + { name: 'Robert', age: 45, city: 'Manhattan' }, + { name: 'Joe', age: 32, city: 'New York' }, + { name: 'Sarah', age: 31, city: 'New York' }, + { name: 'Michelle', age: 27, city: 'Los Angeles' }, + { name: 'Chris', age: 23, city: 'New York' }, + { name: 'Emily', age: 19, city: 'Atlanta' }, + { name: 'Kevin', age: 19, city: 'Atlanta' } + ]) + }) + + test('should sort data (strings)', () => { + expect(go(data, ['sort', 'name'])).toEqual([ + { name: 'Chris', age: 23, city: 'New York' }, + { name: 'Emily', age: 19, city: 'Atlanta' }, + { name: 'Joe', age: 32, city: 'New York' }, + { name: 'Kevin', age: 19, city: 'Atlanta' }, + { name: 'Michelle', age: 27, city: 'Los Angeles' }, + { name: 'Robert', age: 45, city: 'Manhattan' }, + { name: 'Sarah', age: 31, city: 'New York' } + ]) + }) + + test('should sort nested data', () => { + expect(go(nestedData, ['sort', ['get', 'address', 'city']])).toEqual([ + { name: 'Emily', age: 19, address: { city: 'Atlanta' } }, + { name: 'Kevin', age: 19, address: { city: 'Atlanta' } }, + { name: 'Michelle', age: 27, address: { city: 'Los Angeles' } }, + { name: 'Robert', age: 45, address: { city: 'Manhattan' } }, + { name: 'Chris', age: 23, address: { city: 'New York' } }, + { name: 'Joe', age: 32, address: { city: 'New York' } }, + { name: 'Sarah', age: 31, address: { city: 'New York' } } + ]) + }) + + test('should sort a list with numbers rather than objects', () => { + expect(go([3, 7, 2, 6], ['sort'])).toEqual([2, 3, 6, 7]) + expect(go([3, 7, 2, 6], ['sort', ['get'], 'desc'])).toEqual([7, 6, 3, 2]) + }) + + test('should not crash when sorting a list with nested arrays', () => { + expect(go([[3], [7], [4]], ['sort'])).toEqual([[3], [4], [7]]) + expect(go([[], [], []], ['sort'])).toEqual([[], [], []]) + }) + + test('should not crash when sorting a list with nested objects', () => { + expect(go([{ a: 1 }, { c: 3 }, { b: 2 }], ['sort'])).toEqual([{ a: 1 }, { c: 3 }, { b: 2 }]) + expect(go([{}, {}, {}], ['sort'])).toEqual([{}, {}, {}]) + }) + }) + + describe('pick', () => { + test('should pick data from an array (single field)', () => { + expect(go(data, ['pick', ['get', 'name']])).toEqual([ + { name: 'Chris' }, + { name: 'Emily' }, + { name: 'Joe' }, + { name: 'Kevin' }, + { name: 'Michelle' }, + { name: 'Robert' }, + { name: 'Sarah' } + ]) + }) + + test('should pick data from an object', () => { + expect(go({ a: 1, b: 2, c: 3 }, ['pick', ['get', 'b']])).toEqual({ b: 2 }) + expect(go({ a: 1, b: 2, c: 3 }, ['pick', ['get', 'b'], ['get', 'a']])).toEqual({ + b: 2, + a: 1 + }) + }) + + test('should pick data from an array (multiple fields)', () => { + expect(go(data, ['pick', ['get', 'name'], ['get', 'city']])).toEqual([ + { name: 'Chris', city: 'New York' }, + { name: 'Emily', city: 'Atlanta' }, + { name: 'Joe', city: 'New York' }, + { name: 'Kevin', city: 'Atlanta' }, + { name: 'Michelle', city: 'Los Angeles' }, + { name: 'Robert', city: 'Manhattan' }, + { name: 'Sarah', city: 'New York' } + ]) + }) + + test('should pick data from an array (a single nested field)', () => { + expect(go(nestedData, ['pick', ['get', 'address', 'city']])).toEqual([ + { city: 'New York' }, + { city: 'Atlanta' }, + { city: 'New York' }, + { city: 'Atlanta' }, + { city: 'Los Angeles' }, + { city: 'Manhattan' }, + { city: 'New York' } + ]) + }) + + test('should pick data from an array (multiple fields with nested fields)', () => { + expect(go(nestedData, ['pick', ['get', 'name'], ['get', 'address', 'city']])).toEqual([ + { name: 'Chris', city: 'New York' }, + { name: 'Emily', city: 'Atlanta' }, + { name: 'Joe', city: 'New York' }, + { name: 'Kevin', city: 'Atlanta' }, + { name: 'Michelle', city: 'Los Angeles' }, + { name: 'Robert', city: 'Manhattan' }, + { name: 'Sarah', city: 'New York' } + ]) + }) + }) + + test('should group items by a key', () => { + expect(go(data, ['groupBy', ['get', 'city']])).toEqual({ + 'New York': [ + { name: 'Chris', age: 23, city: 'New York' }, + { name: 'Joe', age: 32, city: 'New York' }, + { name: 'Sarah', age: 31, city: 'New York' } + ], + Atlanta: [ + { name: 'Emily', age: 19, city: 'Atlanta' }, + { name: 'Kevin', age: 19, city: 'Atlanta' } + ], + 'Los Angeles': [{ name: 'Michelle', age: 27, city: 'Los Angeles' }], + Manhattan: [{ name: 'Robert', age: 45, city: 'Manhattan' }] + }) + }) + + test('should turn an array in an object by key', () => { + const users = [ + { id: 1, name: 'Joe' }, + { id: 2, name: 'Sarah' }, + { id: 3, name: 'Chris' } + ] + + expect(go(users, ['keyBy', ['get', 'id']])).toEqual({ + 1: { id: 1, name: 'Joe' }, + 2: { id: 2, name: 'Sarah' }, + 3: { id: 3, name: 'Chris' } + }) + }) + + test('should handle duplicate keys in keyBy', () => { + const users = [ + { id: 1, name: 'Joe' }, + { id: 2, name: 'Sarah' }, + { id: 1, name: 'Chris' } + ] + + // keep the first occurrence + expect(go(users, ['keyBy', ['get', 'id']])).toEqual({ + 1: { id: 1, name: 'Joe' }, + 2: { id: 2, name: 'Sarah' } + }) + }) + + test('should get nested data from an object', () => { + expect(go(friendsData, ['get', 'friends'])).toEqual(data) + }) + + test('should get nested data from an array with objects', () => { + expect(go(nestedData, ['map', ['get', 'address', 'city']])).toEqual([ + 'New York', + 'Atlanta', + 'New York', + 'Atlanta', + 'Los Angeles', + 'Manhattan', + 'New York' + ]) + }) + + test('should get unique values from a list', () => { + expect(go([2, 3, 2, 7, 1, 1], ['uniq'])).toEqual([2, 3, 7, 1]) + }) + + test('should get unique objects by key', () => { + // keep the first occurrence + expect(go(data, ['uniqBy', ['get', 'city']])).toEqual([ + { name: 'Chris', age: 23, city: 'New York' }, + { name: 'Emily', age: 19, city: 'Atlanta' }, + { name: 'Michelle', age: 27, city: 'Los Angeles' }, + { name: 'Robert', age: 45, city: 'Manhattan' } + ]) + }) + + test('should calculate the sum', () => { + expect(go([2, 3, 2, 7, 1, 1], ['sum'])).toEqual(16) + }) + + test('should round a value', () => { + expect(go(null, ['round', 23.1345])).toEqual(23) + expect(go(null, ['round', 23.761])).toEqual(24) + expect(go(null, ['round', 23.1345, 2])).toEqual(23.13) + expect(go(null, ['round', 23.1345, 3])).toEqual(23.135) + expect(go({ a: 23.1345 }, ['round', ['get', 'a']])).toEqual(23) + }) + + test('should round an array with values', () => { + expect(go([2.24, 3.77, 4.49], ['map', ['round', ['get']]])).toEqual([2, 4, 4]) + expect(go([2.24, 3.77, 4.49], ['map', ['round', ['get'], 1]])).toEqual([2.2, 3.8, 4.5]) + }) + + test('should calculate the product', () => { + expect(go([2, 3, 2, 7, 1, 1], ['prod'])).toEqual(84) + }) + + test('should calculate the average', () => { + expect(go([2, 3, 2, 7, 1], ['average'])).toEqual(3) + }) + + test('should count the size of an array', () => { + expect(go([], ['size'])).toEqual(0) + expect(go([1, 2, 3], ['size'])).toEqual(3) + expect(go([1, 2, 3, 4, 5], ['size'])).toEqual(5) + }) + + test('should extract the keys of an object', () => { + expect(go({ a: 2, b: 3 }, ['keys'])).toEqual(['a', 'b']) + }) + + test('should extract the values of an object', () => { + expect(go({ a: 2, b: 3 }, ['values'])).toEqual([2, 3]) + }) + + test('should limit data', () => { + expect(go(data, ['limit', 2])).toEqual([ + { name: 'Chris', age: 23, city: 'New York' }, + { name: 'Emily', age: 19, city: 'Atlanta' } + ]) + }) + + test('should process "not"', () => { + expect(go(data, ['not', 2])).toEqual(false) + expect(go({ a: false }, ['not', ['get', 'a']])).toEqual(true) + expect(go({ a: true }, ['not', ['get', 'a']])).toEqual(false) + expect(go({ nested: { a: false } }, ['not', ['get', 'nested', 'a']])).toEqual(true) + expect(go({ nested: { a: true } }, ['not', ['get', 'nested', 'a']])).toEqual(false) + + expect(go(data, ['filter', ['not', ['eq', ['get', 'city'], 'New York']]])).toEqual([ + { name: 'Emily', age: 19, city: 'Atlanta' }, + { name: 'Kevin', age: 19, city: 'Atlanta' }, + { name: 'Michelle', age: 27, city: 'Los Angeles' }, + { name: 'Robert', age: 45, city: 'Manhattan' } + ]) + }) + + test('should process "exists"', () => { + expect(go({ a: false }, ['exists', ['get', 'a']])).toEqual(true) + expect(go({ a: null }, ['exists', ['get', 'a']])).toEqual(true) + expect(go({ a: 2 }, ['exists', ['get', 'a']])).toEqual(true) + expect(go({ a: 0 }, ['exists', ['get', 'a']])).toEqual(true) + expect(go({ a: '' }, ['exists', ['get', 'a']])).toEqual(true) + expect(go({ nested: { a: 2 } }, ['exists', ['get', 'nested', 'a']])).toEqual(true) + + expect(go({ a: undefined }, ['exists', ['get', 'a']])).toEqual(false) + expect(go({}, ['exists', ['get', 'a']])).toEqual(false) + expect(go({}, ['exists', ['get', 'nested', 'a']])).toEqual(false) + expect(go({}, ['exists', ['get', 'sort']])).toEqual(false) + + const detailsData = [ + { name: 'Chris', details: { age: 16 } }, + { name: 'Emily' }, + { name: 'Joe', details: { age: 18 } } + ] + expect(go(detailsData, ['filter', ['exists', ['get', 'details']]])).toEqual([ + { name: 'Chris', details: { age: 16 } }, + { name: 'Joe', details: { age: 18 } } + ]) + }) + + test('should process function eq', () => { + expect(go({ a: 6 }, ['eq', ['get', 'a'], 6])).toEqual(true) + expect(go({ a: 6 }, ['eq', ['get', 'a'], 2])).toEqual(false) + expect(go({ a: 6 }, ['eq', ['get', 'a'], '6'])).toEqual(false) + expect(go({ a: 'Hi' }, ['eq', ['get', 'a'], 'Hi'])).toEqual(true) + expect(go({ a: 'Hi' }, ['eq', ['get', 'a'], 'Hello'])).toEqual(false) + }) + + test('should process function gt', () => { + expect(go({ a: 6 }, ['gt', ['get', 'a'], 5])).toEqual(true) + expect(go({ a: 6 }, ['gt', ['get', 'a'], 6])).toEqual(false) + expect(go({ a: 6 }, ['gt', ['get', 'a'], 7])).toEqual(false) + }) + + test('should process function gte', () => { + expect(go({ a: 6 }, ['gte', ['get', 'a'], 5])).toEqual(true) + expect(go({ a: 6 }, ['gte', ['get', 'a'], 6])).toEqual(true) + expect(go({ a: 6 }, ['gte', ['get', 'a'], 7])).toEqual(false) + }) + + test('should process function lt', () => { + expect(go({ a: 6 }, ['lt', ['get', 'a'], 5])).toEqual(false) + expect(go({ a: 6 }, ['lt', ['get', 'a'], 6])).toEqual(false) + expect(go({ a: 6 }, ['lt', ['get', 'a'], 7])).toEqual(true) + }) + + test('should process function lte', () => { + expect(go({ a: 6 }, ['lte', ['get', 'a'], 5])).toEqual(false) + expect(go({ a: 6 }, ['lte', ['get', 'a'], 6])).toEqual(true) + expect(go({ a: 6 }, ['lte', ['get', 'a'], 7])).toEqual(true) + }) + + test('should process function ne', () => { + expect(go({ a: 6 }, ['ne', ['get', 'a'], 6])).toEqual(false) + expect(go({ a: 6 }, ['ne', ['get', 'a'], 2])).toEqual(true) + expect(go({ a: 6 }, ['ne', ['get', 'a'], '6'])).toEqual(true) + expect(go({ a: 'Hi' }, ['ne', ['get', 'a'], 'Hi'])).toEqual(false) + expect(go({ a: 'Hi' }, ['ne', ['get', 'a'], 'Hello'])).toEqual(true) + }) + + test('should process function add', () => { + expect(go({ a: 6, b: 2 }, ['add', ['get', 'a'], ['get', 'b']])).toEqual(8) + }) + + test('should process function subtract', () => { + expect(go({ a: 6, b: 2 }, ['subtract', ['get', 'a'], ['get', 'b']])).toEqual(4) + }) + + test('should process function multiply', () => { + expect(go({ a: 6, b: 2 }, ['multiply', ['get', 'a'], ['get', 'b']])).toEqual(12) + }) + + test('should process function divide', () => { + expect(go({ a: 6, b: 2 }, ['divide', ['get', 'a'], ['get', 'b']])).toEqual(3) + }) + + test('should process function pow', () => { + expect(go({ a: 2, b: 3 }, ['pow', ['get', 'a'], ['get', 'b']])).toEqual(8) + expect(go({ a: 25, b: 1 / 2 }, ['pow', ['get', 'a'], ['get', 'b']])).toEqual(5) // sqrt + }) + + test('should process function mod (remainder)', () => { + expect(go({ a: 8, b: 3 }, ['mod', ['get', 'a'], ['get', 'b']])).toEqual(2) + }) + + test('should calculate the minimum value', () => { + expect(go([3, -4, 1, -7], ['min'])).toEqual(-7) + }) + + test('should calculate the absolute value', () => { + expect(go(null, ['abs', 2])).toEqual(2) + expect(go(null, ['abs', -2])).toEqual(2) + expect(go({ a: -3 }, ['abs', ['get', 'a']])).toEqual(3) + expect(go([3, -4, 1, -7], ['map', ['abs', ['get']]])).toEqual([3, 4, 1, 7]) + }) + + test('should process multiple operations', () => { + expect( + go(friendsData, [ + 'pipe', + ['get', 'friends'], + ['filter', ['eq', ['get', 'city'], 'New York']], + ['sort', ['get', 'age']], + ['map', ['get', 'name']], + ['limit', 2] + ]) + ).toEqual(['Chris', 'Sarah']) + }) + + test('should extend with a custom function "times"', () => { + const options = { + functions: { + times: (value: number) => (data: number[]) => data.map((item) => item * value) + } + } + + expect(go([1, 2, 3], ['times', 2], options)).toEqual([2, 4, 6]) + expect(() => go([1, 2, 3], ['times', 2])).toThrow("Unknown function 'times'") + }) + + test('should extend with a custom function with more than 2 arguments', () => { + const options = { + functions: { + oneOf: buildFunction( + (value: unknown, a: unknown, b: unknown, c: unknown) => + value === a || value === b || value === c + ) + } + } + + expect(go('C', ['oneOf', ['get'], 'A', 'B', 'C'], options)).toEqual(true) + expect(go('D', ['oneOf', ['get'], 'A', 'B', 'C'], options)).toEqual(false) + }) + + test('should override an existing function', () => { + const options = { + functions: { + sort: () => (_data: unknown[]) => 'custom sort' + } + } + + expect(go([2, 3, 1], ['sort'], options)).toEqual('custom sort') + }) + + test('should be able to insert a function in a nested compile', () => { + const options = { + functions: { + times: (value: JSONQuery) => { + const _options = { + functions: { + foo: () => (_data: unknown) => 42 + } + } + const _value = compile(value, _options) + + return (data: number[]) => data.map((item) => item * (_value(data) as number)) + } + } + } + + expect(go([1, 2, 3], ['times', 2], options)).toEqual([2, 4, 6]) + expect(go([1, 2, 3], ['times', ['foo']], options)).toEqual([42, 84, 126]) + + // The function `foo` must not be available outside the `times` function + expect(() => go([1, 2, 3], ['foo'], options)).toThrow("Unknown function 'foo'") + }) + + test('should cleanup the custom function stack when creating a query throws an error', () => { + const options = { + functions: { + sort: () => { + throw new Error('Test Error') + } + } + } + + expect(() => go({}, ['sort'], options)).toThrow('Test Error') + + expect(go([2, 3, 1], ['sort'])).toEqual([1, 2, 3]) + }) + + test('should extend with a custom function aboutEq', () => { + const options = { + functions: { + // biome-ignore lint/suspicious/noDoubleEquals: we want to test loosely equal here + aboutEq: buildFunction((a, b) => a == b) // loosely equal + } + } + + expect(go({ a: 2 }, ['aboutEq', ['get', 'a'], 2], options)).toEqual(true) + expect(go({ a: 2 }, ['aboutEq', ['get', 'a'], '2'], options)).toEqual(true) + }) + + test('should use functions to calculate a shopping cart', () => { + const data = [ + { name: 'bread', price: 2.5, quantity: 2 }, + { name: 'milk', price: 1.2, quantity: 3 } + ] + + expect( + go(data, ['pipe', ['map', ['multiply', ['get', 'price'], ['get', 'quantity']]], ['sum']]) + ).toEqual(8.6) + }) + + test('should be able to query the jmespath example', () => { + const options = { + functions: { + join: + (separator = ', ') => + (data: unknown[]) => + data.join(separator) + } + } + + const data = { + locations: [ + { name: 'Seattle', state: 'WA' }, + { name: 'New York', state: 'NY' }, + { name: 'Bellevue', state: 'WA' }, + { name: 'Olympia', state: 'WA' } + ] + } + + // locations[?state == 'WA'].name | sort(@) | {WashingtonCities: join(', ', @)} + expect( + go( + data, + [ + 'pipe', + ['get', 'locations'], + ['filter', ['eq', ['get', 'state'], 'WA']], + ['map', ['get', 'name']], + ['sort'], + ['object', { WashingtonCities: ['join'] }] + ], + options + ) + ).toEqual({ + WashingtonCities: 'Bellevue, Olympia, Seattle' + }) + }) +}) diff --git a/src/compile.ts b/src/compile.ts index 2751648..5b6d0a8 100644 --- a/src/compile.ts +++ b/src/compile.ts @@ -1,24 +1,24 @@ -import { - Function, +import { functions } from './functions' +import { isArray } from './is' +import type { + Fun, FunctionBuildersMap, - Getter, JSONQuery, - JSONQueryFunction, - JSONQueryObject, - JSONQueryOptions, - JSONQueryPipe + JSONQueryCompileOptions, + JSONQueryFunction } from './types' -import { isArray, isObject, isString } from './is' -import { functions } from './functions' -const functionsStack: FunctionBuildersMap[] = [functions] +const functionsStack: FunctionBuildersMap[] = [] -export function compile(query: JSONQuery, options?: JSONQueryOptions): Function { - functionsStack.unshift({ ...functionsStack[0], ...options?.functions }) +export function compile(query: JSONQuery, options?: JSONQueryCompileOptions): Fun { + functionsStack.unshift({ ...functions, ...functionsStack[0], ...options?.functions }) try { - const exec = _compile(query, functionsStack[0]) + const exec = isArray(query) + ? compileFunction(query as JSONQueryFunction, functionsStack[0]) // function + : () => query // primitive value (string, number, boolean, null) + // create a wrapper function which can attach a stack to the error return (data) => { try { return exec(data) @@ -34,48 +34,13 @@ export function compile(query: JSONQuery, options?: JSONQueryOptions): Function } } -function _compile(query: JSONQuery, functions: FunctionBuildersMap): Function { - if (isArray(query)) { - // function - if (isString(query[0])) { - return fun(query as JSONQueryFunction, functions) - } - - // pipe - return pipe(query as JSONQueryPipe) - } - - // object - if (isObject(query)) { - return object(query as JSONQueryObject) - } - - // value (string, number, boolean, null) - return () => query -} - -function fun(query: JSONQueryFunction, functions: FunctionBuildersMap) { +function compileFunction(query: JSONQueryFunction, functions: FunctionBuildersMap) { const [fnName, ...args] = query const fnBuilder = functions[fnName] if (!fnBuilder) { - throw new Error(`Unknown function "${fnName}"`) + throw new Error(`Unknown function '${fnName}'`) } return fnBuilder(...args) } - -function pipe(entries: JSONQuery[]) { - const _entries = entries.map((entry) => compile(entry)) - return (data: unknown) => _entries.reduce((data, evaluator) => evaluator(data), data) -} - -function object(query: JSONQueryObject) { - const getters: Getter[] = Object.keys(query).map((key) => [key, compile(query[key])]) - - return (data: unknown) => { - const obj = {} - getters.forEach(([key, getter]) => (obj[key] = getter(data))) - return obj - } -} diff --git a/src/constants.ts b/src/constants.ts new file mode 100644 index 0000000..4cc929c --- /dev/null +++ b/src/constants.ts @@ -0,0 +1,29 @@ +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' +} + +export const unquotedPropertyRegex = /^[a-zA-Z_$][a-zA-Z\d_$]*$/ +export const startsWithUnquotedPropertyRegex = /^[a-zA-Z_$][a-zA-Z\d_$]*/ +export const startsWithStringRegex = /^"(?:[^"\\]|\\.)*"/ // https://stackoverflow.com/a/249937/1262753 +export const startsWithNumberRegex = /^-?(?:0|[1-9]\d*)(?:\.\d+)?(?:[eE][+-]?\d+)?/ // https://stackoverflow.com/a/13340826/1262753 +export const startsWithIntRegex = /^(0|[1-9][0-9]*)/ +export const startsWithKeywordRegex = /^(true|false|null)/ +export const startsWithWhitespaceRegex = /^[ \n\t\r]+/ diff --git a/src/functions.ts b/src/functions.ts index 65d5aa0..9ee19dc 100644 --- a/src/functions.ts +++ b/src/functions.ts @@ -1,9 +1,55 @@ -import { FunctionBuildersMap, Getter, JSONPath, JSONQuery, JSONQueryProperty } from './types' import { compile } from './compile' import { isArray } from './is' -import { buildFunction } from './buildFunction' +import type { + FunctionBuilder, + FunctionBuildersMap, + Getter, + JSONPath, + JSONQuery, + JSONQueryObject, + JSONQueryProperty +} from './types' + +export function buildFunction(fn: (...args: unknown[]) => unknown): FunctionBuilder { + return (...args: JSONQuery[]) => { + const compiledArgs = args.map((arg) => compile(arg)) + + const arg0 = compiledArgs[0] + const arg1 = compiledArgs[1] + + return compiledArgs.length === 1 + ? (data: unknown) => fn(arg0(data)) + : compiledArgs.length === 2 + ? (data: unknown) => fn(arg0(data), arg1(data)) + : (data: unknown) => fn(...compiledArgs.map((arg) => arg(data))) + } +} export const functions: FunctionBuildersMap = { + pipe: (...entries: JSONQuery[]) => { + const _entries = entries.map((entry) => compile(entry)) + + return (data: unknown) => _entries.reduce((data, evaluator) => evaluator(data), data) + }, + + object: (query: JSONQueryObject) => { + const getters: Getter[] = Object.keys(query).map((key) => [key, compile(query[key])]) + + return (data: unknown) => { + const obj = {} + for (const [key, getter] of getters) { + obj[key] = getter(data) + } + return obj + } + }, + + array: (...items: JSONQuery[]) => { + const _items = items.map((entry: JSONQuery) => compile(entry)) + + return (data: unknown) => _items.map((item) => item(data)) + }, + get: (...path: JSONPath) => { if (path.length === 0) { return (data: unknown) => data @@ -27,11 +73,13 @@ export const functions: FunctionBuildersMap = { map: (callback: JSONQuery) => { const _callback = compile(callback) + return (data: T[]) => data.map(_callback) }, - filter: (...predicate: JSONQuery[]) => { - const _predicate = compile(predicate.length === 1 ? predicate[0] : predicate) + filter: (predicate: JSONQuery[]) => { + const _predicate = compile(predicate) + return (data: T[]) => data.filter(_predicate) }, @@ -55,7 +103,9 @@ export const functions: FunctionBuildersMap = { const _pick = (object: Record, getters: Getter[]): unknown => { const out = {} - getters.forEach(([key, getter]) => (out[key] = getter(object))) + for (const [key, getter] of getters) { + out[key] = getter(object) + } return out } @@ -93,10 +143,10 @@ export const functions: FunctionBuildersMap = { return (data: T[]) => { const res = {} - data.forEach((item) => { + for (const item of data) { const value = getter(item) as string res[value] = res[value] ?? item - }) + } return res } @@ -137,19 +187,23 @@ export const functions: FunctionBuildersMap = { max: () => (data: number[]) => Math.max(...data), - in: (path: string, values: string[]) => { + in: (path: string, values: JSONQuery) => { const getter = compile(path) - return (data: unknown) => values.includes(getter(data) as string) + const _values = compile(values) + + return (data: unknown) => (_values(data) as string[]).includes(getter(data) as string) }, - 'not in': (path: string, values: string[]) => { - const getter = compile(path) - return (data: unknown) => !values.includes(getter(data) as string) + 'not in': (path: string, values: JSONQuery) => { + const _in = functions.in(path, values) + + return (data: unknown) => !_in(data) }, regex: (path: JSONQuery, expression: string, options?: string) => { const regex = new RegExp(expression, options) const getter = compile(path) + return (data: unknown) => regex.test(getter(data) as string) }, @@ -173,7 +227,7 @@ export const functions: FunctionBuildersMap = { mod: 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)) - return Number(num + 'e' + -digits) + const num = Math.round(Number(`${value}e${digits}`)) + return Number(`${num}e${-digits}`) }) } diff --git a/src/index.test.ts b/src/index.test.ts new file mode 100644 index 0000000..16f8d57 --- /dev/null +++ b/src/index.test.ts @@ -0,0 +1,12 @@ +import { describe, expect, test } from 'vitest' +import { buildFunction, compile, jsonquery, parse, stringify } from './index' + +describe('index', () => { + test('have exported all documented functions', () => { + expect(jsonquery).toBeTypeOf('function') + expect(parse).toBeTypeOf('function') + expect(stringify).toBeTypeOf('function') + expect(compile).toBeTypeOf('function') + expect(buildFunction).toBeTypeOf('function') + }) +}) diff --git a/src/index.ts b/src/index.ts index 8b5ba04..9228e77 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,2 +1,5 @@ export { jsonquery } from './jsonquery' export { compile } from './compile' +export { stringify } from './stringify' +export { parse } from './parse' +export { buildFunction } from './functions' diff --git a/src/jsonquery.test.ts b/src/jsonquery.test.ts index db7129e..ec25d60 100644 --- a/src/jsonquery.test.ts +++ b/src/jsonquery.test.ts @@ -1,912 +1,44 @@ import { describe, expect, test } from 'vitest' -import { jsonquery } from './jsonquery.js' -import { JSONQuery } from './types' -import { compile } from './compile' -import { buildFunction } from './buildFunction' - -const data = [ - { name: 'Chris', age: 23, city: 'New York' }, - { name: 'Emily', age: 19, city: 'Atlanta' }, - { name: 'Joe', age: 32, city: 'New York' }, - { name: 'Kevin', age: 19, city: 'Atlanta' }, - { name: 'Michelle', age: 27, city: 'Los Angeles' }, - { name: 'Robert', age: 45, city: 'Manhattan' }, - { name: 'Sarah', age: 31, city: 'New York' } -] - -const friendsData = { - friends: data -} - -const nestedData = [ - { name: 'Chris', age: 23, address: { city: 'New York' } }, - { name: 'Emily', age: 19, address: { city: 'Atlanta' } }, - { name: 'Joe', age: 32, address: { city: 'New York' } }, - { name: 'Kevin', age: 19, address: { city: 'Atlanta' } }, - { name: 'Michelle', age: 27, address: { city: 'Los Angeles' } }, - { name: 'Robert', age: 45, address: { city: 'Manhattan' } }, - { name: 'Sarah', age: 31, address: { city: 'New York' } } -] - -const scoresData = [ - { name: 'Chris', scores: [5, 7, 3] }, - { name: 'Emily', scores: [8, 5, 2, 5] }, - { name: 'Joe', scores: [1, 1, 5, 6] } -] +import { jsonquery } from './jsonquery' describe('jsonquery', () => { - describe('prop', () => { - test('should get a path with a single property as string', () => { - expect(jsonquery({ name: 'Joe' }, ['get', 'name'])).toEqual('Joe') - }) - - test('should get the full object itself', () => { - expect(jsonquery({ name: 'Joe' }, ['get'])).toEqual({ name: 'Joe' }) - expect(jsonquery(2, ['get'])).toEqual(2) - }) - - test('should return undefined in case of a non existing path', () => { - expect(jsonquery({}, ['get', 'foo', 'bar'])).toEqual(undefined) - }) - - test('should get a path using function get', () => { - expect(jsonquery({ name: 'Joe' }, ['get', 'name'])).toEqual('Joe') - }) - - test('should get a path that has the same name as a function', () => { - expect(jsonquery({ sort: 'Joe' }, ['get', 'sort'])).toEqual('Joe') - }) - - test('should get a nested value that has the same name as a function', () => { - expect(jsonquery({ sort: { name: 'Joe' } }, ['get', 'sort', 'name'])).toEqual('Joe') - }) - - test('should get in item from an array', () => { - expect(jsonquery(['A', 'B', 'C'], ['get', 1])).toEqual('B') - expect(jsonquery({ arr: ['A', 'B', 'C'] }, ['get', 'arr', 1])).toEqual('B') - expect(jsonquery([{ text: 'A' }, { text: 'B' }, { text: 'C' }], ['get', 1, 'text'])).toEqual( - 'B' - ) - }) - }) - - test('should execute a function', () => { - expect(jsonquery([3, 1, 2], ['sort'])).toEqual([1, 2, 3]) - }) - - describe('object', () => { - test('should create an object', () => { - expect( - jsonquery( - { a: 2, b: 3 }, - { - aa: ['get', 'a'], - bb: 42 - } - ) - ).toEqual({ - aa: 2, - bb: 42 - }) - }) - - test('should create a nested object', () => { - expect( - jsonquery(data, { - names: ['map', ['get', 'name']], - stats: { - count: ['size'], - averageAge: [['map', ['get', 'age']], ['average']] - } - }) - ).toEqual({ - names: ['Chris', 'Emily', 'Joe', 'Kevin', 'Michelle', 'Robert', 'Sarah'], - stats: { - count: 7, - averageAge: 28 - } - }) - }) - }) - - describe('pipe', () => { - test('should execute a pipeline', () => { - expect( - jsonquery({ user: { name: 'Joe' } }, [ - ['get', 'user'], - ['get', 'name'] - ]) - ).toEqual('Joe') - }) - - test('should create an object containing pipelines', () => { - expect( - jsonquery(data, { - names: ['map', ['get', 'name']], - count: ['size'], - averageAge: [['map', ['get', 'age']], ['average']] - }) - ).toEqual({ - names: ['Chris', 'Emily', 'Joe', 'Kevin', 'Michelle', 'Robert', 'Sarah'], - count: 7, - averageAge: 28 - }) - }) - - test('should throw a helpful error when a pipe contains a compile time error', () => { - let actualErr = undefined - try { - jsonquery(data, ['foo', 42]) - } catch (err) { - actualErr = err - } - - expect(actualErr?.message).toBe('Unknown function "foo"') - }) - - test('should throw a helpful error when a pipe contains a runtime error', () => { - const scoreData = { - participants: [ - { name: 'Chris', age: 23, scores: [7.2, 5, 8.0] }, - { name: 'Emily', age: 19 }, - { name: 'Joe', age: 32, scores: [6.1, 8.1] } - ] - } - const query = [ - ['get', 'participants'], - ['map', [['get', 'scores'], ['sum']]] - ] - - let actualErr = undefined - try { - jsonquery(scoreData, query) - } catch (err) { - actualErr = err - } - - expect(actualErr?.message).toBe("Cannot read properties of undefined (reading 'reduce')") - expect(actualErr?.jsonquery).toEqual([ - { data: scoreData, query }, - { - data: scoreData.participants, - query: ['map', [['get', 'scores'], ['sum']]] - }, - { data: { name: 'Emily', age: 19 }, query: [['get', 'scores'], ['sum']] }, - { data: undefined, query: ['sum'] } - ]) - }) - }) - - describe('map', () => { - test('should map over an array', () => { - expect( - jsonquery(scoresData, [ - [ - 'map', - { - name: ['get', 'name'], - maxScore: [['get', 'scores'], ['max']], - minScore: [['get', 'scores'], ['min']] - } - ], - ['sort', ['get', 'maxScore'], 'desc'] - ]) - ).toEqual([ - { name: 'Emily', maxScore: 8, minScore: 2 }, - { name: 'Chris', maxScore: 7, minScore: 3 }, - { name: 'Joe', maxScore: 6, minScore: 1 } - ]) - }) - - test('should map a path', () => { - expect(jsonquery(data, ['map', ['get', 'name']])).toEqual([ - 'Chris', - 'Emily', - 'Joe', - 'Kevin', - 'Michelle', - 'Robert', - 'Sarah' - ]) - }) - - test('should map over an array using pick', () => { - expect(jsonquery(data, ['map', ['pick', ['get', 'name']]])).toEqual([ - { name: 'Chris' }, - { name: 'Emily' }, - { name: 'Joe' }, - { name: 'Kevin' }, - { name: 'Michelle' }, - { name: 'Robert' }, - { name: 'Sarah' } - ]) - }) - }) - - test('should flatten an array', () => { - expect( - jsonquery( - [ - [1, 2], - [3, 4, 5] - ], - ['flatten'] - ) - ).toEqual([1, 2, 3, 4, 5]) - }) - - test('should resolve an function', () => { - expect(jsonquery([], ['and', true, false])).toEqual(false) - expect(jsonquery([], ['or', true, false])).toEqual(true) - expect(jsonquery({ city: 'New York' }, ['eq', ['get', 'city'], 'New York'])).toEqual(true) - }) - - describe('filter', () => { - test('should filter data using equal', () => { - expect(jsonquery(data, ['filter', ['eq', ['get', 'city'], 'New York']])).toEqual([ - { name: 'Chris', age: 23, city: 'New York' }, - { name: 'Joe', age: 32, city: 'New York' }, - { name: 'Sarah', age: 31, city: 'New York' } - ]) - }) - - test('should filter nested data using equal', () => { - expect( - jsonquery(nestedData, ['filter', ['eq', ['get', 'address', 'city'], 'New York']]) - ).toEqual([ - { name: 'Chris', age: 23, address: { city: 'New York' } }, - { name: 'Joe', age: 32, address: { city: 'New York' } }, - { name: 'Sarah', age: 31, address: { city: 'New York' } } - ]) - }) - - test('should filter multiple conditions (and)', () => { - expect( - jsonquery(nestedData, [ - ['filter', ['gt', ['get', 'age'], 30]], - ['filter', ['eq', ['get', 'address', 'city'], 'New York']] - ]) - ).toEqual([ - { name: 'Joe', age: 32, address: { city: 'New York' } }, - { name: 'Sarah', age: 31, address: { city: 'New York' } } - ]) - }) - - test('should filter with a condition being a function', () => { - expect(jsonquery(scoresData, ['filter', ['gte', [['get', 'scores'], ['max']], 7]])).toEqual([ - { name: 'Chris', scores: [5, 7, 3] }, - { name: 'Emily', scores: [8, 5, 2, 5] } - ]) - }) - - test('should filter data using ne', () => { - expect(jsonquery(data, ['filter', ['ne', ['get', 'city'], 'New York']])).toEqual([ - { name: 'Emily', age: 19, city: 'Atlanta' }, - { name: 'Kevin', age: 19, city: 'Atlanta' }, - { name: 'Michelle', age: 27, city: 'Los Angeles' }, - { name: 'Robert', age: 45, city: 'Manhattan' } - ]) - }) - - test('should filter data using gt', () => { - expect(jsonquery(data, ['filter', ['gt', ['get', 'age'], 45]])).toEqual([]) - }) - - test('should filter data using gte', () => { - expect(jsonquery(data, ['filter', ['gte', ['get', 'age'], 45]])).toEqual([ - { name: 'Robert', age: 45, city: 'Manhattan' } - ]) - }) - - test('should filter data using lt', () => { - expect(jsonquery(data, ['filter', ['lt', ['get', 'age'], 19]])).toEqual([]) - }) - - test('should filter data using lte', () => { - expect(jsonquery(data, ['filter', ['lte', ['get', 'age'], 19]])).toEqual([ - { name: 'Emily', age: 19, city: 'Atlanta' }, - { name: 'Kevin', age: 19, city: 'Atlanta' } - ]) - }) - - test('should filter data using gte and lte', () => { - expect( - jsonquery(data, [ - ['filter', ['gte', ['get', 'age'], 23]], - ['filter', ['lte', ['get', 'age'], 27]] - ]) - ).toEqual([ - { name: 'Chris', age: 23, city: 'New York' }, - { name: 'Michelle', age: 27, city: 'Los Angeles' } - ]) - - expect( - jsonquery(data, [ - ['filter', ['and', ['gte', ['get', 'age'], 23], ['lte', ['get', 'age'], 27]]] - ]) - ).toEqual([ - { name: 'Chris', age: 23, city: 'New York' }, - { name: 'Michelle', age: 27, city: 'Los Angeles' } - ]) - }) - - test('should filter data using "_in"', () => { - expect(jsonquery(data, ['filter', ['in', ['get', 'age'], [19, 23]]])).toEqual([ - { name: 'Chris', age: 23, city: 'New York' }, - { name: 'Emily', age: 19, city: 'Atlanta' }, - { name: 'Kevin', age: 19, city: 'Atlanta' } - ]) - }) - - test('should filter data using "not in"', () => { - expect(jsonquery(data, ['filter', ['not in', ['get', 'age'], [19, 23]]])).toEqual([ - { name: 'Joe', age: 32, city: 'New York' }, - { name: 'Michelle', age: 27, city: 'Los Angeles' }, - { name: 'Robert', age: 45, city: 'Manhattan' }, - { name: 'Sarah', age: 31, city: 'New York' } - ]) - }) - - test('should filter data using "regex"', () => { - // search for a name containing 3 to 5 letters - expect(jsonquery(data, ['filter', ['regex', ['get', 'name'], '^[A-z]{3,5}$']])).toEqual([ - { name: 'Chris', age: 23, city: 'New York' }, - { name: 'Emily', age: 19, city: 'Atlanta' }, - { name: 'Joe', age: 32, city: 'New York' }, - { name: 'Kevin', age: 19, city: 'Atlanta' }, - { name: 'Sarah', age: 31, city: 'New York' } - ]) - }) - - test('should filter data using "regex" with flags', () => { - // search for a name containing a case-insensitive character "m" - expect(jsonquery(data, ['filter', ['regex', ['get', 'name'], 'm', 'i']])).toEqual([ - { name: 'Emily', age: 19, city: 'Atlanta' }, - { name: 'Michelle', age: 27, city: 'Los Angeles' } - ]) - }) - - test('should filter multiple conditions using "and" and "or"', () => { - const item1 = { a: 1, b: 1 } - const item2 = { a: 2, b: 22 } - const item3 = { a: 3, b: 33 } - const data = [item1, item2, item3] - - expect(jsonquery(data, ['filter', ['eq', ['get', 'a'], 2]])).toEqual([item2]) - expect(jsonquery(data, ['filter', ['eq', ['get', 'a'], 3]])).toEqual([item3]) - expect(jsonquery(data, ['filter', ['eq', 3, ['get', 'a']]])).toEqual([item3]) - expect(jsonquery(data, ['filter', ['eq', 3, ['get', 'a']]])).toEqual([item3]) - - expect(jsonquery(data, ['filter', ['eq', ['get', 'a'], ['get', 'b']]])).toEqual([item1]) - expect(jsonquery(data, ['filter', ['gte', 2, ['get', 'a']]])).toEqual([item1, item2]) - - expect( - jsonquery(data, ['filter', ['and', ['eq', ['get', 'a'], 2], ['eq', ['get', 'b'], 22]]]) - ).toEqual([item2]) - expect( - jsonquery(data, ['filter', ['or', ['eq', ['get', 'a'], 1], ['eq', ['get', 'b'], 22]]]) - ).toEqual([item1, item2]) - expect( - jsonquery(data, ['filter', ['or', ['eq', ['get', 'a'], 1], ['eq', ['get', 'b'], 4]]]) - ).toEqual([item1]) - expect( - jsonquery(data, [ - 'filter', - [ - 'or', - ['and', ['eq', ['get', 'a'], 1], ['eq', ['get', 'b'], 1]], - ['and', ['eq', ['get', 'a'], 2], ['eq', ['get', 'b'], 22]] - ] - ]) - ).toEqual([item1, item2]) - // FIXME: support multiple and/or in one go? - - const dataMsg = [{ message: 'hello' }] - expect(jsonquery(dataMsg, ['filter', ['eq', ['get', 'message'], 'hello']])).toEqual(dataMsg) - expect(jsonquery(dataMsg, ['filter', ['eq', 'hello', ['get', 'message']]])).toEqual(dataMsg) - }) - }) - - describe('sort', () => { - test('should sort data (default direction)', () => { - expect(jsonquery(data, ['sort', ['get', 'age']])).toEqual([ - { name: 'Emily', age: 19, city: 'Atlanta' }, - { name: 'Kevin', age: 19, city: 'Atlanta' }, - { name: 'Chris', age: 23, city: 'New York' }, - { name: 'Michelle', age: 27, city: 'Los Angeles' }, - { name: 'Sarah', age: 31, city: 'New York' }, - { name: 'Joe', age: 32, city: 'New York' }, - { name: 'Robert', age: 45, city: 'Manhattan' } - ]) - }) - - test('should sort data (asc)', () => { - expect(jsonquery(data, ['sort', ['get', 'age'], 'asc'])).toEqual([ - { name: 'Emily', age: 19, city: 'Atlanta' }, - { name: 'Kevin', age: 19, city: 'Atlanta' }, - { name: 'Chris', age: 23, city: 'New York' }, - { name: 'Michelle', age: 27, city: 'Los Angeles' }, - { name: 'Sarah', age: 31, city: 'New York' }, - { name: 'Joe', age: 32, city: 'New York' }, - { name: 'Robert', age: 45, city: 'Manhattan' } - ]) - }) - - test('should sort data (desc)', () => { - expect(jsonquery(data, ['sort', ['get', 'age'], 'desc'])).toEqual([ - { name: 'Robert', age: 45, city: 'Manhattan' }, - { name: 'Joe', age: 32, city: 'New York' }, - { name: 'Sarah', age: 31, city: 'New York' }, - { name: 'Michelle', age: 27, city: 'Los Angeles' }, - { name: 'Chris', age: 23, city: 'New York' }, - { name: 'Emily', age: 19, city: 'Atlanta' }, - { name: 'Kevin', age: 19, city: 'Atlanta' } - ]) - }) - - test('should sort data (strings)', () => { - expect(jsonquery(data, ['sort', 'name'])).toEqual([ - { name: 'Chris', age: 23, city: 'New York' }, - { name: 'Emily', age: 19, city: 'Atlanta' }, - { name: 'Joe', age: 32, city: 'New York' }, - { name: 'Kevin', age: 19, city: 'Atlanta' }, - { name: 'Michelle', age: 27, city: 'Los Angeles' }, - { name: 'Robert', age: 45, city: 'Manhattan' }, - { name: 'Sarah', age: 31, city: 'New York' } - ]) - }) - - test('should sort nested data', () => { - expect(jsonquery(nestedData, ['sort', ['get', 'address', 'city']])).toEqual([ - { name: 'Emily', age: 19, address: { city: 'Atlanta' } }, - { name: 'Kevin', age: 19, address: { city: 'Atlanta' } }, - { name: 'Michelle', age: 27, address: { city: 'Los Angeles' } }, - { name: 'Robert', age: 45, address: { city: 'Manhattan' } }, - { name: 'Chris', age: 23, address: { city: 'New York' } }, - { name: 'Joe', age: 32, address: { city: 'New York' } }, - { name: 'Sarah', age: 31, address: { city: 'New York' } } - ]) - }) - - test('should sort a list with numbers rather than objects', () => { - expect(jsonquery([3, 7, 2, 6], ['sort'])).toEqual([2, 3, 6, 7]) - expect(jsonquery([3, 7, 2, 6], ['sort', [], 'desc'])).toEqual([7, 6, 3, 2]) - }) - - test('should not crash when sorting a list with nested arrays', () => { - expect(jsonquery([[3], [7], [4]], ['sort'])).toEqual([[3], [4], [7]]) - expect(jsonquery([[], [], []], ['sort'])).toEqual([[], [], []]) - }) - - test('should not crash when sorting a list with nested objects', () => { - expect(jsonquery([{ a: 1 }, { c: 3 }, { b: 2 }], ['sort'])).toEqual([ - { a: 1 }, - { c: 3 }, - { b: 2 } - ]) - expect(jsonquery([{}, {}, {}], ['sort'])).toEqual([{}, {}, {}]) - }) - }) - - describe('pick', () => { - test('should pick data from an array (single field)', () => { - expect(jsonquery(data, ['pick', ['get', 'name']])).toEqual([ - { name: 'Chris' }, - { name: 'Emily' }, - { name: 'Joe' }, - { name: 'Kevin' }, - { name: 'Michelle' }, - { name: 'Robert' }, - { name: 'Sarah' } - ]) - }) - - test('should pick data from an object', () => { - expect(jsonquery({ a: 1, b: 2, c: 3 }, ['pick', ['get', 'b']])).toEqual({ b: 2 }) - expect(jsonquery({ a: 1, b: 2, c: 3 }, ['pick', ['get', 'b'], ['get', 'a']])).toEqual({ - b: 2, - a: 1 - }) - }) - - test('should pick data from an array (multiple fields)', () => { - expect(jsonquery(data, ['pick', ['get', 'name'], ['get', 'city']])).toEqual([ - { name: 'Chris', city: 'New York' }, - { name: 'Emily', city: 'Atlanta' }, - { name: 'Joe', city: 'New York' }, - { name: 'Kevin', city: 'Atlanta' }, - { name: 'Michelle', city: 'Los Angeles' }, - { name: 'Robert', city: 'Manhattan' }, - { name: 'Sarah', city: 'New York' } - ]) - }) - - test('should pick data from an array (a single nested field)', () => { - expect(jsonquery(nestedData, ['pick', ['get', 'address', 'city']])).toEqual([ - { city: 'New York' }, - { city: 'Atlanta' }, - { city: 'New York' }, - { city: 'Atlanta' }, - { city: 'Los Angeles' }, - { city: 'Manhattan' }, - { city: 'New York' } - ]) - }) - - test('should pick data from an array (multiple fields with nested fields)', () => { - expect(jsonquery(nestedData, ['pick', ['get', 'name'], ['get', 'address', 'city']])).toEqual([ - { name: 'Chris', city: 'New York' }, - { name: 'Emily', city: 'Atlanta' }, - { name: 'Joe', city: 'New York' }, - { name: 'Kevin', city: 'Atlanta' }, - { name: 'Michelle', city: 'Los Angeles' }, - { name: 'Robert', city: 'Manhattan' }, - { name: 'Sarah', city: 'New York' } - ]) - }) - }) - - test('should group items by a key', () => { - expect(jsonquery(data, ['groupBy', ['get', 'city']])).toEqual({ - 'New York': [ - { name: 'Chris', age: 23, city: 'New York' }, - { name: 'Joe', age: 32, city: 'New York' }, - { name: 'Sarah', age: 31, city: 'New York' } - ], - Atlanta: [ - { name: 'Emily', age: 19, city: 'Atlanta' }, - { name: 'Kevin', age: 19, city: 'Atlanta' } - ], - 'Los Angeles': [{ name: 'Michelle', age: 27, city: 'Los Angeles' }], - Manhattan: [{ name: 'Robert', age: 45, city: 'Manhattan' }] - }) - }) - - test('should turn an array in an object by key', () => { - const users = [ - { id: 1, name: 'Joe' }, - { id: 2, name: 'Sarah' }, - { id: 3, name: 'Chris' } - ] - - expect(jsonquery(users, ['keyBy', ['get', 'id']])).toEqual({ - 1: { id: 1, name: 'Joe' }, - 2: { id: 2, name: 'Sarah' }, - 3: { id: 3, name: 'Chris' } - }) - }) - - test('should handle duplicate keys in keyBy', () => { - const users = [ - { id: 1, name: 'Joe' }, - { id: 2, name: 'Sarah' }, - { id: 1, name: 'Chris' } - ] - - // keep the first occurrence - expect(jsonquery(users, ['keyBy', ['get', 'id']])).toEqual({ - 1: { id: 1, name: 'Joe' }, - 2: { id: 2, name: 'Sarah' } - }) - }) - - test('should get nested data from an object', () => { - expect(jsonquery(friendsData, ['get', 'friends'])).toEqual(data) - }) - - test('should get nested data from an array with objects', () => { - expect(jsonquery(nestedData, ['map', ['get', 'address', 'city']])).toEqual([ - 'New York', - 'Atlanta', - 'New York', - 'Atlanta', - 'Los Angeles', - 'Manhattan', - 'New York' - ]) - }) - - test('should get unique values from a list', () => { - expect(jsonquery([2, 3, 2, 7, 1, 1], ['uniq'])).toEqual([2, 3, 7, 1]) - }) - - test('should get unique objects by key', () => { - // keep the first occurrence - expect(jsonquery(data, ['uniqBy', ['get', 'city']])).toEqual([ - { name: 'Chris', age: 23, city: 'New York' }, - { name: 'Emily', age: 19, city: 'Atlanta' }, - { name: 'Michelle', age: 27, city: 'Los Angeles' }, - { name: 'Robert', age: 45, city: 'Manhattan' } - ]) - }) - - test('should calculate the sum', () => { - expect(jsonquery([2, 3, 2, 7, 1, 1], ['sum'])).toEqual(16) - }) - - test('should round a value', () => { - expect(jsonquery(null, ['round', 23.1345])).toEqual(23) - expect(jsonquery(null, ['round', 23.761])).toEqual(24) - expect(jsonquery(null, ['round', 23.1345, 2])).toEqual(23.13) - expect(jsonquery(null, ['round', 23.1345, 3])).toEqual(23.135) - expect(jsonquery({ a: 23.1345 }, ['round', ['get', 'a']])).toEqual(23) - }) - - test('should round an array with values', () => { - expect(jsonquery([2.24, 3.77, 4.49], ['map', ['round', ['get']]])).toEqual([2, 4, 4]) - expect(jsonquery([2.24, 3.77, 4.49], ['map', ['round', ['get'], 1]])).toEqual([2.2, 3.8, 4.5]) - }) - - test('should calculate the product', () => { - expect(jsonquery([2, 3, 2, 7, 1, 1], ['prod'])).toEqual(84) - }) - - test('should calculate the average', () => { - expect(jsonquery([2, 3, 2, 7, 1], ['average'])).toEqual(3) - }) - - test('should count the size of an array', () => { - expect(jsonquery([], ['size'])).toEqual(0) - expect(jsonquery([1, 2, 3], ['size'])).toEqual(3) - expect(jsonquery([1, 2, 3, 4, 5], ['size'])).toEqual(5) - }) - - test('should extract the keys of an object', () => { - expect(jsonquery({ a: 2, b: 3 }, ['keys'])).toEqual(['a', 'b']) - }) - - test('should extract the values of an object', () => { - expect(jsonquery({ a: 2, b: 3 }, ['values'])).toEqual([2, 3]) - }) - - test('should limit data', () => { - expect(jsonquery(data, ['limit', 2])).toEqual([ - { name: 'Chris', age: 23, city: 'New York' }, - { name: 'Emily', age: 19, city: 'Atlanta' } - ]) - }) - - test('should process "not"', () => { - expect(jsonquery(data, ['not', 2])).toEqual(false) - expect(jsonquery({ a: false }, ['not', ['get', 'a']])).toEqual(true) - expect(jsonquery({ a: true }, ['not', ['get', 'a']])).toEqual(false) - expect(jsonquery({ nested: { a: false } }, ['not', ['get', 'nested', 'a']])).toEqual(true) - expect(jsonquery({ nested: { a: true } }, ['not', ['get', 'nested', 'a']])).toEqual(false) - - expect(jsonquery(data, ['filter', ['not', ['eq', ['get', 'city'], 'New York']]])).toEqual([ - { name: 'Emily', age: 19, city: 'Atlanta' }, - { name: 'Kevin', age: 19, city: 'Atlanta' }, - { name: 'Michelle', age: 27, city: 'Los Angeles' }, - { name: 'Robert', age: 45, city: 'Manhattan' } - ]) - }) - - test('should process "exists"', () => { - expect(jsonquery({ a: false }, ['exists', ['get', 'a']])).toEqual(true) - expect(jsonquery({ a: null }, ['exists', ['get', 'a']])).toEqual(true) - expect(jsonquery({ a: 2 }, ['exists', ['get', 'a']])).toEqual(true) - expect(jsonquery({ a: 0 }, ['exists', ['get', 'a']])).toEqual(true) - expect(jsonquery({ a: '' }, ['exists', ['get', 'a']])).toEqual(true) - expect(jsonquery({ nested: { a: 2 } }, ['exists', ['get', 'nested', 'a']])).toEqual(true) - - expect(jsonquery({ a: undefined }, ['exists', ['get', 'a']])).toEqual(false) - expect(jsonquery({}, ['exists', ['get', 'a']])).toEqual(false) - expect(jsonquery({}, ['exists', ['get', 'nested', 'a']])).toEqual(false) - expect(jsonquery({}, ['exists', ['get', 'sort']])).toEqual(false) - - const detailsData = [ - { name: 'Chris', details: { age: 16 } }, - { name: 'Emily' }, - { name: 'Joe', details: { age: 18 } } - ] - expect(jsonquery(detailsData, ['filter', ['exists', ['get', 'details']]])).toEqual([ - { name: 'Chris', details: { age: 16 } }, - { name: 'Joe', details: { age: 18 } } - ]) - }) - - test('should process function eq', () => { - expect(jsonquery({ a: 6 }, ['eq', ['get', 'a'], 6])).toEqual(true) - expect(jsonquery({ a: 6 }, ['eq', ['get', 'a'], 2])).toEqual(false) - expect(jsonquery({ a: 6 }, ['eq', ['get', 'a'], '6'])).toEqual(false) - expect(jsonquery({ a: 'Hi' }, ['eq', ['get', 'a'], 'Hi'])).toEqual(true) - expect(jsonquery({ a: 'Hi' }, ['eq', ['get', 'a'], 'Hello'])).toEqual(false) - }) - - test('should process function gt', () => { - expect(jsonquery({ a: 6 }, ['gt', ['get', 'a'], 5])).toEqual(true) - expect(jsonquery({ a: 6 }, ['gt', ['get', 'a'], 6])).toEqual(false) - expect(jsonquery({ a: 6 }, ['gt', ['get', 'a'], 7])).toEqual(false) - }) - - test('should process function gte', () => { - expect(jsonquery({ a: 6 }, ['gte', ['get', 'a'], 5])).toEqual(true) - expect(jsonquery({ a: 6 }, ['gte', ['get', 'a'], 6])).toEqual(true) - expect(jsonquery({ a: 6 }, ['gte', ['get', 'a'], 7])).toEqual(false) + test('should execute a JSON query', () => { + expect(jsonquery({ name: 'Joe' }, ['get', 'name'])).toEqual('Joe') }) - test('should process function lt', () => { - expect(jsonquery({ a: 6 }, ['lt', ['get', 'a'], 5])).toEqual(false) - expect(jsonquery({ a: 6 }, ['lt', ['get', 'a'], 6])).toEqual(false) - expect(jsonquery({ a: 6 }, ['lt', ['get', 'a'], 7])).toEqual(true) + test('should execute a text query', () => { + expect(jsonquery({ name: 'Joe' }, '.name')).toEqual('Joe') }) - test('should process function lte', () => { - expect(jsonquery({ a: 6 }, ['lte', ['get', 'a'], 5])).toEqual(false) - expect(jsonquery({ a: 6 }, ['lte', ['get', 'a'], 6])).toEqual(true) - expect(jsonquery({ a: 6 }, ['lte', ['get', 'a'], 7])).toEqual(true) - }) - - test('should process function ne', () => { - expect(jsonquery({ a: 6 }, ['ne', ['get', 'a'], 6])).toEqual(false) - expect(jsonquery({ a: 6 }, ['ne', ['get', 'a'], 2])).toEqual(true) - expect(jsonquery({ a: 6 }, ['ne', ['get', 'a'], '6'])).toEqual(true) - expect(jsonquery({ a: 'Hi' }, ['ne', ['get', 'a'], 'Hi'])).toEqual(false) - expect(jsonquery({ a: 'Hi' }, ['ne', ['get', 'a'], 'Hello'])).toEqual(true) - }) - - test('should process function add', () => { - expect(jsonquery({ a: 6, b: 2 }, ['add', ['get', 'a'], ['get', 'b']])).toEqual(8) - }) - - test('should process function subtract', () => { - expect(jsonquery({ a: 6, b: 2 }, ['subtract', ['get', 'a'], ['get', 'b']])).toEqual(4) - }) - - test('should process function multiply', () => { - expect(jsonquery({ a: 6, b: 2 }, ['multiply', ['get', 'a'], ['get', 'b']])).toEqual(12) - }) - - test('should process function divide', () => { - expect(jsonquery({ a: 6, b: 2 }, ['divide', ['get', 'a'], ['get', 'b']])).toEqual(3) - }) - - test('should process function pow', () => { - expect(jsonquery({ a: 2, b: 3 }, ['pow', ['get', 'a'], ['get', 'b']])).toEqual(8) - expect(jsonquery({ a: 25, b: 1 / 2 }, ['pow', ['get', 'a'], ['get', 'b']])).toEqual(5) // sqrt - }) - - test('should process function mod (remainder)', () => { - expect(jsonquery({ a: 8, b: 3 }, ['mod', ['get', 'a'], ['get', 'b']])).toEqual(2) - }) - - test('should calculate the minimum value', () => { - expect(jsonquery([3, -4, 1, -7], ['min'])).toEqual(-7) - }) - - test('should calculate the absolute value', () => { - expect(jsonquery(null, ['abs', 2])).toEqual(2) - expect(jsonquery(null, ['abs', -2])).toEqual(2) - expect(jsonquery({ a: -3 }, ['abs', ['get', 'a']])).toEqual(3) - expect(jsonquery([3, -4, 1, -7], ['map', ['abs', ['get']]])).toEqual([3, 4, 1, 7]) - }) - - test('should process multiple operations', () => { - expect( - jsonquery(friendsData, [ - ['get', 'friends'], - ['filter', ['eq', ['get', 'city'], 'New York']], - ['sort', ['get', 'age']], - ['map', ['get', 'name']], - ['limit', 2] - ]) - ).toEqual(['Chris', 'Sarah']) - }) - - test('should extend with a custom function "times"', () => { - const options = { - functions: { - times: (value: number) => (data: number[]) => data.map((item) => item * value) - } - } - - expect(jsonquery([1, 2, 3], ['times', 2], options)).toEqual([2, 4, 6]) - expect(() => jsonquery([1, 2, 3], ['times', 2])).toThrow('Unknown function "times"') - }) - - test('should override an existing function', () => { - const options = { - functions: { - sort: () => (_data: unknown[]) => 'custom sort' - } + test('should execute a JSON query with custom functions', () => { + const functions = { + customFn: () => (_data: unknown) => 42 } - expect(jsonquery([2, 3, 1], ['sort'], options)).toEqual('custom sort') + expect(jsonquery({}, ['customFn'], { functions })).toEqual(42) }) - test('should be able to insert a function in a nested compile', () => { - const options = { - functions: { - times: (value: JSONQuery) => { - const _options = { - functions: { - foo: () => (_data: unknown) => 42 - } - } - const _value = compile(value, _options) - - return (data: number[]) => data.map((item) => item * (_value(data) as number)) - } - } + test('should execute a text query with custom functions', () => { + const functions = { + customFn: () => (_data: unknown) => 42 } - expect(jsonquery([1, 2, 3], ['times', 2], options)).toEqual([2, 4, 6]) - expect(jsonquery([1, 2, 3], ['times', ['foo']], options)).toEqual([42, 84, 126]) - - // The function `foo` must not be available outside the `times` function - expect(() => jsonquery([1, 2, 3], ['foo'], options)).toThrow('Unknown function "foo"') + expect(jsonquery({ name: 'Joe' }, '.name', { functions })).toEqual('Joe') }) - test('should cleanup the custom function stack when creating a query throws an error', () => { - const options = { - functions: { - sort: () => { - throw new Error('Test Error') - } - } + test('should execute a JSON query with custom operators', () => { + const operators = { + aboutEq: '~=' } - expect(() => jsonquery({}, ['sort'], options)).toThrow('Test Error') - - expect(jsonquery([2, 3, 1], ['sort'])).toEqual([1, 2, 3]) + expect(jsonquery({ name: 'Joe' }, ['get', 'name'], { operators })).toEqual('Joe') }) - test('should extend with a custom function abouteq', () => { - const options = { - functions: { - abouteq: buildFunction((a, b) => a == b) // loosely equal - } - } - - expect(jsonquery({ a: 2 }, ['abouteq', ['get', 'a'], 2], options)).toEqual(true) - expect(jsonquery({ a: 2 }, ['abouteq', ['get', 'a'], '2'], options)).toEqual(true) - }) - - test('should use functions to calculate a shopping cart', () => { - const data = [ - { name: 'bread', price: 2.5, quantity: 2 }, - { name: 'milk', price: 1.2, quantity: 3 } - ] - - expect( - jsonquery(data, [['map', ['multiply', ['get', 'price'], ['get', 'quantity']]], ['sum']]) - ).toEqual(8.6) - }) - - test('should be able to query the jmespath example', () => { - const options = { - functions: { - join: - (separator = ', ') => - (data: unknown[]) => - data.join(separator) - } - } - - const data = { - locations: [ - { name: 'Seattle', state: 'WA' }, - { name: 'New York', state: 'NY' }, - { name: 'Bellevue', state: 'WA' }, - { name: 'Olympia', state: 'WA' } - ] + test('should execute a text query with custom operators', () => { + const operators = { + aboutEq: '~=' } - // locations[?state == 'WA'].name | sort(@) | {WashingtonCities: join(', ', @)} - expect( - jsonquery( - data, - [ - ['get', 'locations'], - ['filter', ['eq', ['get', 'state'], 'WA']], - ['map', ['get', 'name']], - ['sort'], - { WashingtonCities: ['join'] } - ], - options - ) - ).toEqual({ - WashingtonCities: 'Bellevue, Olympia, Seattle' - }) + expect(jsonquery({ name: 'Joe' }, '.name', { operators })).toEqual('Joe') }) }) diff --git a/src/jsonquery.ts b/src/jsonquery.ts index 4342b0a..a82794e 100644 --- a/src/jsonquery.ts +++ b/src/jsonquery.ts @@ -1,6 +1,12 @@ -import { JSONQuery, JSONQueryOptions } from './types' import { compile } from './compile' +import { isString } from './is' +import { parse } from './parse' +import type { JSONQuery, JSONQueryOptions } from './types' -export function jsonquery(data: unknown, query: JSONQuery, options?: JSONQueryOptions): unknown { - return compile(query, options)(data) +export function jsonquery( + data: unknown, + query: string | JSONQuery, + options?: JSONQueryOptions +): unknown { + return compile(isString(query) ? parse(query, options) : query, options)(data) } diff --git a/src/parse.test.ts b/src/parse.test.ts new file mode 100644 index 0000000..f2042c2 --- /dev/null +++ b/src/parse.test.ts @@ -0,0 +1,347 @@ +import { describe, expect, test } from 'vitest' +import { parse } from './parse' +import type { JSONQueryParseOptions } from './types' + +describe('parse', () => { + describe('property', () => { + test('should parse a property without quotes', () => { + expect(parse('.name')).toEqual(['get', 'name']) + expect(parse('.AaZz_$')).toEqual(['get', 'AaZz_$']) + expect(parse('.AaZz09_$')).toEqual(['get', 'AaZz09_$']) + expect(parse('.9')).toEqual(['get', 9]) + expect(parse('.123')).toEqual(['get', 123]) + expect(parse('.0')).toEqual(['get', 0]) + expect(parse(' .name ')).toEqual(['get', 'name']) + expect(() => parse('.')).toThrow('Property expected (pos: 1)') + }) + + test('should throw an error in case of an invalid unquoted property', () => { + expect(() => parse('.01')).toThrow("Unexpected part '1'") + expect(() => parse('.1abc')).toThrow("Unexpected part 'abc'") + expect(() => parse('.[')).toThrow('Property expected (pos: 1)') + }) + + test('should parse a property with quotes', () => { + expect(parse('."name"')).toEqual(['get', 'name']) + expect(parse(' ."name" ')).toEqual(['get', 'name']) + expect(parse('."escape \\n \\"chars"')).toEqual(['get', 'escape \n "chars']) + }) + + test('should throw an error when a property misses an end quote', () => { + expect(() => parse('."name')).toThrow('Property expected (pos: 1)') + }) + + test('should throw an error when there is whitespace between the dot and the property name', () => { + expect(() => parse('. "name"')).toThrow('Property expected (pos: 1)') + expect(() => parse('."address" ."city"')).toThrow('Unexpected part \'."city"\' (pos: 11)') + expect(() => parse('.address .city')).toThrow("Unexpected part '.city' (pos: 9)") + }) + + test('should parse a nested property', () => { + expect(parse('.address.city')).toEqual(['get', 'address', 'city']) + expect(parse('."address"."city"')).toEqual(['get', 'address', 'city']) + expect(parse('."address"."city"')).toEqual(['get', 'address', 'city']) + expect(parse('.array.2')).toEqual(['get', 'array', 2]) + }) + + test('should throw an error in case of an invalid property', () => { + expect(() => parse('.foo#')).toThrow("Unexpected part '#'") + expect(() => parse('.foo#bar')).toThrow("Unexpected part '#bar'") + }) + }) + + describe('function', () => { + test('should parse a function without arguments', () => { + expect(parse('sort()')).toEqual(['sort']) + expect(parse('sort( )')).toEqual(['sort']) + expect(parse('sort ( )')).toEqual(['sort']) + expect(parse(' sort ( ) ')).toEqual(['sort']) + }) + + test('should parse a function with one argument', () => { + expect(parse('sort(.age)')).toEqual(['sort', ['get', 'age']]) + expect(parse('sort(get())')).toEqual(['sort', ['get']]) + expect(parse('sort ( .age )')).toEqual(['sort', ['get', 'age']]) + }) + + test('should parse a function with multiple arguments', () => { + expect(parse('sort(.age, "desc")')).toEqual(['sort', ['get', 'age'], 'desc']) + expect(parse('sort(get(), "desc")')).toEqual(['sort', ['get'], 'desc']) + }) + + test('should parse a custom function', () => { + const options: JSONQueryParseOptions = { + functions: { customFn: true } + } + + expect(parse('customFn(.age, "desc")', options)).toEqual(['customFn', ['get', 'age'], 'desc']) + }) + + test('should throw an error in case of an unknown function name', () => { + expect(() => parse('foo(42)')).toThrow("Unknown function 'foo' (pos: 4)") + }) + + test('should throw an error when the end bracket is missing', () => { + expect(() => parse('sort(.age, "desc"')).toThrow("Character ')' expected (pos: 17)") + }) + + test('should throw an error when a comma is missing', () => { + expect(() => parse('sort(.age "desc")')).toThrow("Character ',' expected (pos: 10)") + }) + }) + + describe('operator', () => { + test('should parse an operator', () => { + expect(parse('.score==8')).toEqual(['eq', ['get', 'score'], 8]) + expect(parse('.score == 8')).toEqual(['eq', ['get', 'score'], 8]) + expect(parse('.score < 8')).toEqual(['lt', ['get', 'score'], 8]) + expect(parse('.score <= 8')).toEqual(['lte', ['get', 'score'], 8]) + expect(parse('.score > 8')).toEqual(['gt', ['get', 'score'], 8]) + expect(parse('.score >= 8')).toEqual(['gte', ['get', 'score'], 8]) + expect(parse('.score != 8')).toEqual(['ne', ['get', 'score'], 8]) + expect(parse('.score + 8')).toEqual(['add', ['get', 'score'], 8]) + expect(parse('.score - 8')).toEqual(['subtract', ['get', 'score'], 8]) + expect(parse('.score * 8')).toEqual(['multiply', ['get', 'score'], 8]) + expect(parse('.score / 8')).toEqual(['divide', ['get', 'score'], 8]) + expect(parse('.score ^ 8')).toEqual(['pow', ['get', 'score'], 8]) + expect(parse('.score % 8')).toEqual(['mod', ['get', 'score'], 8]) + expect(parse('.name in ["Joe", "Sarah"]')).toEqual([ + 'in', + ['get', 'name'], + ['array', 'Joe', 'Sarah'] + ]) + expect(parse('.name not in ["Joe", "Sarah"]')).toEqual([ + 'not in', + ['get', 'name'], + ['array', 'Joe', 'Sarah'] + ]) + }) + + test('should parse an operator having the same name as a function', () => { + expect(parse('0 and 1')).toEqual(['and', 0, 1]) + expect(parse('.a and .b')).toEqual(['and', ['get', 'a'], ['get', 'b']]) + }) + + test('should parse nested operators', () => { + expect(parse('(.a == "A") and (.b == "B")')).toEqual([ + 'and', + ['eq', ['get', 'a'], 'A'], + ['eq', ['get', 'b'], 'B'] + ]) + + expect(parse('(.a == "A") or (.b == "B")')).toEqual([ + 'or', + ['eq', ['get', 'a'], 'A'], + ['eq', ['get', 'b'], 'B'] + ]) + + expect(parse('(.a == "A") or ((.b == "B") and (.c == "C"))')).toEqual([ + 'or', + ['eq', ['get', 'a'], 'A'], + ['and', ['eq', ['get', 'b'], 'B'], ['eq', ['get', 'c'], 'C']] + ]) + + expect(parse('(.a * 2) + 3')).toEqual(['add', ['multiply', ['get', 'a'], 2], 3]) + expect(parse('3 + (.a * 2)')).toEqual(['add', 3, ['multiply', ['get', 'a'], 2]]) + }) + + test('should throw an error when using multiple operators without brackets', () => { + expect(() => parse('.a == "A" and .b == "B"')).toThrow('Unexpected part \'and .b == "B"\'') + expect(() => parse('(.a == "A") and (.b == "B") and (.C == "C")')).toThrow( + 'Unexpected part \'and (.C == "C")\' (pos: 28)' + ) + expect(() => parse('.a + 2 * 3')).toThrow("Unexpected part '* 3' (pos: 7)") + }) + + test('should throw an error in case of an unknown operator', () => { + expect(() => parse('.a === "A"')).toThrow('Value expected (pos: 5)') + expect(() => parse('.a <> "A"')).toThrow('Value expected (pos: 4)') + }) + + test('should throw an error in case a missing right hand side', () => { + expect(() => parse('.a ==')).toThrow('Value expected (pos: 5)') + }) + + test('should throw an error in case a missing left and right hand side', () => { + expect(() => parse('+')).toThrow('Value expected (pos: 0)') + expect(() => parse(' +')).toThrow('Value expected (pos: 1)') + }) + + test('should parse a custom operator', () => { + const options: JSONQueryParseOptions = { + operators: { aboutEq: '~=' } + } + + expect(parse('.score ~= 8', options)).toEqual(['aboutEq', ['get', 'score'], 8]) + }) + }) + + describe('pipe', () => { + test('should parse a pipe', () => { + expect(parse('.friends | sort(.age)')).toEqual([ + 'pipe', + ['get', 'friends'], + ['sort', ['get', 'age']] + ]) + + expect(parse('.friends | sort(.age) | filter(.age >= 18)')).toEqual([ + 'pipe', + ['get', 'friends'], + ['sort', ['get', 'age']], + ['filter', ['gte', ['get', 'age'], 18]] + ]) + }) + + test('should throw an error when a value is missing after a pipe', () => { + expect(() => parse('.friends |')).toThrow('Value expected (pos: 10)') + }) + + test('should throw an error when a value is missing before a pipe', () => { + expect(() => parse('| .friends ')).toThrow('Value expected (pos: 0)') + }) + }) + + describe('parentheses', () => { + test('should parse parentheses', () => { + expect(parse('(.friends)')).toEqual(['get', 'friends']) + expect(parse('( .friends)')).toEqual(['get', 'friends']) + expect(parse('(.friends )')).toEqual(['get', 'friends']) + expect(parse('(.age == 18)')).toEqual(['eq', ['get', 'age'], 18]) + expect(parse('(42)')).toEqual(42) + expect(parse(' ( 42 ) ')).toEqual(42) + expect(parse('((42))')).toEqual(42) + }) + + test('should throw an error when missing closing parenthesis', () => { + expect(() => parse('(.friends')).toThrow("Character ')' expected (pos: 9)") + }) + }) + + describe('object', () => { + test('should parse a basic object', () => { + expect(parse('{}')).toEqual(['object', {}]) + expect(parse('{ }')).toEqual(['object', {}]) + expect(parse('{a:1}')).toEqual(['object', { a: 1 }]) + expect(parse('{a1:1}')).toEqual(['object', { a1: 1 }]) + expect(parse('{AaZz_$019:1}')).toEqual(['object', { AaZz_$019: 1 }]) + expect(parse('{ a : 1 }')).toEqual(['object', { a: 1 }]) + expect(parse('{a:1,b:2}')).toEqual(['object', { a: 1, b: 2 }]) + expect(parse('{ a : 1 , b : 2 }')).toEqual(['object', { a: 1, b: 2 }]) + expect(parse('{ "a" : 1 , "b" : 2 }')).toEqual(['object', { a: 1, b: 2 }]) + expect(parse('{2:"two"}')).toEqual(['object', { 2: 'two' }]) + expect(parse('{null:null}')).toEqual(['object', { null: null }]) + expect(parse('{"":"empty"}')).toEqual(['object', { '': 'empty' }]) + }) + + test('should parse a larger object', () => { + expect( + parse(`{ + name: .name, + city: .address.city, + averageAge: map(.age) | average() + }`) + ).toEqual([ + 'object', + { + name: ['get', 'name'], + city: ['get', 'address', 'city'], + averageAge: ['pipe', ['map', ['get', 'age']], ['average']] + } + ]) + }) + + test('should throw an error when missing closing parenthesis', () => { + expect(() => parse('{a:1')).toThrow("Character '}' expected (pos: 4)") + }) + + test('should throw an error when missing a comma', () => { + expect(() => parse('{a:1 b:2}')).toThrow("Character ',' expected (pos: 5)") + }) + + test('should throw an error when missing a colon', () => { + expect(() => parse('{a')).toThrow("Character ':' expected (pos: 2)") + }) + + test('should throw an error when missing a key', () => { + expect(() => parse('{{')).toThrow('Key expected (pos: 1)') + expect(() => parse('{a:2,{')).toThrow('Key expected (pos: 5)') + }) + + test('should throw an error when missing a value', () => { + expect(() => parse('{a:')).toThrow('Value expected (pos: 3)') + expect(() => parse('{a:2,b:}')).toThrow('Value expected (pos: 7)') + }) + + test('should throw an error in case of a trailing comma', () => { + expect(() => parse('{a:2,}')).toThrow('Key expected (pos: 5)') + }) + }) + + describe('array', () => { + test('should parse an array', () => { + expect(parse('[]')).toEqual(['array']) + expect(parse(' [ ] ')).toEqual(['array']) + expect(parse('[1, 2, 3]')).toEqual(['array', 1, 2, 3]) + expect(parse(' [ 1 , 2 , 3 ] ')).toEqual(['array', 1, 2, 3]) + expect(parse('[(1 + 3), 2, 4]')).toEqual(['array', ['add', 1, 3], 2, 4]) + expect(parse('[2, (1 + 2), 4]')).toEqual(['array', 2, ['add', 1, 2], 4]) + }) + + test('should throw an error when missing closing bracket', () => { + expect(() => parse('[1,2')).toThrow("Character ']' expected (pos: 4)") + }) + + test('should throw an error when missing a comma', () => { + expect(() => parse('[1 2]')).toThrow("Character ',' expected (pos: 3)") + }) + + test('should throw an error when missing a value', () => { + expect(() => parse('[1,')).toThrow('Value expected (pos: 3)') + }) + + test('should throw an error in case of a trailing comma', () => { + expect(() => parse('[1,2,]')).toThrow('Value expected (pos: 5)') + }) + }) + + test('should parse a string', () => { + expect(parse('"hello"')).toEqual('hello') + expect(parse(' "hello"')).toEqual('hello') + expect(parse('"hello" ')).toEqual('hello') + expect(() => parse('"hello')).toThrow('Value expected (pos: 0)') + }) + + test('should parse a number', () => { + expect(parse('42')).toEqual(42) + expect(parse('-42')).toEqual(-42) + expect(parse('2.3')).toEqual(2.3) + expect(parse('-2.3')).toEqual(-2.3) + expect(parse('2.3e2')).toEqual(230) + expect(parse('2.3e+2')).toEqual(230) + expect(parse('2.3e-2')).toEqual(0.023) + expect(parse('2.3E+2')).toEqual(230) + expect(parse('2.3E-2')).toEqual(0.023) + }) + + test('should parse a boolean', () => { + expect(parse('true')).toEqual(true) + expect(parse('false')).toEqual(false) + }) + + test('should parse null', () => { + expect(parse('null')).toEqual(null) + }) + + test('should throw an error in case of garbage at the end', () => { + expect(() => parse('null 2')).toThrow("Unexpected part '2' (pos: 5)") + expect(() => parse('sort() 2')).toThrow("Unexpected part '2' (pos: 7)") + }) + + test('should skip whitespace characters', () => { + expect(parse(' \n\r\t"hello" \n\r\t')).toEqual('hello') + }) + + test('should throw when the query is empty', () => { + expect(() => parse('')).toThrow('Value expected (pos: 0)') + }) +}) diff --git a/src/parse.ts b/src/parse.ts new file mode 100644 index 0000000..48f1fcd --- /dev/null +++ b/src/parse.ts @@ -0,0 +1,243 @@ +import { + operators, + startsWithIntRegex, + startsWithKeywordRegex, + startsWithNumberRegex, + startsWithStringRegex, + startsWithUnquotedPropertyRegex, + startsWithWhitespaceRegex +} from './constants' +import { functions } from './functions' +import type { JSONQuery, JSONQueryParseOptions } from './types' + +/** + * Parse a string containing a JSON Query into JSON. + * + * Example: + * + * const textQuery = '.friends | filter(.city == "new York") | sort(.age) | pick(.name, .age)' + * const jsonQuery = parse(textQuery) + * // jsonQuery = [ + * // 'pipe', + * // ['get', 'friends'], + * // ['filter', ['eq', ['get', 'city'], 'New York']], + * // ['sort', ['get', 'age']], + * // ['pick', ['get', 'name'], ['get', 'age']] + * // ] + */ +export function parse(query: string, options?: JSONQueryParseOptions): JSONQuery { + const parsePipe = () => { + skipWhitespace() + const first = parseOperator() + skipWhitespace() + + if (query[i] === '|') { + const pipe = [first] + + while (query[i] === '|') { + i++ + skipWhitespace() + + pipe.push(parseOperator()) + } + + return ['pipe', ...pipe] + } + + return first + } + + const parseOperator = () => { + const allOperators = { ...operators, ...options?.operators } + + const left = parseParentheses() + + skipWhitespace() + + // we sort the operators from longest to shortest, so we first handle "<=" and next "<" + for (const name of Object.keys(allOperators).sort((a, b) => b.length - a.length)) { + const op = allOperators[name] + if (query.substring(i, i + op.length) === op) { + i += op.length + skipWhitespace() + const right = parseParentheses() + + return [name, left, right] + } + } + + return left + } + + const parseParentheses = () => { + if (query[i] === '(') { + i++ + const inner = parsePipe() + eatChar(')') + return inner + } + + return parseProperty() + } + + const parseProperty = () => { + const props = [] + + if (query[i] === '.') { + while (query[i] === '.') { + i++ + + props.push( + parseString() ?? + parseUnquotedString() ?? + parseInteger() ?? + throwError('Property expected') + ) + } + + return ['get', ...props] + } + + return parseFunction() + } + + const parseFunction = () => { + const start = i + const name = parseUnquotedString() + skipWhitespace() + if (!name || query[i] !== '(') { + i = start + return parseObject() + } + i++ + + if (!options?.functions[name] && !functions[name]) { + throwError(`Unknown function '${name}'`) + } + + skipWhitespace() + + const args = query[i] !== ')' ? [parsePipe()] : [] + while (i < query.length && query[i] !== ')') { + skipWhitespace() + eatChar(',') + args.push(parsePipe()) + } + + eatChar(')') + + return [name, ...args] + } + + const parseObject = () => { + if (query[i] === '{') { + i++ + skipWhitespace() + + const object = {} + let first = true + while (i < query.length && query[i] !== '}') { + if (first) { + first = false + } else { + eatChar(',') + skipWhitespace() + } + + const key = + parseString() ?? parseUnquotedString() ?? parseInteger() ?? throwError('Key expected') + + skipWhitespace() + eatChar(':') + + object[key] = parsePipe() + } + + eatChar('}') + + return ['object', object] + } + + return parseArray() + } + + const parseArray = () => { + if (query[i] === '[') { + i++ + skipWhitespace() + + const array = [] + + let first = true + while (i < query.length && query[i] !== ']') { + if (first) { + first = false + } else { + eatChar(',') + skipWhitespace() + } + + array.push(parsePipe()) + } + + eatChar(']') + + return ['array', ...array] + } + + return parseString() ?? parseNumber() ?? parseKeyword() + } + + const parseString = () => parseRegex(startsWithStringRegex, JSON.parse) + + const parseUnquotedString = () => parseRegex(startsWithUnquotedPropertyRegex, (text) => text) + + const parseNumber = () => parseRegex(startsWithNumberRegex, JSON.parse) + + const parseInteger = () => parseRegex(startsWithIntRegex, JSON.parse) + + const parseKeyword = () => { + const keyword = parseRegex(startsWithKeywordRegex, JSON.parse) + if (keyword !== undefined) { + return keyword + } + + // end of the parsing chain + throwError('Value expected') + } + + const parseEnd = () => { + skipWhitespace() + + if (i < query.length) { + throwError(`Unexpected part '${query.substring(i)}'`) + } + } + + const parseRegex = (regex: RegExp, callback: (match: string) => T): T | undefined => { + const match = query.substring(i).match(regex) + if (match) { + i += match[0].length + return callback(match[0]) + } + } + + const skipWhitespace = () => parseRegex(startsWithWhitespaceRegex, (text) => text) + + const eatChar = (char: string) => { + if (query[i] !== char) { + throwError(`Character '${char}' expected`) + } + i++ + } + + const throwError = (message: string, pos = i) => { + throw new SyntaxError(`${message} (pos: ${pos})`) + } + + let i = 0 + const output = parsePipe() + parseEnd() + + return output +} diff --git a/src/stringify.test.ts b/src/stringify.test.ts new file mode 100644 index 0000000..2a98028 --- /dev/null +++ b/src/stringify.test.ts @@ -0,0 +1,193 @@ +import { describe, expect, test } from 'vitest' +import { stringify } from './stringify' +import type { JSONQueryStringifyOptions } from './types' + +describe('stringify', () => { + test('should stringify a function', () => { + expect(stringify(['sort', ['get', 'age'], 'desc'])).toEqual('sort(.age, "desc")') + expect(stringify(['filter', ['gt', ['get', 'age'], 18]])).toEqual('filter(.age > 18)') + }) + + test('should stringify a function with indentation', () => { + expect(stringify(['sort', ['get', 'age'], 'desc'], { maxLineLength: 4 })).toEqual( + 'sort(\n .age,\n "desc"\n)' + ) + }) + + test('should stringify a nested function with indentation', () => { + expect( + stringify(['object', { sorted: ['sort', ['get', 'age'], 'desc'] }], { maxLineLength: 4 }) + ).toEqual('{\n sorted: sort(\n .age,\n "desc"\n )\n}') + }) + + test('should stringify a nested function having one argument with indentation', () => { + expect( + stringify(['map', ['object', { name: ['get', 'name'], city: ['get', 'address', 'city'] }]], { + maxLineLength: 4 + }) + ).toEqual('map({\n name: .name,\n city: .address.city\n})') + }) + + test('should stringify a property', () => { + expect(stringify(['get'])).toEqual('get()') + expect(stringify(['get', 'age'])).toEqual('.age') + expect(stringify(['get', 'address', 'city'])).toEqual('.address.city') + expect(stringify(['get', 'with space'])).toEqual('."with space"') + expect(stringify(['get', 'with special !'])).toEqual('."with special !"') + }) + + test('should stringify an operator', () => { + expect(stringify(['add', 2, 3])).toEqual('(2 + 3)') + }) + + test('should stringify an custom operator', () => { + const options: JSONQueryStringifyOptions = { + operators: { aboutEq: '~=' } + } + + expect(stringify(['aboutEq', 2, 3], options)).toEqual('(2 ~= 3)') + expect(stringify(['filter', ['aboutEq', 2, 3]], options)).toEqual('filter(2 ~= 3)') + expect(stringify(['object', { result: ['aboutEq', 2, 3] }], options)).toEqual( + '{ result: (2 ~= 3) }' + ) + expect(stringify(['eq', 2, 3], options)).toEqual('(2 == 3)') + }) + + test('should stringify a pipe', () => { + expect(stringify(['pipe', ['get', 'age'], ['average']])).toEqual('.age | average()') + }) + + test('should stringify a pipe with indentation', () => { + expect(stringify(['pipe', ['get', 'age'], ['average']], { maxLineLength: 10 })).toEqual( + '.age\n | average()' + ) + }) + + test('should stringify a nested pipe with indentation', () => { + const query = ['object', { nested: ['pipe', ['get', 'age'], ['average']] }] + expect(stringify(query, { maxLineLength: 10 })).toEqual('{\n nested: .age\n | average()\n}') + }) + + test('should stringify an object', () => { + expect( + stringify(['object', { name: ['get', 'name'], city: ['get', 'address', 'city'] }]) + ).toEqual('{ name: .name, city: .address.city }') + }) + + test('should stringify an object with indentation', () => { + const query = ['object', { name: ['get', 'name'], city: ['get', 'address', 'city'] }] + + expect(stringify(query, { maxLineLength: 20 })).toEqual( + '{\n name: .name,\n city: .address.city\n}' + ) + }) + + test('should stringify a nested object with indentation', () => { + const query = [ + 'object', + { + name: ['get', 'name'], + address: [ + 'object', + { + city: ['get', 'city'], + street: ['get', 'street'] + } + ] + } + ] + + expect(stringify(query, { maxLineLength: 4 })).toEqual( + '{\n name: .name,\n address: {\n city: .city,\n street: .street\n }\n}' + ) + }) + + test('should stringify an object with custom indentation', () => { + const query = ['object', { name: ['get', 'name'], city: ['get', 'address', 'city'] }] + + expect(stringify(query, { maxLineLength: 20, indentation: ' ' })).toEqual( + '{\n name: .name,\n city: .address.city\n}' + ) + + expect(stringify(query, { maxLineLength: 20, indentation: '\t' })).toEqual( + '{\n\tname: .name,\n\tcity: .address.city\n}' + ) + }) + + test('should stringify an array', () => { + expect(stringify(['array', 1, 2, 3])).toEqual('[1, 2, 3]') + expect(stringify(['array', ['add', 1, 2], 4, 5])).toEqual('[(1 + 2), 4, 5]') + expect(stringify(['filter', ['in', ['get', 'age'], ['array', 19, 23]]])).toEqual( + 'filter(.age in [19, 23])' + ) + }) + + test('should stringify an array with indentation', () => { + expect(stringify(['array', 1, 2, 3], { maxLineLength: 4 })).toEqual('[\n 1,\n 2,\n 3\n]') + }) + + test('should stringify a nested array with indentation', () => { + expect(stringify(['object', { array: ['array', 1, 2, 3] }], { maxLineLength: 4 })).toEqual( + '{\n array: [\n 1,\n 2,\n 3\n ]\n}' + ) + }) + + test('should stringify a composed query (1)', () => { + expect( + stringify(['pipe', ['map', ['multiply', ['get', 'price'], ['get', 'quantity']]], ['sum']]) + ).toEqual('map(.price * .quantity) | sum()') + }) + + test('should stringify a composed query (2)', () => { + expect( + stringify([ + 'pipe', + ['get', 'friends'], + ['filter', ['eq', ['get', 'city'], 'New York']], + ['sort', ['get', 'age']], + ['pick', ['get', 'name'], ['get', 'age']] + ]) + ).toEqual(`.friends + | filter(.city == "New York") + | sort(.age) + | pick(.name, .age)`) + }) + + test('should stringify a composed query (3)', () => { + expect( + stringify(['filter', ['and', ['gte', ['get', 'age'], 23], ['lte', ['get', 'age'], 27]]]) + ).toEqual('filter((.age >= 23) and (.age <= 27))') + }) + + test('should stringify a composed query (4)', () => { + expect( + stringify([ + 'pipe', + ['get', 'friends'], + [ + 'object', + { + names: ['map', ['get', 'name']], + count: ['size'], + averageAge: ['pipe', ['map', ['get', 'age']], ['average']] + } + ] + ]) + ).toEqual( + '.friends\n | {\n names: map(.name),\n count: size(),\n averageAge: map(.age) | average()\n }' + ) + }) + + test('should stringify a composed query (5)', () => { + expect( + stringify([ + 'object', + { + name: ['get', 'name'], + city: ['get', 'address', 'city'], + averageAge: ['pipe', ['map', ['get', 'age']], ['average']] + } + ]) + ).toEqual('{\n name: .name,\n city: .address.city,\n averageAge: map(.age) | average()\n}') + }) +}) diff --git a/src/stringify.ts b/src/stringify.ts new file mode 100644 index 0000000..8a96442 --- /dev/null +++ b/src/stringify.ts @@ -0,0 +1,127 @@ +import { operators, unquotedPropertyRegex } from './constants' +import { isArray } from './is' +import type { + JSONPath, + JSONQuery, + JSONQueryFunction, + JSONQueryObject, + JSONQueryStringifyOptions +} from './types' + +const DEFAULT_MAX_LINE_LENGTH = 40 +const DEFAULT_INDENTATION = ' ' + +/** + * Stringify a JSON Query into a readable, human friendly text syntax. + * + * Example: + * + * const jsonQuery = [ + * ['get', 'friends'], + * ['filter', ['eq', ['get', 'city'], 'New York']], + * ['sort', ['get', 'age']], + * ['pick', ['get', 'name'], ['get', 'age']] + * ] + * const textQuery = stringify(jsonQuery) + * // textQuery = '.friends | filter(.city == "new York") | sort(.age) | pick(.name, .age)' + * + * @param query The JSON Query to be stringified + * @param {Object} [options] An object which can have the following options: + * `maxLineLength` Optional maximum line length. When the query exceeds this maximum, + * It will be formatted over multiple lines. Default value: 40. + * `indentation` Optional indentation. Defaults to a string with two spaces: ' '. + */ +export const stringify = (query: JSONQuery, options?: JSONQueryStringifyOptions) => { + const space = options?.indentation ?? DEFAULT_INDENTATION + + const _stringify = (query: JSONQuery, indent: string) => + isArray(query) ? stringifyFunction(query as JSONQueryFunction, indent) : JSON.stringify(query) // value (string, number, boolean, null) + + const stringifyFunction = (query: JSONQueryFunction, indent: string) => { + const [name, ...args] = query + + if (name === 'get' && args.length > 0) { + return stringifyPath(args as JSONPath) + } + + if (name === 'pipe') { + const argsStr = args.map((arg) => _stringify(arg, indent + space)) + + return join(argsStr, ['', ' | ', ''], ['', `\n${indent + space}| `, '']) + } + + if (name === 'object') { + return stringifyObject(args[0] as JSONQueryObject, indent) + } + + if (name === 'array') { + const argsStr = args.map((arg) => _stringify(arg, indent)) + return join( + argsStr, + ['[', ', ', ']'], + [`[\n${indent + space}`, `,\n${indent + space}`, `\n${indent}]`] + ) + } + + // operator like ".age >= 18" + const op = options?.operators?.[name] ?? operators[name] + if (op && args.length === 2) { + const [left, right] = args + const leftStr = _stringify(left, indent) + const rightStr = _stringify(right, indent) + return `(${leftStr} ${op} ${rightStr})` + } + + // regular function like sort(.age) + const childIndent = args.length === 1 ? indent : indent + space + const argsStr = args.map((arg) => _stringify(arg, childIndent)) + return args.length === 1 && argsStr[0][0] === '(' + ? `${name}${argsStr}` + : join( + argsStr, + [`${name}(`, ', ', ')'], + args.length === 1 + ? [`${name}(`, `,\n${indent}`, ')'] + : [`${name}(\n${childIndent}`, `,\n${childIndent}`, `\n${indent})`] + ) + } + + const stringifyObject = (query: JSONQueryObject, indent: string) => { + const childIndent = indent + space + const entries = Object.entries(query).map(([key, value]) => { + return `${stringifyProperty(key)}: ${_stringify(value, childIndent)}` + }) + + return join( + entries, + ['{ ', ', ', ' }'], + [`{\n${childIndent}`, `,\n${childIndent}`, `\n${indent}}`] + ) + } + + const stringifyPath = (path: JSONPath): string => + path.map((prop) => `.${stringifyProperty(prop)}`).join('') + + const stringifyProperty = (prop: string): string => + unquotedPropertyRegex.test(prop) ? prop : JSON.stringify(prop) + + type JoinDefinition = [start: string, separator: string, end: string] + + const join = ( + items: string[], + [compactStart, compactSeparator, compactEnd]: JoinDefinition, + [formatStart, formatSeparator, formatEnd]: JoinDefinition + ): string => { + const compactLength = + compactStart.length + + items.reduce((sum: number, item: string) => sum + item.length + compactSeparator.length, 0) - + compactSeparator.length + + compactEnd.length + + return compactLength <= (options?.maxLineLength ?? DEFAULT_MAX_LINE_LENGTH) + ? compactStart + items.join(compactSeparator) + compactEnd + : formatStart + items.join(formatSeparator) + formatEnd + } + + return _stringify(query, '') +} diff --git a/src/types.ts b/src/types.ts index 387c74c..8bff95e 100644 --- a/src/types.ts +++ b/src/types.ts @@ -10,9 +10,25 @@ export type JSONQueryProperty = ['get', path?: string | JSONPath] export interface JSONQueryOptions { functions?: FunctionBuildersMap + operators?: Record } -export type Function = (data: unknown) => unknown -export type FunctionBuilder = (...args: JSONQuery[]) => Function +export interface JSONQueryCompileOptions { + functions?: FunctionBuildersMap +} + +export interface JSONQueryStringifyOptions { + operators?: Record + maxLineLength?: number + indentation?: string +} + +export interface JSONQueryParseOptions { + functions?: Record | FunctionBuildersMap + operators?: Record +} + +export type Fun = (data: unknown) => unknown +export type FunctionBuilder = (...args: JSONQuery[]) => Fun export type FunctionBuildersMap = Record -export type Getter = [key: string, Function] +export type Getter = [key: string, Fun] diff --git a/test-lib/apps/esmApp.mjs b/test-lib/apps/esmApp.mjs index 7f60d8d..ed348dd 100644 --- a/test-lib/apps/esmApp.mjs +++ b/test-lib/apps/esmApp.mjs @@ -11,6 +11,7 @@ const data = [ ] const result = jsonquery(data, [ + 'pipe', ['filter', ['eq', ['get', 'city'], 'New York']], ['map', ['get', 'name']] ]) diff --git a/test-lib/lib.test.js b/test-lib/lib.test.js index 9b2e11e..fcbf883 100644 --- a/test-lib/lib.test.js +++ b/test-lib/lib.test.js @@ -1,7 +1,7 @@ import cp from 'node:child_process' import { dirname, join } from 'node:path' import { fileURLToPath } from 'node:url' -import { describe, test, expect } from 'vitest' +import { describe, expect, test } from 'vitest' const __filename = fileURLToPath(import.meta.url) const __dirname = dirname(__filename) @@ -16,7 +16,7 @@ describe('lib', () => { function run(command) { return new Promise((resolve, reject) => { - cp.exec(command, function (error, result) { + cp.exec(command, (error, result) => { if (error) { reject(error) } else { diff --git a/vite.config.js b/vite.config.js index 53211e9..8653487 100644 --- a/vite.config.js +++ b/vite.config.js @@ -7,5 +7,8 @@ export default { }, outDir: './lib', sourcemap: true + }, + coverage: { + provider: 'v8' } }