├── .github └── workflows │ ├── build.yaml │ └── release.yaml ├── .gitignore ├── .releaserc ├── LICENSE.md ├── README.md ├── bin ├── cli.js └── help.js ├── biome.json ├── package-lock.json ├── package.json ├── reference └── functions.md ├── src ├── compile.test.ts ├── compile.ts ├── functions.ts ├── is.ts ├── jsonquery.test.ts ├── jsonquery.ts ├── operators.test.ts ├── operators.ts ├── parse.test.ts ├── parse.ts ├── regexps.ts ├── stringify.test.ts ├── stringify.ts └── types.ts ├── test-lib ├── apps │ └── esmApp.mjs ├── cli.test.js ├── data │ ├── input.json │ ├── query.json │ └── query.txt ├── lib.test.js ├── output │ └── .gitignore ├── test-parse-stringify.html └── test.html ├── test-suite ├── README.md ├── compile.test.d.ts ├── compile.test.json ├── compile.test.schema.json ├── parse.test.d.ts ├── parse.test.json ├── parse.test.schema.json ├── stringify.test.d.ts ├── stringify.test.json └── stringify.test.schema.json ├── tsconfig-types.json ├── tsconfig.json └── vite.config.js /.github/workflows/build.yaml: -------------------------------------------------------------------------------- 1 | name: Node.js CI 2 | 3 | on: [push, pull_request] 4 | 5 | jobs: 6 | build: 7 | runs-on: ubuntu-latest 8 | 9 | strategy: 10 | matrix: 11 | node-version: [20.x, 22.x, 24.x] 12 | 13 | steps: 14 | - uses: actions/checkout@v4 15 | - name: Use Node.js ${{ matrix.node-version }} 16 | uses: actions/setup-node@v4 17 | with: 18 | node-version: ${{ matrix.node-version }} 19 | - run: npm install 20 | - run: npm run build-and-test 21 | env: 22 | CI: true 23 | -------------------------------------------------------------------------------- /.github/workflows/release.yaml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | 8 | permissions: 9 | contents: read 10 | 11 | jobs: 12 | release: 13 | name: Release 14 | runs-on: ubuntu-latest 15 | permissions: 16 | contents: write 17 | issues: write 18 | pull-requests: write 19 | id-token: write 20 | steps: 21 | - name: Checkout 22 | uses: actions/checkout@v4 23 | with: 24 | fetch-depth: 0 25 | - name: Setup Node.js 26 | uses: actions/setup-node@v4 27 | with: 28 | node-version: 'lts/*' 29 | - name: Install dependencies 30 | run: npm clean-install 31 | - name: Verify dependencies 32 | run: npm audit signatures 33 | - name: Release 34 | env: 35 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 36 | NPM_TOKEN: ${{ secrets.NPM_TOKEN }} 37 | run: npm run release 38 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | lib 3 | coverage 4 | .idea -------------------------------------------------------------------------------- /.releaserc: -------------------------------------------------------------------------------- 1 | { 2 | "plugins": [ 3 | "@semantic-release/commit-analyzer", 4 | "@semantic-release/release-notes-generator", 5 | "@semantic-release/npm", 6 | [ 7 | "@semantic-release/github", 8 | { 9 | "assets": [ 10 | { 11 | "path": "lib/jsonquery.js" 12 | } 13 | ] 14 | } 15 | ] 16 | ] 17 | } -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | The ISC License 2 | 3 | Copyright (c) 2024-2025 by Jos de Jong 4 | 5 | Permission to use, copy, modify, and/or distribute this software for any purpose with or without fee is hereby granted, provided that the above copyright notice and this permission notice appear in all copies. 6 | 7 | THE SOFTWARE IS PROVIDED "AS IS" AND ISC DISCLAIMS ALL WARRANTIES WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL ISC BE LIABLE FOR ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. 8 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # JSON Query 2 | 3 | ![JSON Query Logo](https://jsonquerylang.org/frog-756900-100.png) 4 | 5 | A small, flexible, and expandable JSON query language. 6 | 7 | Try it out on the online playground: 8 | 9 | ![JSON Query Overview](https://jsonquerylang.org/jsonquery-overview.svg) 10 | 11 | ## Features 12 | 13 | - Small: just `4.0 kB` when minified and gzipped! The JSON query engine without parse/stringify is only `2.0 kB`. 14 | - Feature rich (50+ powerful functions and operators) 15 | - Easy to interoperate with thanks to the intermediate JSON format. 16 | - Expressive 17 | - Expandable 18 | 19 | ## Documentation 20 | 21 | On this page: 22 | 23 | - [Installation](#installation) 24 | - [Usage](#usage) 25 | - [JavaScript API](#javascript-api) 26 | - [Command line interface (CLI)](#command-line-interface-cli) 27 | - [Development](#development) 28 | - [License](#license) 29 | 30 | External pages: 31 | 32 | - [JSON Query Documentation](https://jsonquerylang.org/docs/) 33 | - [JSON Query Function reference](https://jsonquerylang.org/reference/) 34 | - [JSON Query Test Suite](test-suite/README.md) 35 | 36 | ## Installation 37 | 38 | Install the JavaScript library via [npm](https://www.npmjs.com/): 39 | 40 | ```text 41 | npm install @jsonquerylang/jsonquery 42 | ``` 43 | 44 | A Python implementation can be found here: https://github.com/jsonquerylang/jsonquery-python 45 | 46 | ## Usage 47 | 48 | ```js 49 | import { jsonquery } from '@jsonquerylang/jsonquery' 50 | 51 | const data = { 52 | "friends": [ 53 | { "name": "Chris", "age": 23, "city": "New York" }, 54 | { "name": "Emily", "age": 19, "city": "Atlanta" }, 55 | { "name": "Joe", "age": 32, "city": "New York" }, 56 | { "name": "Kevin", "age": 19, "city": "Atlanta" }, 57 | { "name": "Michelle", "age": 27, "city": "Los Angeles" }, 58 | { "name": "Robert", "age": 45, "city": "Manhattan" }, 59 | { "name": "Sarah", "age": 31, "city": "New York" } 60 | ] 61 | } 62 | 63 | // Get the array containing the friends from the object, filter the friends that live in New York, 64 | // sort them by age, and pick just the name and age out of the objects. 65 | const output = jsonquery(data, ` 66 | .friends 67 | | filter(.city == "New York") 68 | | sort(.age) 69 | | pick(.name, .age) 70 | `) 71 | // output = [ 72 | // { "name": "Chris", "age": 23 }, 73 | // { "name": "Sarah", "age": 31 }, 74 | // { "name": "Joe", "age": 32 } 75 | // ] 76 | 77 | // The same query can be written in JSON format instead of the text format. 78 | // Note that the functions `parse` and `stringify` can be used 79 | // to convert from text format to JSON format and vice versa. 80 | jsonquery(data, [ 81 | "pipe", 82 | ["get", "friends"], 83 | ["filter", ["eq", ["get", "city"], "New York"]], 84 | ["sort", ["get", "age"]], 85 | ["pick", ["get", "name"], ["get", "age"]] 86 | ]) 87 | ``` 88 | 89 | The build in functions can be extended with custom functions, like `times` in the following example: 90 | 91 | ```js 92 | import { jsonquery } from '@jsonquerylang/jsonquery' 93 | 94 | const options = { 95 | functions: { 96 | times: (value) => (data) => data.map((item) => item * value) 97 | } 98 | } 99 | 100 | const data = [1, 2, 3] 101 | const result = jsonquery(data, 'times(3)', options) 102 | // [3, 6, 9] 103 | ``` 104 | 105 | Documentation on the syntax of JSON Query and all supported functions can be found on the website: https://jsonquerylang.org/docs/. 106 | 107 | ## JavaScript API 108 | 109 | The library exports the following functions: 110 | 111 | - [`jsonquery`](#jsonquery) is the core function of the library, which parses, compiles, and evaluates a query in one go. 112 | - [`compile`](#compile) to compile and evaluate a query. 113 | - [`parse`](#parse) to parse a query in text format into JSON. 114 | - [`stringify`](#stringify) to convert a query in JSON into the text format. 115 | - [`buildFunction`](#buildfunction) a helper function to create a custom function. 116 | 117 | ### jsonquery 118 | 119 | The function `jsonquery` allows to pass data and a query in one go and parse, compile and execute it: 120 | 121 | ```text 122 | jsonquery(data: JSON, query: string | JSONQuery, options: JSONQueryOptions) : JSON 123 | ``` 124 | 125 | Here: 126 | 127 | - `data` is the JSON document that will be queried, often an array with objects. 128 | - `query` is a JSON document containing a [JSON query](https://jsonquerylang.org/docs/), either the text format or the parsed JSON format. 129 | - `options` is an optional object that can contain the following properties: 130 | - `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: 131 | 132 | ```js 133 | const options = { 134 | functions: { 135 | // usage example: 'times(3)' 136 | times: (value) => (data) => data.map((item) => item * value) 137 | } 138 | } 139 | ``` 140 | 141 | If the parameters are not a static value but can be a query themselves, the function `compile` can be used to compile them. For example, the actual implementation of the function `filter` is the following: 142 | 143 | ```js 144 | const options = { 145 | functions: { 146 | // usage example: 'filter(.age > 20)' 147 | filter: (predicate) => { 148 | const _predicate = compile(predicate) 149 | return (data) => data.filter(_predicate) 150 | } 151 | } 152 | } 153 | ``` 154 | 155 | You can have a look at the source code of the functions in [`/src/functions.ts`](/src/functions.ts) for more examples. 156 | 157 | - `operators` is an optional array definitions for custom operators. Each definition describes the new operator, the name of the function that it maps to, and the desired precedence of the operator: the same, before, or after one of the existing operators (`at`, `before`, or `after`): 158 | 159 | ```ts 160 | type CustomOperator = 161 | | { name: string; op: string; at: string; vararg?: boolean, leftAssociative?: boolean } 162 | | { name: string; op: string; after: string; vararg?: boolean, leftAssociative?: boolean } 163 | | { name: string; op: string; before: string; vararg?: boolean, leftAssociative?: boolean } 164 | ``` 165 | 166 | The defined operators can be used in a text query. Only operators with both a left and right hand side are supported, like `a == b`. They can only be executed when there is a corresponding function. For example: 167 | 168 | ```js 169 | import { buildFunction } from '@jsonquerylang/jsonquery' 170 | 171 | const options = { 172 | // Define a new function "notEqual". 173 | functions: { 174 | notEqual: buildFunction((a, b) => a !== b) 175 | }, 176 | 177 | // Define a new operator "<>" which maps to the function "notEqual" 178 | // and has the same precedence as operator "==". 179 | operators: [ 180 | { name: 'aboutEq', op: '~=', at: '==' } 181 | ] 182 | } 183 | ``` 184 | 185 | To allow using a chain of multiple operators without parenthesis, like `a and b and c`, the option `leftAssociative` can be set `true`. Without this, an exception will be thrown, which can be solved by using parenthesis like `(a and b) and c`. 186 | 187 | When the function of the operator supports more than two arguments, like `and(a, b, c, ...)`, the option `vararg` can be set `true`. In that case, a chain of operators like `a and b and c` will be parsed into the JSON Format `["and", a, b, c, ...]`. Operators that do not support variable arguments, like `1 + 2 + 3`, will be parsed into a nested JSON Format like `["add", ["add", 1, 2], 3]`. 188 | 189 | All build-in operators and their precedence are listed on the documentation page in the section [Operators](https://jsonquerylang.org/docs/#operators). 190 | 191 | Here an example of using the function `jsonquery`: 192 | 193 | ```js 194 | import { jsonquery } from '@jsonquerylang/jsonquery' 195 | 196 | const data = [ 197 | { "name": "Chris", "age": 23 }, 198 | { "name": "Emily", "age": 19 }, 199 | { "name": "Joe", "age": 32 } 200 | ] 201 | 202 | const result = jsonquery(data, ["filter", ["gt", ["get", "age"], 20]]) 203 | // result = [ 204 | // { "name": "Chris", "age": 23 }, 205 | // { "name": "Joe", "age": 32 } 206 | // ] 207 | ``` 208 | 209 | ### compile 210 | 211 | 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`. 212 | 213 | ```text 214 | compile(query: JSONQuery, options: JSONQueryOptions) => (data: JSON) => JSON 215 | ``` 216 | 217 | Example: 218 | 219 | ```js 220 | import { compile } from '@jsonquerylang/jsonquery' 221 | 222 | const queryIt = compile(["filter", ["gt", ["get", "age"], 20]]) 223 | 224 | const data = [ 225 | { "name": "Chris", "age": 23 }, 226 | { "name": "Emily", "age": 19 }, 227 | { "name": "Joe", "age": 32 } 228 | ] 229 | 230 | const result = queryIt(data) 231 | // result = [ 232 | // { "name": "Chris", "age": 23 }, 233 | // { "name": "Joe", "age": 32 } 234 | // ] 235 | ``` 236 | 237 | ### parse 238 | 239 | Function `parse` parses a query in text format into JSON. Function `stringify` can be used to do the opposite. 240 | 241 | ```text 242 | parse(query: text, options: JSONQueryParseOptions) : JSONQuery 243 | ``` 244 | 245 | Example: 246 | 247 | ```js 248 | import { parse } from '@jsonquerylang/jsonquery' 249 | 250 | const text = 'filter(.age > 20)' 251 | const json = parse(text) 252 | // json = ["filter", ["gt", ["get", "age"], 20]] 253 | ``` 254 | 255 | ### stringify 256 | 257 | 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. 258 | 259 | ```text 260 | stringify(query: JSONQuery, options: JSONQueryStringifyOptions) : string 261 | ``` 262 | 263 | Example: 264 | 265 | ```js 266 | import { stringify } from '@jsonquerylang/jsonquery' 267 | 268 | const json = ["filter", ["gt", ["get", "age"], 20]] 269 | const text = stringify(json) 270 | // text = 'filter(.age > 20)' 271 | ``` 272 | 273 | ### buildFunction 274 | 275 | 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. 276 | 277 | 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: 278 | 279 | ```ts 280 | const options = { 281 | functions: { 282 | notEqual: (a: JSONQuery, b: JSONQuery) => { 283 | const aCompiled = compile(a) 284 | const bCompiled = compile(b) 285 | 286 | return (data: unknown) => { 287 | const aEvaluated = aCompiled(data) 288 | const bEvaluated = bCompiled(data) 289 | 290 | return aEvaluated !== bEvaluated 291 | } 292 | } 293 | } 294 | } 295 | 296 | const data = { x: 2, y: 3} 297 | const result = jsonquery(data, '(.x + .y) <> 6', options) // true 298 | ``` 299 | 300 | To automatically compile and evaluate the arguments of the function, the helper function `buildFunction` can be used: 301 | 302 | ```ts 303 | import { jsonquery, buildFunction } from '@jsonquerylang/jsonquery' 304 | 305 | const options = { 306 | functions: { 307 | notEqual: buildFunction((a: number, b: number) => a !== b) 308 | } 309 | } 310 | 311 | const data = { x: 2, y: 3} 312 | const result = jsonquery(data, '(.x + .y) <> 6', options) // true 313 | ``` 314 | 315 | ### error handling 316 | 317 | 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 }>`. 318 | 319 | ```js 320 | const data = { 321 | "participants": [ 322 | { "name": "Chris", "age": 23, "scores": [7.2, 5, 8.0] }, 323 | { "name": "Emily", "age": 19 }, 324 | { "name": "Joe", "age": 32, "scores": [6.1, 8.1] } 325 | ] 326 | } 327 | 328 | try { 329 | jsonquery(data, [ 330 | ["get", "participants"], 331 | ["map", [["get", "scores"], ["sum"]]] 332 | ]) 333 | } catch (err) { 334 | console.log(err.jsonquery) 335 | // error stack: 336 | // [ 337 | // { 338 | // "data": { 339 | // "participants": [ 340 | // { "name": "Chris", "age": 23, "scores": [7.2, 5, 8.0] }, 341 | // { "name": "Emily", "age": 19 }, 342 | // { "name": "Joe", "age": 32, "scores": [6.1, 8.1] } 343 | // ] 344 | // }, 345 | // "query": [ 346 | // ["get", "participants"], 347 | // ["map", [["get", "scores"], ["sum"]]] 348 | // ] 349 | // }, 350 | // { 351 | // "data": [ 352 | // { "name": "Chris", "age": 23, "scores": [7.2, 5, 8.0] }, 353 | // { "name": "Emily", "age": 19 }, 354 | // { "name": "Joe", "age": 32, "scores": [6.1, 8.1] } 355 | // ], 356 | // "query": ["map", [["get", "scores"], ["sum"]]] 357 | // }, 358 | // { 359 | // "data": { "name": "Emily", "age": 19 }, 360 | // "query": [["get", "scores"], ["sum"]] 361 | // }, 362 | // { 363 | // "data" : undefined, 364 | // "query": ["sum"] 365 | // } 366 | // ] 367 | } 368 | ``` 369 | 370 | ## Command line interface (CLI) 371 | 372 | When `jsonquery` is installed globally using npm, it can be used on the command line. To install `jsonquery` globally: 373 | 374 | ```bash 375 | $ npm install -g @jsonquerylang/jsonquery 376 | ``` 377 | 378 | Usage: 379 | 380 | ``` 381 | $ jsonquery [query] {OPTIONS} 382 | ``` 383 | 384 | Options: 385 | 386 | ``` 387 | --input Input file name 388 | --query Query file name 389 | --output Output file name 390 | --format Can be "text" (default) or "json" 391 | --indentation A string containing the desired indentation, 392 | like " " (default) or " " or "\t". An empty 393 | string will create output without indentation. 394 | --overwrite If true, output can overwrite an existing file 395 | --version, -v Show application version 396 | --help, -h Show this message 397 | ``` 398 | 399 | Example usage: 400 | 401 | ``` 402 | $ jsonquery --input users.json 'sort(.age)' 403 | $ jsonquery --input users.json 'filter(.city == "Rotterdam") | sort(.age)' 404 | $ jsonquery --input users.json 'sort(.age)' > output.json 405 | $ jsonquery --input users.json 'sort(.age)' --output output.json 406 | $ jsonquery --input users.json --query query.txt 407 | $ jsonquery --input users.json --query query.json --format json 408 | $ cat users.json | jsonquery 'sort(.age)' 409 | $ cat users.json | jsonquery 'sort(.age)' > output.json 410 | ``` 411 | 412 | ## Development 413 | 414 | ### JavaScript 415 | 416 | To develop, check out the JavaScript repo, install dependencies once, and then use the following scripts: 417 | 418 | ```text 419 | npm run test 420 | npm run test-ci 421 | npm run lint 422 | npm run format 423 | npm run coverage 424 | npm run build 425 | npm run build-and-test 426 | npm run release-dry-run 427 | ``` 428 | 429 | Note that a new package is published on [npm](https://www.npmjs.com/package/@jsonquerylang/jsonquery) and [GitHub](https://github.com/jsonquerylang/jsonquery/releases) on changes pushed to the `main` branch. This is done using [`semantic-release`](https://github.com/semantic-release/semantic-release), and we do not use the `version` number in the `package.json` file. A changelog can be found by looking at the [releases on GitHub](https://github.com/jsonquerylang/jsonquery/releases). 430 | 431 | ## License 432 | 433 | Released under the [ISC license](LICENSE.md). 434 | -------------------------------------------------------------------------------- /bin/cli.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | import { existsSync, readFileSync, writeFileSync } from 'node:fs' 3 | import { dirname, join } from 'node:path' 4 | import { fileURLToPath } from 'node:url' 5 | import { parseArgs } from 'node:util' 6 | import { jsonquery } from '../lib/jsonquery.js' 7 | import { help } from './help.js' 8 | 9 | const __filename = fileURLToPath(import.meta.url) 10 | const __dirname = dirname(__filename) 11 | 12 | const options = { 13 | input: { type: 'string' }, 14 | query: { type: 'string' }, 15 | output: { type: 'string' }, 16 | format: { type: 'string', default: 'text' }, 17 | overwrite: { type: 'boolean', default: false }, 18 | indentation: { type: 'string', default: ' ' }, 19 | version: { type: 'boolean', short: 'v' }, 20 | help: { type: 'boolean', short: 'h' } 21 | } 22 | 23 | const { 24 | values, 25 | positionals: [inlineQuery] 26 | } = parseArgs({ options, allowPositionals: true }) 27 | 28 | await run({ ...values, inlineQuery }) 29 | 30 | /** 31 | * @param {Options} options 32 | * @returns {Promise} 33 | */ 34 | async function run(options) { 35 | if (options.version) { 36 | return writeVersion() 37 | } 38 | 39 | if (options.help) { 40 | return writeHelp() 41 | } 42 | 43 | try { 44 | const input = await readInput(options) 45 | const query = readQuery(options) 46 | 47 | const output = jsonquery(input, query) 48 | 49 | return writeOutput(options, output) 50 | } catch (err) { 51 | process.stderr.write(err.toString()) 52 | process.exit(1) 53 | } 54 | } 55 | 56 | /** 57 | * @param {Options} options 58 | * @returns {Promise} 59 | */ 60 | async function readInput(options) { 61 | const inputStr = options.input ? fileToString(options.input) : await streamToString(process.stdin) 62 | 63 | if (inputStr.trim() === '') { 64 | throw Error('No input data provided') 65 | } 66 | 67 | return JSON.parse(inputStr) 68 | } 69 | 70 | /** 71 | * @param {Options} options 72 | * @returns {Promise} 73 | */ 74 | function readQuery(options) { 75 | const queryStr = options.query 76 | ? fileToString(options.query) 77 | : options.inlineQuery 78 | ? options.inlineQuery 79 | : throwError('No query provided') 80 | 81 | return options.format === 'text' || options.format === undefined 82 | ? queryStr 83 | : options.format === 'json' 84 | ? JSON.parse(queryStr) 85 | : throwError(`Unknown format "${options.format}". Choose either "text" (default) or "json".`) 86 | } 87 | 88 | /** 89 | * @param {Options} options 90 | * @param {JSON} output 91 | */ 92 | function writeOutput(options, output) { 93 | const outputStr = JSON.stringify(output, null, options.indentation) 94 | 95 | if (options.output) { 96 | if (existsSync(options.output) && !options.overwrite) { 97 | throwError(`Cannot overwrite existing file "${options.output}"`) 98 | } 99 | 100 | writeFileSync(options.output, outputStr) 101 | } else { 102 | process.stdout.write(outputStr) 103 | } 104 | } 105 | 106 | function writeVersion() { 107 | const file = join(__dirname, '../package.json') 108 | const pkg = JSON.parse(String(readFileSync(file, 'utf-8'))) 109 | 110 | process.stdout.write(pkg.version) 111 | } 112 | 113 | function writeHelp() { 114 | process.stdout.write(help) 115 | } 116 | 117 | function fileToString(fileName) { 118 | return String(readFileSync(fileName)) 119 | } 120 | 121 | /** 122 | * @param {ReadableStream} readableStream 123 | * @returns {Promise} 124 | */ 125 | function streamToString(readableStream) { 126 | return new Promise((resolve, reject) => { 127 | let text = '' 128 | 129 | readableStream.on('data', (chunk) => { 130 | text += String(chunk) 131 | }) 132 | readableStream.on('end', () => { 133 | readableStream.destroy() 134 | resolve(text) 135 | }) 136 | readableStream.on('error', (err) => reject(err)) 137 | }) 138 | } 139 | 140 | function throwError(message) { 141 | throw new Error(message) 142 | } 143 | 144 | /** 145 | * @typedef {Object} Options 146 | * @property {boolean} [version] 147 | * @property {boolean} [help] 148 | * @property {string} [input] 149 | * @property {string} [query] 150 | * @property {string} [output] 151 | * @property {string} [inlineQuery] 152 | * @property {'text' | 'json'} [format='text'] 153 | * @property {string} [indentation=' '] 154 | * @property {boolean} [overwrite=false] 155 | */ 156 | -------------------------------------------------------------------------------- /bin/help.js: -------------------------------------------------------------------------------- 1 | export const help = ` 2 | jsonquery 3 | https://github.com/jsonquerylang/jsonquery 4 | 5 | Query JSON documents. The command line application requires an input document 6 | and query, and returns output containing the query result. The query can be 7 | either in text format (default) or json format. 8 | 9 | Usage: 10 | 11 | jsonquery [query] {OPTIONS} 12 | 13 | Options: 14 | 15 | --input Input file name 16 | --query Query file name 17 | --output Output file name 18 | --format Can be "text" (default) or "json" 19 | --indentation A string containing the desired indentation, 20 | like " " (default) or " " or "\\t". An empty 21 | string will create output without indentation. 22 | --overwrite If true, output can overwrite an existing file 23 | --version, -v Show application version 24 | --help, -h Show this message 25 | 26 | Example usage: 27 | 28 | jsonquery --input users.json 'sort(.age)' 29 | jsonquery --input users.json 'filter(.city == "Rotterdam") | sort(.age)' 30 | jsonquery --input users.json 'sort(.age)' > output.json 31 | jsonquery --input users.json 'sort(.age)' --output output.json 32 | jsonquery --input users.json --query query.txt 33 | jsonquery --input users.json --query query.json --format json 34 | cat users.json | jsonquery 'sort(.age)' 35 | cat users.json | jsonquery 'sort(.age)' > output.json 36 | 37 | ` 38 | -------------------------------------------------------------------------------- /biome.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://biomejs.dev/schemas/1.9.1/schema.json", 3 | "vcs": { "enabled": false, "clientKind": "git", "useIgnoreFile": false }, 4 | "files": { "ignoreUnknown": false, "ignore": ["lib", "coverage", "package.json"] }, 5 | "formatter": { 6 | "enabled": true, 7 | "useEditorconfig": true, 8 | "formatWithErrors": false, 9 | "indentStyle": "space", 10 | "indentWidth": 2, 11 | "lineEnding": "lf", 12 | "lineWidth": 100, 13 | "attributePosition": "auto", 14 | "bracketSpacing": true 15 | }, 16 | "organizeImports": { "enabled": true }, 17 | "linter": { "enabled": true, "rules": { "recommended": true } }, 18 | "javascript": { 19 | "formatter": { 20 | "quoteProperties": "asNeeded", 21 | "trailingCommas": "none", 22 | "semicolons": "asNeeded", 23 | "arrowParentheses": "always", 24 | "bracketSameLine": false, 25 | "quoteStyle": "single", 26 | "attributePosition": "auto", 27 | "bracketSpacing": true 28 | } 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@jsonquerylang/jsonquery", 3 | "version": "0.0.0", 4 | "description": "A small, flexible, and expandable JSON query language", 5 | "keywords": [ 6 | "json", 7 | "query", 8 | "language", 9 | "small", 10 | "lightweight", 11 | "flexible", 12 | "expandable", 13 | "simple" 14 | ], 15 | "author": "Jos de Jong", 16 | "type": "module", 17 | "sideeffects": false, 18 | "license": "ISC", 19 | "repository": { 20 | "type": "git", 21 | "url": "git+https://github.com/jsonquerylang/jsonquery.git" 22 | }, 23 | "bin": { 24 | "jsonquery": "./bin/cli.js" 25 | }, 26 | "module": "./lib/jsonquery.js", 27 | "types": "./lib/jsonquery.d.ts", 28 | "exports": { 29 | ".": { 30 | "import": "./lib/jsonquery.js", 31 | "types": "./lib/jsonquery.d.ts" 32 | } 33 | }, 34 | "files": [ 35 | "lib", 36 | "bin", 37 | "LICENSE.md", 38 | "README.md" 39 | ], 40 | "scripts": { 41 | "test": "vitest src", 42 | "test-ci": "vitest run src", 43 | "coverage": "vitest run src --coverage", 44 | "build": "npm-run-all build:**", 45 | "build:esm": "vite build", 46 | "build:types": "tsc --project tsconfig-types.json", 47 | "build:validate": "vitest run test-lib", 48 | "lint": "biome check", 49 | "format": "biome check --write", 50 | "format:readme": "prettier README.md --write --ignore-path notneeded", 51 | "build-and-test": "npm-run-all test-ci lint build", 52 | "prepublishOnly": "npm run build-and-test", 53 | "release": "semantic-release", 54 | "release-dry-run": "semantic-release --dry-run --plugins \"@semantic-release/commit-analyzer\"" 55 | }, 56 | "devDependencies": { 57 | "@biomejs/biome": "1.9.4", 58 | "@vitest/coverage-v8": "3.1.3", 59 | "ajv": "8.17.1", 60 | "npm-run-all": "4.1.5", 61 | "semantic-release": "24.2.3", 62 | "typescript": "5.8.3", 63 | "vite": "6.3.5", 64 | "vitest": "3.1.3" 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /reference/functions.md: -------------------------------------------------------------------------------- 1 | # Functions 2 | 3 | The function reference has been moved to the website: https://jsonquerylang.org/reference/ 4 | -------------------------------------------------------------------------------- /src/compile.test.ts: -------------------------------------------------------------------------------- 1 | import Ajv from 'ajv' 2 | import { describe, expect, test } from 'vitest' 3 | import type { CompileTestException, CompileTestSuite } from '../test-suite/compile.test' 4 | import suite from '../test-suite/compile.test.json' 5 | import schema from '../test-suite/compile.test.schema.json' 6 | import { compile } from './compile' 7 | import { buildFunction } from './functions' 8 | import type { JSONQuery, JSONQueryCompileOptions } from './types' 9 | 10 | function isTestException(test: unknown): test is CompileTestException { 11 | return !!test && typeof (test as Record).throws === 'string' 12 | } 13 | 14 | const data = [ 15 | { name: 'Chris', age: 23, city: 'New York' }, 16 | { name: 'Emily', age: 19, city: 'Atlanta' }, 17 | { name: 'Joe', age: 32, city: 'New York' }, 18 | { name: 'Kevin', age: 19, city: 'Atlanta' }, 19 | { name: 'Michelle', age: 27, city: 'Los Angeles' }, 20 | { name: 'Robert', age: 45, city: 'Manhattan' }, 21 | { name: 'Sarah', age: 31, city: 'New York' } 22 | ] 23 | 24 | /** 25 | * Compile and execute 26 | */ 27 | function go(data: unknown, query: JSONQuery, options?: JSONQueryCompileOptions) { 28 | const exec = compile(query, options) 29 | return exec(data) 30 | } 31 | 32 | const groupByCategory = compile(['groupBy', ['get', 'category']]) 33 | const testsByCategory = groupByCategory(suite.groups) as Record 34 | 35 | for (const [category, testGroups] of Object.entries(testsByCategory)) { 36 | describe(category, () => { 37 | for (const group of testGroups) { 38 | describe(group.description, () => { 39 | for (const currentTest of group.tests) { 40 | const description = `input=${JSON.stringify(currentTest.input)}, query=${JSON.stringify(currentTest.query)}` 41 | 42 | if (isTestException(currentTest)) { 43 | test(description, () => { 44 | const { input, query, throws } = currentTest 45 | 46 | expect(() => compile(query)(input)).toThrow(throws) 47 | }) 48 | } else { 49 | test(description, () => { 50 | const { input, query, output } = currentTest 51 | const actualOutput = compile(query)(input) 52 | 53 | expect({ input, query, output: actualOutput }).toEqual({ input, query, output }) 54 | }) 55 | } 56 | } 57 | }) 58 | } 59 | }) 60 | } 61 | 62 | describe('error handling', () => { 63 | test('should throw a helpful error when a pipe contains a compile time error', () => { 64 | let actualErr = undefined 65 | try { 66 | go(data, ['foo', 42]) 67 | } catch (err) { 68 | actualErr = err 69 | } 70 | 71 | expect(actualErr?.message).toBe("Unknown function 'foo'") 72 | }) 73 | 74 | test('should throw a helpful error when passing an object {...} instead of function ["object", {...}]', () => { 75 | let actualErr = undefined 76 | const user = { name: 'Joe' } 77 | const query = { name: ['get', 'name'] } 78 | try { 79 | go(user, query) 80 | } catch (err) { 81 | actualErr = err 82 | } 83 | 84 | expect(actualErr?.message).toBe( 85 | 'Function notation ["object", {...}] expected but got {"name":["get","name"]}' 86 | ) 87 | }) 88 | 89 | test('should throw a helpful error when a pipe contains a runtime error', () => { 90 | const scoreData = { 91 | participants: [ 92 | { name: 'Chris', age: 23, scores: [7.2, 5, 8.0] }, 93 | { name: 'Emily', age: 19 }, 94 | { name: 'Joe', age: 32, scores: [6.1, 8.1] } 95 | ] 96 | } 97 | const query = [ 98 | 'pipe', 99 | ['get', 'participants'], 100 | ['map', ['pipe', ['get', 'scores'], ['map', ['round']], ['sum']]] 101 | ] 102 | 103 | let actualErr = undefined 104 | try { 105 | go(scoreData, query) 106 | } catch (err) { 107 | actualErr = err 108 | } 109 | 110 | expect(actualErr?.message).toBe("Cannot read properties of null (reading 'map')") 111 | expect(actualErr?.jsonquery).toEqual([ 112 | { data: scoreData, query }, 113 | { 114 | data: scoreData.participants, 115 | query: ['map', ['pipe', ['get', 'scores'], ['map', ['round']], ['sum']]] 116 | }, 117 | { 118 | data: { name: 'Emily', age: 19 }, 119 | query: ['pipe', ['get', 'scores'], ['map', ['round']], ['sum']] 120 | }, 121 | { data: null, query: ['map', ['round']] } 122 | ]) 123 | }) 124 | }) 125 | 126 | describe('customization', () => { 127 | test('should extend with a custom function "times"', () => { 128 | const options = { 129 | functions: { 130 | times: (value: number) => (data: number[]) => data.map((item) => item * value) 131 | } 132 | } 133 | 134 | expect(go([1, 2, 3], ['times', 2], options)).toEqual([2, 4, 6]) 135 | expect(() => go([1, 2, 3], ['times', 2])).toThrow("Unknown function 'times'") 136 | }) 137 | 138 | test('should extend with a custom function with more than 2 arguments', () => { 139 | const options = { 140 | functions: { 141 | oneOf: buildFunction( 142 | (value: unknown, a: unknown, b: unknown, c: unknown) => 143 | value === a || value === b || value === c 144 | ) 145 | } 146 | } 147 | 148 | expect(go('C', ['oneOf', ['get'], 'A', 'B', 'C'], options)).toEqual(true) 149 | expect(go('D', ['oneOf', ['get'], 'A', 'B', 'C'], options)).toEqual(false) 150 | }) 151 | 152 | test('should override an existing function', () => { 153 | const options = { 154 | functions: { 155 | sort: () => (_data: unknown[]) => 'custom sort' 156 | } 157 | } 158 | 159 | expect(go([2, 3, 1], ['sort'], options)).toEqual('custom sort') 160 | }) 161 | 162 | test('should be able to insert a function in a nested compile', () => { 163 | const options = { 164 | functions: { 165 | times: (value: JSONQuery) => { 166 | const _options = { 167 | functions: { 168 | foo: () => (_data: unknown) => 42 169 | } 170 | } 171 | const _value = compile(value, _options) 172 | 173 | return (data: number[]) => data.map((item) => item * (_value(data) as number)) 174 | } 175 | } 176 | } 177 | 178 | expect(go([1, 2, 3], ['times', 2], options)).toEqual([2, 4, 6]) 179 | expect(go([1, 2, 3], ['times', ['foo']], options)).toEqual([42, 84, 126]) 180 | 181 | // The function `foo` must not be available outside the `times` function 182 | expect(() => go([1, 2, 3], ['foo'], options)).toThrow("Unknown function 'foo'") 183 | }) 184 | 185 | test('should cleanup the custom function stack when creating a query throws an error', () => { 186 | const options = { 187 | functions: { 188 | sort: () => { 189 | throw new Error('Test Error') 190 | } 191 | } 192 | } 193 | 194 | expect(() => go({}, ['sort'], options)).toThrow('Test Error') 195 | 196 | expect(go([2, 3, 1], ['sort'])).toEqual([1, 2, 3]) 197 | }) 198 | 199 | test('should extend with a custom function aboutEq', () => { 200 | const options = { 201 | functions: { 202 | // biome-ignore lint/suspicious/noDoubleEquals: we want to test loosely equal here 203 | aboutEq: buildFunction((a, b) => a == b) // loosely equal 204 | } 205 | } 206 | 207 | expect(go({ a: 2 }, ['aboutEq', ['get', 'a'], 2], options)).toEqual(true) 208 | expect(go({ a: 2 }, ['aboutEq', ['get', 'a'], '2'], options)).toEqual(true) 209 | }) 210 | }) 211 | 212 | test('should validate the compile test-suite against its JSON schema', () => { 213 | const ajv = new Ajv({ allErrors: false }) 214 | const valid = ajv.validate(schema, suite) 215 | 216 | expect(ajv.errors).toEqual(null) 217 | expect(valid).toEqual(true) 218 | }) 219 | -------------------------------------------------------------------------------- /src/compile.ts: -------------------------------------------------------------------------------- 1 | import { functions, throwTypeError } from './functions' 2 | import { isArray, isObject } from './is' 3 | import type { 4 | Fun, 5 | FunctionBuildersMap, 6 | JSONQuery, 7 | JSONQueryCompileOptions, 8 | JSONQueryFunction 9 | } from './types' 10 | 11 | const functionsStack: FunctionBuildersMap[] = [] 12 | 13 | export function compile(query: JSONQuery, options?: JSONQueryCompileOptions): Fun { 14 | functionsStack.unshift({ ...functions, ...functionsStack[0], ...options?.functions }) 15 | 16 | try { 17 | const exec = isArray(query) 18 | ? compileFunction(query as JSONQueryFunction, functionsStack[0]) // function 19 | : isObject(query) 20 | ? throwTypeError( 21 | `Function notation ["object", {...}] expected but got ${JSON.stringify(query)}` 22 | ) 23 | : () => query // primitive value (string, number, boolean, null) 24 | 25 | // create a wrapper function which can attach a stack to the error 26 | return (data) => { 27 | try { 28 | return exec(data) 29 | } catch (err) { 30 | // attach a stack to the error 31 | err.jsonquery = [{ data, query }, ...(err.jsonquery ?? [])] 32 | 33 | throw err 34 | } 35 | } 36 | } finally { 37 | functionsStack.shift() 38 | } 39 | } 40 | 41 | function compileFunction(query: JSONQueryFunction, functions: FunctionBuildersMap) { 42 | const [fnName, ...args] = query 43 | 44 | const fnBuilder = functions[fnName] 45 | if (!fnBuilder) { 46 | throwTypeError(`Unknown function '${fnName}'`) 47 | } 48 | 49 | return fnBuilder(...args) 50 | } 51 | -------------------------------------------------------------------------------- /src/functions.ts: -------------------------------------------------------------------------------- 1 | import { compile } from './compile' 2 | import { isArray, isEqual } from './is' 3 | import type { 4 | Entry, 5 | FunctionBuilder, 6 | FunctionBuildersMap, 7 | Getter, 8 | JSONPath, 9 | JSONQuery, 10 | JSONQueryFunction, 11 | JSONQueryObject, 12 | JSONQueryProperty 13 | } from './types' 14 | 15 | export function buildFunction(fn: (...args: unknown[]) => unknown): FunctionBuilder { 16 | return (...args: JSONQuery[]) => { 17 | const compiledArgs = args.map((arg) => compile(arg)) 18 | 19 | const arg0 = compiledArgs[0] 20 | const arg1 = compiledArgs[1] 21 | 22 | return compiledArgs.length === 1 23 | ? (data: unknown) => fn(arg0(data)) 24 | : compiledArgs.length === 2 25 | ? (data: unknown) => fn(arg0(data), arg1(data)) 26 | : (data: unknown) => fn(...compiledArgs.map((arg) => arg(data))) 27 | } 28 | } 29 | 30 | const sortableTypes = { boolean: 0, number: 1, string: 2 } 31 | const otherTypes = 3 32 | 33 | const gt = (a: unknown, b: unknown) => 34 | typeof a === typeof b && (typeof a) in sortableTypes ? a > b : false 35 | 36 | const gte = (a: unknown, b: unknown) => isEqual(a, b) || gt(a, b) 37 | 38 | const lt = (a: unknown, b: unknown) => 39 | typeof a === typeof b && (typeof a) in sortableTypes ? a < b : false 40 | 41 | const lte = (a: unknown, b: unknown) => isEqual(a, b) || lt(a, b) 42 | 43 | export const functions: FunctionBuildersMap = { 44 | pipe: (...entries: JSONQuery[]) => { 45 | const _entries = entries.map((entry) => compile(entry)) 46 | 47 | return (data: unknown) => _entries.reduce((data, evaluator) => evaluator(data), data) 48 | }, 49 | 50 | object: (query: JSONQueryObject) => { 51 | const getters: Getter[] = Object.keys(query).map((key) => [key, compile(query[key])]) 52 | 53 | return (data: unknown) => { 54 | const obj = {} 55 | for (const [key, getter] of getters) { 56 | obj[key] = getter(data) 57 | } 58 | return obj 59 | } 60 | }, 61 | 62 | array: (...items: JSONQuery[]) => { 63 | const _items = items.map((entry: JSONQuery) => compile(entry)) 64 | 65 | return (data: unknown) => _items.map((item) => item(data)) 66 | }, 67 | 68 | get: (...path: JSONPath) => { 69 | if (path.length === 0) { 70 | return (data: unknown) => data ?? null 71 | } 72 | 73 | if (path.length === 1) { 74 | const prop = path[0] 75 | return (data: unknown) => data?.[prop] ?? null 76 | } 77 | 78 | return (data: unknown) => { 79 | let value = data 80 | 81 | for (const prop of path) { 82 | value = value?.[prop] 83 | } 84 | 85 | return value ?? null 86 | } 87 | }, 88 | 89 | map: (callback: JSONQuery) => { 90 | const _callback = compile(callback) 91 | 92 | return (data: T[]) => data.map(_callback) 93 | }, 94 | 95 | mapObject: (callback: JSONQuery) => { 96 | const _callback = compile(callback) 97 | 98 | return (data: Record) => { 99 | const output = {} 100 | for (const key of Object.keys(data)) { 101 | const updated = _callback({ key, value: data[key] }) as Entry 102 | output[updated.key] = updated.value 103 | } 104 | return output 105 | } 106 | }, 107 | 108 | mapKeys: (callback: JSONQuery) => { 109 | const _callback = compile(callback) 110 | 111 | return (data: Record) => { 112 | const output = {} 113 | for (const key of Object.keys(data)) { 114 | const updatedKey = _callback(key) as string 115 | output[updatedKey] = data[key] 116 | } 117 | return output 118 | } 119 | }, 120 | 121 | mapValues: (callback: JSONQuery) => { 122 | const _callback = compile(callback) 123 | 124 | return (data: Record) => { 125 | const output = {} 126 | for (const key of Object.keys(data)) { 127 | output[key] = _callback(data[key]) 128 | } 129 | return output 130 | } 131 | }, 132 | 133 | filter: (predicate: JSONQuery[]) => { 134 | const _predicate = compile(predicate) 135 | 136 | return (data: T[]) => data.filter((item) => truthy(_predicate(item))) 137 | }, 138 | 139 | sort: (path: JSONQueryProperty = ['get'], direction?: 'asc' | 'desc') => { 140 | const getter = compile(path) 141 | const sign = direction === 'desc' ? -1 : 1 142 | 143 | function compare(itemA: unknown, itemB: unknown) { 144 | const a = getter(itemA) 145 | const b = getter(itemB) 146 | 147 | // Order mixed types 148 | if (typeof a !== typeof b) { 149 | const aIndex = sortableTypes[typeof a] ?? otherTypes 150 | const bIndex = sortableTypes[typeof b] ?? otherTypes 151 | 152 | return aIndex > bIndex ? sign : aIndex < bIndex ? -sign : 0 153 | } 154 | 155 | // Order two numbers, two strings, or two booleans 156 | if ((typeof a) in sortableTypes) { 157 | return a > b ? sign : a < b ? -sign : 0 158 | } 159 | 160 | // Leave arrays, objects, and unknown types ordered as is 161 | return 0 162 | } 163 | 164 | return (data: T[]) => data.slice().sort(compare) 165 | }, 166 | 167 | reverse: 168 | () => 169 | (data: T[]) => 170 | data.toReversed(), 171 | 172 | pick: (...properties: JSONQueryProperty[]) => { 173 | const getters = properties.map( 174 | ([_get, ...path]) => [path[path.length - 1], functions.get(...path)] as Getter 175 | ) 176 | 177 | const _pick = (object: Record, getters: Getter[]): unknown => { 178 | const out = {} 179 | for (const [key, getter] of getters) { 180 | out[key] = getter(object) 181 | } 182 | return out 183 | } 184 | 185 | return (data: Record): unknown => { 186 | if (isArray(data)) { 187 | return data.map((item: Record) => _pick(item, getters)) 188 | } 189 | 190 | return _pick(data, getters) 191 | } 192 | }, 193 | 194 | groupBy: (path: JSONQueryProperty) => { 195 | const getter = compile(path) 196 | 197 | return (data: T[]) => { 198 | const res = {} 199 | 200 | for (const item of data) { 201 | const value = getter(item) as string 202 | if (res[value]) { 203 | res[value].push(item) 204 | } else { 205 | res[value] = [item] 206 | } 207 | } 208 | 209 | return res 210 | } 211 | }, 212 | 213 | keyBy: (path: JSONQueryProperty) => { 214 | const getter = compile(path) 215 | 216 | return (data: T[]) => { 217 | const res = {} 218 | 219 | for (const item of data) { 220 | const value = getter(item) as string 221 | if (!(value in res)) { 222 | res[value] = item 223 | } 224 | } 225 | 226 | return res 227 | } 228 | }, 229 | 230 | flatten: () => (data: unknown[]) => data.flat(), 231 | 232 | join: 233 | (separator = '') => 234 | (data: T[]) => 235 | data.join(separator), 236 | 237 | split: buildFunction((text: string, separator?: string) => 238 | separator !== undefined ? text.split(separator) : text.trim().split(/\s+/) 239 | ), 240 | 241 | substring: buildFunction((text: string, start: number, end?: number) => 242 | text.slice(Math.max(start, 0), end) 243 | ), 244 | 245 | uniq: 246 | () => 247 | (data: T[]) => { 248 | const res: T[] = [] 249 | 250 | for (const item of data) { 251 | if (res.findIndex((resItem) => isEqual(resItem, item)) === -1) { 252 | res.push(item) 253 | } 254 | } 255 | 256 | return res 257 | }, 258 | 259 | uniqBy: 260 | (path: JSONQueryProperty) => 261 | (data: T[]): T[] => 262 | Object.values(functions.keyBy(path)(data)), 263 | 264 | limit: 265 | (count: number) => 266 | (data: T[]) => 267 | data.slice(0, Math.max(count, 0)), 268 | 269 | size: 270 | () => 271 | (data: T[]) => 272 | data.length, 273 | 274 | keys: () => Object.keys, 275 | values: () => Object.values, 276 | 277 | prod: () => (data: number[]) => reduce(data, (a, b) => a * b), 278 | 279 | sum: () => (data: number[]) => 280 | isArray(data) ? data.reduce((a, b) => a + b, 0) : throwArrayExpected(), 281 | 282 | average: () => (data: number[]) => 283 | isArray(data) 284 | ? data.length > 0 285 | ? data.reduce((a, b) => a + b) / data.length 286 | : null 287 | : throwArrayExpected(), 288 | 289 | min: () => (data: number[]) => reduce(data, (a, b) => Math.min(a, b)), 290 | max: () => (data: number[]) => reduce(data, (a, b) => Math.max(a, b)), 291 | 292 | and: buildFunction((...data: unknown[]) => reduce(data, (a, b) => !!(a && b))), 293 | or: buildFunction((...data: unknown[]) => reduce(data, (a, b) => !!(a || b))), 294 | not: buildFunction((a: unknown) => !a), 295 | 296 | exists: (queryGet: JSONQueryFunction) => { 297 | const parentPath = queryGet.slice(1) 298 | const key = parentPath.pop() 299 | const getter = functions.get(...parentPath) 300 | 301 | return (data: unknown) => { 302 | const parent = getter(data) 303 | return !!parent && Object.hasOwnProperty.call(parent, key) 304 | } 305 | }, 306 | if: (condition: JSONQuery, valueIfTrue: JSONQuery, valueIfFalse: JSONQuery) => { 307 | const _condition = compile(condition) 308 | const _valueIfTrue = compile(valueIfTrue) 309 | const _valueIfFalse = compile(valueIfFalse) 310 | 311 | return (data: unknown) => (truthy(_condition(data)) ? _valueIfTrue(data) : _valueIfFalse(data)) 312 | }, 313 | in: (value: JSONQuery, values: JSONQuery) => { 314 | const getValue = compile(value) 315 | const getValues = compile(values) 316 | 317 | return (data: unknown) => { 318 | const _value = getValue(data) 319 | const _values = getValues(data) as unknown[] 320 | 321 | return _values.findIndex((item) => isEqual(item, _value)) !== -1 322 | } 323 | }, 324 | 'not in': (value: JSONQuery, values: JSONQuery) => { 325 | const _in = functions.in(value, values) 326 | 327 | return (data: unknown) => !_in(data) 328 | }, 329 | regex: (path: JSONQuery, expression: string, options?: string) => { 330 | const regex = new RegExp(expression, options) 331 | const getter = compile(path) 332 | 333 | return (data: unknown) => regex.test(getter(data) as string) 334 | }, 335 | 336 | eq: buildFunction(isEqual), 337 | gt: buildFunction(gt), 338 | gte: buildFunction(gte), 339 | lt: buildFunction(lt), 340 | lte: buildFunction(lte), 341 | ne: buildFunction((a, b) => !isEqual(a, b)), 342 | 343 | add: buildFunction((a: number, b: number) => a + b), 344 | subtract: buildFunction((a: number, b: number) => a - b), 345 | multiply: buildFunction((a: number, b: number) => a * b), 346 | divide: buildFunction((a: number, b: number) => a / b), 347 | mod: buildFunction((a: number, b: number) => a % b), 348 | pow: buildFunction((a: number, b: number) => a ** b), 349 | 350 | abs: buildFunction(Math.abs), 351 | round: buildFunction((value: number, digits = 0) => { 352 | const num = Math.round(Number(`${value}e${digits}`)) 353 | return Number(`${num}e${-digits}`) 354 | }), 355 | 356 | number: buildFunction((text: string) => { 357 | const num = Number(text) 358 | return Number.isNaN(Number(text)) ? null : num 359 | }), 360 | string: buildFunction(String) 361 | } 362 | 363 | const truthy = (x: unknown) => x !== null && x !== 0 && x !== false 364 | 365 | const reduce = (data: T[], callback: (previousValue: T, currentValue: T) => T): T => { 366 | if (!isArray(data)) { 367 | throwArrayExpected() 368 | } 369 | 370 | if (data.length === 0) { 371 | return null 372 | } 373 | 374 | return data.reduce(callback) 375 | } 376 | 377 | const throwArrayExpected = () => { 378 | throwTypeError('Array expected') 379 | } 380 | 381 | export const throwTypeError = (message: string) => { 382 | throw new TypeError(message) 383 | } 384 | -------------------------------------------------------------------------------- /src/is.ts: -------------------------------------------------------------------------------- 1 | export const isArray = (value: unknown): value is T[] => Array.isArray(value) 2 | 3 | export const isObject = (value: unknown): value is object => 4 | value !== null && typeof value === 'object' && !isArray(value) 5 | 6 | export const isString = (value: unknown): value is string => typeof value === 'string' 7 | 8 | // source: https://stackoverflow.com/a/77278013/1262753 9 | export const isEqual = (a: T, b: T): boolean => { 10 | if (a === b) { 11 | return true 12 | } 13 | 14 | const bothObject = a !== null && b !== null && typeof a === 'object' && typeof b === 'object' 15 | 16 | return ( 17 | bothObject && 18 | Object.keys(a).length === Object.keys(b).length && 19 | Object.entries(a).every(([k, v]) => isEqual(v, b[k as keyof T])) 20 | ) 21 | } 22 | -------------------------------------------------------------------------------- /src/jsonquery.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, test } from 'vitest' 2 | import { 3 | type JSONQuery, 4 | type JSONQueryOptions, 5 | buildFunction, 6 | compile, 7 | jsonquery, 8 | parse, 9 | stringify 10 | } from './jsonquery' 11 | 12 | describe('jsonquery', () => { 13 | test('should execute a JSON query', () => { 14 | const query: JSONQuery = ['get', 'name'] 15 | expect(jsonquery({ name: 'Joe' }, query)).toEqual('Joe') 16 | }) 17 | 18 | test('should execute a text query', () => { 19 | expect(jsonquery({ name: 'Joe' }, '.name')).toEqual('Joe') 20 | }) 21 | 22 | test('should execute a JSON query with custom functions', () => { 23 | const options: JSONQueryOptions = { 24 | functions: { 25 | customFn: () => (_data: unknown) => 42 26 | } 27 | } 28 | 29 | expect(jsonquery({}, ['customFn'], options)).toEqual(42) 30 | }) 31 | 32 | test('should execute a text query with custom functions', () => { 33 | const options: JSONQueryOptions = { 34 | functions: { 35 | customFn: () => (_data: unknown) => 42 36 | } 37 | } 38 | 39 | expect(jsonquery({ name: 'Joe' }, '.name', options)).toEqual('Joe') 40 | }) 41 | 42 | test('should execute a JSON query with custom operators', () => { 43 | const options: JSONQueryOptions = { 44 | functions: { 45 | aboutEq: buildFunction((a: string, b: string) => a.toLowerCase() === b.toLowerCase()) 46 | } 47 | } 48 | 49 | expect(jsonquery({ name: 'Joe' }, ['aboutEq', ['get', 'name'], 'joe'], options)).toEqual(true) 50 | }) 51 | 52 | test('should execute a text query with custom operators', () => { 53 | const options: JSONQueryOptions = { 54 | operators: [{ name: 'aboutEq', op: '~=', at: '==' }], 55 | functions: { 56 | aboutEq: buildFunction((a: string, b: string) => a.toLowerCase() === b.toLowerCase()) 57 | } 58 | } 59 | 60 | expect(jsonquery({ name: 'Joe' }, '.name ~= "joe"', options)).toEqual(true) 61 | }) 62 | 63 | test('have exported all documented functions and objects', () => { 64 | expect(jsonquery).toBeTypeOf('function') 65 | expect(parse).toBeTypeOf('function') 66 | expect(stringify).toBeTypeOf('function') 67 | expect(compile).toBeTypeOf('function') 68 | expect(buildFunction).toBeTypeOf('function') 69 | }) 70 | }) 71 | -------------------------------------------------------------------------------- /src/jsonquery.ts: -------------------------------------------------------------------------------- 1 | import { compile } from './compile' 2 | import { isString } from './is' 3 | import { parse } from './parse' 4 | import type { JSONQuery, JSONQueryOptions } from './types' 5 | 6 | export function jsonquery( 7 | data: unknown, 8 | query: string | JSONQuery, 9 | options?: JSONQueryOptions 10 | ): unknown { 11 | return compile(isString(query) ? parse(query, options) : query, options)(data) 12 | } 13 | 14 | export { compile } from './compile' 15 | export { stringify } from './stringify' 16 | export { parse } from './parse' 17 | export { buildFunction } from './functions' 18 | 19 | export type { 20 | CustomOperator, 21 | Fun, 22 | FunctionBuilder, 23 | FunctionBuildersMap, 24 | JSONPath, 25 | JSONProperty, 26 | JSONQuery, 27 | JSONQueryCompileOptions, 28 | JSONQueryFunction, 29 | JSONQueryObject, 30 | JSONQueryOptions, 31 | JSONQueryParseOptions, 32 | JSONQueryPipe, 33 | JSONQueryPrimitive, 34 | JSONQueryProperty, 35 | JSONQueryStringifyOptions 36 | } from './types' 37 | -------------------------------------------------------------------------------- /src/operators.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, test } from 'vitest' 2 | import { extendOperators } from './operators' 3 | 4 | describe('operators', () => { 5 | test('should extend operators (at)', () => { 6 | const ops = [{ add: '+', subtract: '-' }, { eq: '==' }] 7 | 8 | expect(extendOperators(ops, [{ name: 'aboutEq', op: '~=', at: '==' }])).toEqual([ 9 | { add: '+', subtract: '-' }, 10 | { eq: '==', aboutEq: '~=' } 11 | ]) 12 | }) 13 | 14 | test('should extend operators (after)', () => { 15 | const ops = [{ add: '+', subtract: '-' }, { eq: '==' }] 16 | 17 | expect(extendOperators(ops, [{ name: 'aboutEq', op: '~=', after: '+' }])).toEqual([ 18 | { add: '+', subtract: '-' }, 19 | { aboutEq: '~=' }, 20 | { eq: '==' } 21 | ]) 22 | }) 23 | 24 | test('should extend operators (before)', () => { 25 | const ops = [{ add: '+', subtract: '-' }, { eq: '==' }] 26 | 27 | expect(extendOperators(ops, [{ name: 'aboutEq', op: '~=', before: '==' }])).toEqual([ 28 | { add: '+', subtract: '-' }, 29 | { aboutEq: '~=' }, 30 | { eq: '==' } 31 | ]) 32 | }) 33 | 34 | test('should extend operators (multiple consecutive)', () => { 35 | const ops = [{ add: '+', subtract: '-' }, { eq: '==' }] 36 | 37 | expect( 38 | extendOperators(ops, [ 39 | { name: 'first', op: 'op1', before: '==' }, 40 | { name: 'second', op: 'op2', before: 'op1' } 41 | ]) 42 | ).toEqual([{ add: '+', subtract: '-' }, { second: 'op2' }, { first: 'op1' }, { eq: '==' }]) 43 | }) 44 | }) 45 | -------------------------------------------------------------------------------- /src/operators.ts: -------------------------------------------------------------------------------- 1 | import { isArray } from './is' 2 | import type { CustomOperator, OperatorGroup } from './types' 3 | 4 | // operator precedence from highest to lowest 5 | export const operators: OperatorGroup[] = [ 6 | { pow: '^' }, 7 | { multiply: '*', divide: '/', mod: '%' }, 8 | { add: '+', subtract: '-' }, 9 | { gt: '>', gte: '>=', lt: '<', lte: '<=', in: 'in', 'not in': 'not in' }, 10 | { eq: '==', ne: '!=' }, 11 | { and: 'and' }, 12 | { or: 'or' }, 13 | { pipe: '|' } 14 | ] 15 | 16 | export const varargOperators = ['|', 'and', 'or'] 17 | export const leftAssociativeOperators = ['|', 'and', 'or', '*', '/', '%', '+', '-'] 18 | 19 | export function extendOperators(operators: OperatorGroup[], customOperators: CustomOperator[]) { 20 | // backward compatibility error with v4 where `operators` was an object 21 | if (!isArray(customOperators)) { 22 | throw new Error('Invalid custom operators') 23 | } 24 | 25 | return customOperators.reduce(extendOperator, operators) 26 | } 27 | 28 | function extendOperator( 29 | operators: OperatorGroup[], 30 | // @ts-expect-error Inside the function we will check whether at, below, and above are defined 31 | { name, op, at, after, before }: CustomOperator 32 | ): OperatorGroup[] { 33 | if (at) { 34 | return operators.map((group) => { 35 | return Object.values(group).includes(at) ? { ...group, [name]: op } : group 36 | }) 37 | } 38 | 39 | const searchOp = after ?? before 40 | const index = operators.findIndex((group) => Object.values(group).includes(searchOp)) 41 | if (index !== -1) { 42 | return operators.toSpliced(index + (after ? 1 : 0), 0, { [name]: op }) 43 | } 44 | 45 | throw new Error('Invalid custom operator') 46 | } 47 | -------------------------------------------------------------------------------- /src/parse.test.ts: -------------------------------------------------------------------------------- 1 | import Ajv from 'ajv' 2 | import { describe, expect, test } from 'vitest' 3 | import type { ParseTestException, ParseTestSuite } from '../test-suite/parse.test' 4 | import suite from '../test-suite/parse.test.json' 5 | import schema from '../test-suite/parse.test.schema.json' 6 | import { compile } from './compile' 7 | import { parse } from './parse' 8 | import type { JSONQueryParseOptions } from './types' 9 | 10 | function isTestException(test: unknown): test is ParseTestException { 11 | return !!test && typeof (test as Record).throws === 'string' 12 | } 13 | 14 | const groupByCategory = compile(['groupBy', ['get', 'category']]) 15 | const testsByCategory = groupByCategory(suite.groups) as Record 16 | 17 | for (const [category, testGroups] of Object.entries(testsByCategory)) { 18 | describe(category, () => { 19 | for (const group of testGroups) { 20 | describe(group.description, () => { 21 | for (const currentTest of group.tests) { 22 | const description = `input = '${currentTest.input}'` 23 | 24 | if (isTestException(currentTest)) { 25 | test(description, () => { 26 | const { input, throws } = currentTest 27 | 28 | expect(() => parse(input)).toThrow(throws) 29 | }) 30 | } else { 31 | test(description, () => { 32 | const { input, output } = currentTest 33 | 34 | expect(parse(input)).toEqual(output) 35 | }) 36 | } 37 | } 38 | }) 39 | } 40 | }) 41 | } 42 | 43 | describe('customization', () => { 44 | test('should parse a custom function', () => { 45 | const options: JSONQueryParseOptions = { 46 | functions: { customFn: () => () => 42 } 47 | } 48 | 49 | expect(parse('customFn(.age, "desc")', options)).toEqual(['customFn', ['get', 'age'], 'desc']) 50 | 51 | // built-in functions should still be available 52 | expect(parse('add(2, 3)', options)).toEqual(['add', 2, 3]) 53 | }) 54 | 55 | test('should parse a custom operator without vararg', () => { 56 | const options: JSONQueryParseOptions = { 57 | operators: [{ name: 'aboutEq', op: '~=', at: '==' }] 58 | } 59 | 60 | expect(parse('.score ~= 8', options)).toEqual(['aboutEq', ['get', 'score'], 8]) 61 | 62 | // built-in operators should still be available 63 | expect(parse('.score == 8', options)).toEqual(['eq', ['get', 'score'], 8]) 64 | 65 | expect(() => parse('2 ~= 3 ~= 4', options)).toThrow("Unexpected part '~= 4'") 66 | }) 67 | 68 | test('should parse a custom operator with vararg without leftAssociative', () => { 69 | const options: JSONQueryParseOptions = { 70 | operators: [{ name: 'aboutEq', op: '~=', at: '==', vararg: true }] 71 | } 72 | 73 | expect(parse('2 and 3 and 4', options)).toEqual(['and', 2, 3, 4]) 74 | expect(parse('2 ~= 3', options)).toEqual(['aboutEq', 2, 3]) 75 | expect(parse('2 ~= 3 and 4', options)).toEqual(['and', ['aboutEq', 2, 3], 4]) 76 | expect(parse('2 and 3 ~= 4', options)).toEqual(['and', 2, ['aboutEq', 3, 4]]) 77 | expect(parse('2 == 3 ~= 4', options)).toEqual(['aboutEq', ['eq', 2, 3], 4]) 78 | expect(parse('2 ~= 3 == 4', options)).toEqual(['eq', ['aboutEq', 2, 3], 4]) 79 | expect(() => parse('2 ~= 3 ~= 4', options)).toThrow("Unexpected part '~= 4'") 80 | expect(() => parse('2 == 3 == 4', options)).toThrow("Unexpected part '== 4'") 81 | }) 82 | 83 | test('should parse a custom operator with vararg with leftAssociative', () => { 84 | const options: JSONQueryParseOptions = { 85 | operators: [{ name: 'aboutEq', op: '~=', at: '==', vararg: true, leftAssociative: true }] 86 | } 87 | 88 | expect(parse('2 and 3 and 4', options)).toEqual(['and', 2, 3, 4]) 89 | expect(parse('2 ~= 3', options)).toEqual(['aboutEq', 2, 3]) 90 | expect(parse('2 ~= 3 ~= 4', options)).toEqual(['aboutEq', 2, 3, 4]) 91 | expect(() => parse('2 == 3 == 4', options)).toThrow("Unexpected part '== 4'") 92 | }) 93 | 94 | test('should throw an error in case of an invalid custom operator', () => { 95 | const options: JSONQueryParseOptions = { 96 | // @ts-ignore 97 | operators: [{}] 98 | } 99 | 100 | expect(() => parse('.score > 8', options)).toThrow('Invalid custom operator') 101 | }) 102 | 103 | test('should throw an error in case of an invalid custom operator (2)', () => { 104 | const options: JSONQueryParseOptions = { 105 | // @ts-ignore 106 | operators: {} 107 | } 108 | 109 | expect(() => parse('.score > 8', options)).toThrow('Invalid custom operators') 110 | }) 111 | }) 112 | 113 | describe('test-suite', () => { 114 | test('should validate the parse test-suite against its JSON schema', () => { 115 | const ajv = new Ajv({ allErrors: false }) 116 | const valid = ajv.validate(schema, suite) 117 | 118 | expect(ajv.errors).toEqual(null) 119 | expect(valid).toEqual(true) 120 | }) 121 | }) 122 | -------------------------------------------------------------------------------- /src/parse.ts: -------------------------------------------------------------------------------- 1 | import { extendOperators, leftAssociativeOperators, operators, varargOperators } from './operators' 2 | import { 3 | startsWithIntRegex, 4 | startsWithKeywordRegex, 5 | startsWithNumberRegex, 6 | startsWithStringRegex, 7 | startsWithUnquotedPropertyRegex, 8 | startsWithWhitespaceRegex 9 | } from './regexps' 10 | import type { JSONQuery, JSONQueryParseOptions, OperatorGroup } from './types' 11 | 12 | /** 13 | * Parse a string containing a JSON Query into JSON. 14 | * 15 | * Example: 16 | * 17 | * const textQuery = '.friends | filter(.city == "new York") | sort(.age) | pick(.name, .age)' 18 | * const jsonQuery = parse(textQuery) 19 | * // jsonQuery = [ 20 | * // 'pipe', 21 | * // ['get', 'friends'], 22 | * // ['filter', ['eq', ['get', 'city'], 'New York']], 23 | * // ['sort', ['get', 'age']], 24 | * // ['pick', ['get', 'name'], ['get', 'age']] 25 | * // ] 26 | */ 27 | export function parse(query: string, options?: JSONQueryParseOptions): JSONQuery { 28 | const customOperators = options?.operators ?? [] 29 | const allOperators = extendOperators(operators, customOperators) 30 | const allOperatorsMap = Object.assign({}, ...allOperators) 31 | const allVarargOperators = varargOperators.concat( 32 | customOperators.filter((op) => op.vararg).map((op) => op.op) 33 | ) 34 | const allLeftAssociativeOperators = leftAssociativeOperators.concat( 35 | customOperators.filter((op) => op.leftAssociative).map((op) => op.op) 36 | ) 37 | 38 | const parseOperator = (precedenceLevel = allOperators.length - 1) => { 39 | const currentOperators = allOperators[precedenceLevel] 40 | if (!currentOperators) { 41 | return parseParenthesis() 42 | } 43 | 44 | const leftParenthesis = query[i] === '(' 45 | let left = parseOperator(precedenceLevel - 1) 46 | 47 | while (true) { 48 | skipWhitespace() 49 | 50 | const start = i 51 | const name = parseOperatorName(currentOperators) 52 | if (!name) { 53 | break 54 | } 55 | 56 | const right = parseOperator(precedenceLevel - 1) 57 | 58 | const childName = left[0] 59 | const chained = name === childName && !leftParenthesis 60 | if (chained && !allLeftAssociativeOperators.includes(allOperatorsMap[name])) { 61 | i = start 62 | break 63 | } 64 | 65 | left = 66 | chained && allVarargOperators.includes(allOperatorsMap[name]) 67 | ? [...left, right] 68 | : [name, left, right] 69 | } 70 | 71 | return left 72 | } 73 | 74 | const parseOperatorName = (currentOperators: OperatorGroup): string | undefined => { 75 | // we sort the operators from longest to shortest, so we first handle "<=" and next "<" 76 | const sortedOperatorNames = Object.keys(currentOperators).sort((a, b) => b.length - a.length) 77 | 78 | for (const name of sortedOperatorNames) { 79 | const op = currentOperators[name] 80 | if (query.substring(i, i + op.length) === op) { 81 | i += op.length 82 | 83 | skipWhitespace() 84 | 85 | return name 86 | } 87 | } 88 | 89 | return undefined 90 | } 91 | 92 | const parseParenthesis = () => { 93 | skipWhitespace() 94 | 95 | if (query[i] === '(') { 96 | i++ 97 | const inner = parseOperator() 98 | eatChar(')') 99 | return inner 100 | } 101 | 102 | return parseProperty() 103 | } 104 | 105 | const parseProperty = () => { 106 | if (query[i] === '.') { 107 | const props = [] 108 | 109 | while (query[i] === '.') { 110 | i++ 111 | 112 | props.push( 113 | parseString() ?? 114 | parseUnquotedString() ?? 115 | parseInteger() ?? 116 | throwSyntaxError('Property expected') 117 | ) 118 | } 119 | 120 | return ['get', ...props] 121 | } 122 | 123 | return parseFunction() 124 | } 125 | 126 | const parseFunction = () => { 127 | const start = i 128 | const name = parseUnquotedString() 129 | skipWhitespace() 130 | if (!name || query[i] !== '(') { 131 | i = start 132 | return parseObject() 133 | } 134 | i++ 135 | 136 | skipWhitespace() 137 | 138 | const args = query[i] !== ')' ? [parseOperator()] : [] 139 | while (i < query.length && query[i] !== ')') { 140 | skipWhitespace() 141 | eatChar(',') 142 | args.push(parseOperator()) 143 | } 144 | 145 | eatChar(')') 146 | 147 | return [name, ...args] 148 | } 149 | 150 | const parseObject = () => { 151 | if (query[i] === '{') { 152 | i++ 153 | skipWhitespace() 154 | 155 | const object = {} 156 | let first = true 157 | while (i < query.length && query[i] !== '}') { 158 | if (first) { 159 | first = false 160 | } else { 161 | eatChar(',') 162 | skipWhitespace() 163 | } 164 | 165 | const key = 166 | parseString() ?? 167 | parseUnquotedString() ?? 168 | parseInteger() ?? 169 | throwSyntaxError('Key expected') 170 | 171 | skipWhitespace() 172 | eatChar(':') 173 | 174 | object[key] = parseOperator() 175 | } 176 | 177 | eatChar('}') 178 | 179 | return ['object', object] 180 | } 181 | 182 | return parseArray() 183 | } 184 | 185 | const parseArray = () => { 186 | if (query[i] === '[') { 187 | i++ 188 | skipWhitespace() 189 | 190 | const array = [] 191 | 192 | let first = true 193 | while (i < query.length && query[i] !== ']') { 194 | if (first) { 195 | first = false 196 | } else { 197 | eatChar(',') 198 | skipWhitespace() 199 | } 200 | 201 | array.push(parseOperator()) 202 | } 203 | 204 | eatChar(']') 205 | 206 | return ['array', ...array] 207 | } 208 | 209 | return parseString() ?? parseNumber() ?? parseKeyword() 210 | } 211 | 212 | const parseString = () => parseRegex(startsWithStringRegex, JSON.parse) 213 | 214 | const parseUnquotedString = () => parseRegex(startsWithUnquotedPropertyRegex, (text) => text) 215 | 216 | const parseNumber = () => parseRegex(startsWithNumberRegex, JSON.parse) 217 | 218 | const parseInteger = () => parseRegex(startsWithIntRegex, JSON.parse) 219 | 220 | const parseKeyword = () => { 221 | const keyword = parseRegex(startsWithKeywordRegex, JSON.parse) 222 | if (keyword !== undefined) { 223 | return keyword 224 | } 225 | 226 | // end of the parsing chain 227 | throwSyntaxError('Value expected') 228 | } 229 | 230 | const parseEnd = () => { 231 | skipWhitespace() 232 | 233 | if (i < query.length) { 234 | throwSyntaxError(`Unexpected part '${query.substring(i)}'`) 235 | } 236 | } 237 | 238 | const parseRegex = (regex: RegExp, callback: (match: string) => T): T | undefined => { 239 | const match = query.substring(i).match(regex) 240 | if (match) { 241 | i += match[0].length 242 | return callback(match[0]) 243 | } 244 | } 245 | 246 | const skipWhitespace = () => parseRegex(startsWithWhitespaceRegex, (text) => text) 247 | 248 | const eatChar = (char: string) => { 249 | if (query[i] !== char) { 250 | throwSyntaxError(`Character '${char}' expected`) 251 | } 252 | i++ 253 | } 254 | 255 | const throwSyntaxError = (message: string, pos = i) => { 256 | throw new SyntaxError(`${message} (pos: ${pos})`) 257 | } 258 | 259 | let i = 0 260 | const output = parseOperator() 261 | parseEnd() 262 | 263 | return output 264 | } 265 | -------------------------------------------------------------------------------- /src/regexps.ts: -------------------------------------------------------------------------------- 1 | export const unquotedPropertyRegex = /^[a-zA-Z_$][a-zA-Z\d_$]*$/ 2 | export const startsWithUnquotedPropertyRegex = /^[a-zA-Z_$][a-zA-Z\d_$]*/ 3 | export const startsWithStringRegex = /^"(?:[^"\\]|\\.)*"/ // https://stackoverflow.com/a/249937/1262753 4 | export const startsWithNumberRegex = /^-?(?:0|[1-9]\d*)(?:\.\d+)?(?:[eE][+-]?\d+)?/ // https://stackoverflow.com/a/13340826/1262753 5 | export const startsWithIntRegex = /^(0|[1-9][0-9]*)/ 6 | export const startsWithKeywordRegex = /^(true|false|null)/ 7 | export const startsWithWhitespaceRegex = /^[ \n\t\r]+/ 8 | -------------------------------------------------------------------------------- /src/stringify.test.ts: -------------------------------------------------------------------------------- 1 | import Ajv from 'ajv' 2 | import { describe, expect, test } from 'vitest' 3 | import type { StringifyTestSuite } from '../test-suite/stringify.test' 4 | import suite from '../test-suite/stringify.test.json' 5 | import schema from '../test-suite/stringify.test.schema.json' 6 | import { compile } from './compile' 7 | import { stringify } from './stringify' 8 | import type { JSONQueryStringifyOptions } from './types' 9 | 10 | const groupByCategory = compile(['groupBy', ['get', 'category']]) 11 | const testsByCategory = groupByCategory(suite.groups) as Record< 12 | string, 13 | StringifyTestSuite['groups'] 14 | > 15 | 16 | for (const [category, testGroups] of Object.entries(testsByCategory)) { 17 | describe(category, () => { 18 | for (const group of testGroups) { 19 | describe(group.description, () => { 20 | for (const currentTest of group.tests) { 21 | const description = `input = ${JSON.stringify(currentTest.input)}` 22 | 23 | test(description, () => { 24 | const { input, output } = currentTest 25 | 26 | expect(stringify(input, group.options)).toEqual(output) 27 | }) 28 | } 29 | }) 30 | } 31 | }) 32 | } 33 | 34 | describe('customization', () => { 35 | test('should stringify a custom operator', () => { 36 | const options: JSONQueryStringifyOptions = { 37 | operators: [{ name: 'aboutEq', op: '~=', at: '==' }] 38 | } 39 | 40 | expect(stringify(['aboutEq', 2, 3], options)).toEqual('2 ~= 3') 41 | expect(stringify(['filter', ['aboutEq', 2, 3]], options)).toEqual('filter(2 ~= 3)') 42 | expect(stringify(['object', { result: ['aboutEq', 2, 3] }], options)).toEqual( 43 | '{ result: 2 ~= 3 }' 44 | ) 45 | // existing operators should still be there 46 | expect(stringify(['eq', 2, 3], options)).toEqual('2 == 3') 47 | 48 | // test precedence and parenthesis 49 | expect(stringify(['aboutEq', ['aboutEq', 2, 3], 4], options)).toEqual('(2 ~= 3) ~= 4') 50 | expect(stringify(['aboutEq', 2, ['aboutEq', 3, 4]], options)).toEqual('2 ~= (3 ~= 4)') 51 | expect(stringify(['aboutEq', ['and', 2, 3], 4], options)).toEqual('(2 and 3) ~= 4') 52 | expect(stringify(['aboutEq', 2, ['and', 3, 4]], options)).toEqual('2 ~= (3 and 4)') 53 | expect(stringify(['and', ['aboutEq', 2, 3], 4], options)).toEqual('2 ~= 3 and 4') 54 | expect(stringify(['and', 2, ['aboutEq', 3, 4]], options)).toEqual('2 and 3 ~= 4') 55 | expect(stringify(['aboutEq', ['add', 2, 3], 4], options)).toEqual('2 + 3 ~= 4') 56 | expect(stringify(['aboutEq', 2, ['add', 3, 4]], options)).toEqual('2 ~= 3 + 4') 57 | expect(stringify(['add', ['aboutEq', 2, 3], 4], options)).toEqual('(2 ~= 3) + 4') 58 | expect(stringify(['add', 2, ['aboutEq', 3, 4]], options)).toEqual('2 + (3 ~= 4)') 59 | }) 60 | 61 | test('should stringify a custom operator which is leftAssociative', () => { 62 | const options: JSONQueryStringifyOptions = { 63 | operators: [{ name: 'aboutEq', op: '~=', at: '==', leftAssociative: true }] 64 | } 65 | 66 | expect(stringify(['aboutEq', ['aboutEq', 2, 3], 4], options)).toEqual('2 ~= 3 ~= 4') 67 | expect(stringify(['aboutEq', 2, ['aboutEq', 3, 4]], options)).toEqual('2 ~= (3 ~= 4)') 68 | }) 69 | 70 | // Note: we do not test the option `CustomOperator.vararg` 71 | // since they have no effect on stringification, only on parsing. 72 | }) 73 | 74 | describe('test-suite', () => { 75 | test('should validate the stringify test-suite against its JSON schema', () => { 76 | const ajv = new Ajv({ allErrors: false }) 77 | const valid = ajv.validate(schema, suite) 78 | 79 | expect(ajv.errors).toEqual(null) 80 | expect(valid).toEqual(true) 81 | }) 82 | }) 83 | -------------------------------------------------------------------------------- /src/stringify.ts: -------------------------------------------------------------------------------- 1 | import { isArray } from './is' 2 | import { extendOperators, leftAssociativeOperators, operators } from './operators' 3 | import { unquotedPropertyRegex } from './regexps' 4 | import type { 5 | JSONPath, 6 | JSONQuery, 7 | JSONQueryFunction, 8 | JSONQueryObject, 9 | JSONQueryStringifyOptions 10 | } from './types' 11 | 12 | const DEFAULT_MAX_LINE_LENGTH = 40 13 | const DEFAULT_INDENTATION = ' ' 14 | 15 | /** 16 | * Stringify a JSON Query into a readable, human friendly text syntax. 17 | * 18 | * Example: 19 | * 20 | * const jsonQuery = [ 21 | * ['get', 'friends'], 22 | * ['filter', ['eq', ['get', 'city'], 'New York']], 23 | * ['sort', ['get', 'age']], 24 | * ['pick', ['get', 'name'], ['get', 'age']] 25 | * ] 26 | * const textQuery = stringify(jsonQuery) 27 | * // textQuery = '.friends | filter(.city == "new York") | sort(.age) | pick(.name, .age)' 28 | * 29 | * @param query The JSON Query to be stringified 30 | * @param {Object} [options] An object which can have the following options: 31 | * `maxLineLength` Optional maximum line length. When the query exceeds this maximum, 32 | * It will be formatted over multiple lines. Default value: 40. 33 | * `indentation` Optional indentation. Defaults to a string with two spaces: ' '. 34 | */ 35 | export const stringify = (query: JSONQuery, options?: JSONQueryStringifyOptions) => { 36 | const space = options?.indentation ?? DEFAULT_INDENTATION 37 | const customOperators = options?.operators ?? [] 38 | const allOperators = extendOperators(operators, customOperators) 39 | const allOperatorsMap = Object.assign({}, ...allOperators) 40 | const allLeftAssociativeOperators = leftAssociativeOperators.concat( 41 | customOperators.filter((op) => op.leftAssociative).map((op) => op.op) 42 | ) 43 | 44 | const _stringify = (query: JSONQuery, indent: string, parenthesis = false) => 45 | isArray(query) 46 | ? stringifyFunction(query as JSONQueryFunction, indent, parenthesis) 47 | : JSON.stringify(query) // value (string, number, boolean, null) 48 | 49 | const stringifyFunction = (query: JSONQueryFunction, indent: string, parenthesis: boolean) => { 50 | const [name, ...args] = query 51 | 52 | if (name === 'get' && args.length > 0) { 53 | return stringifyPath(args as JSONPath) 54 | } 55 | 56 | if (name === 'object') { 57 | return stringifyObject(args[0] as JSONQueryObject, indent) 58 | } 59 | 60 | if (name === 'array') { 61 | const argsStr = args.map((arg) => _stringify(arg, indent)) 62 | return join( 63 | argsStr, 64 | ['[', ', ', ']'], 65 | [`[\n${indent + space}`, `,\n${indent + space}`, `\n${indent}]`] 66 | ) 67 | } 68 | 69 | // operator like ".age >= 18" 70 | const op = allOperatorsMap[name] 71 | if (op) { 72 | const start = parenthesis ? '(' : '' 73 | const end = parenthesis ? ')' : '' 74 | 75 | const argsStr = args.map((arg, index) => { 76 | const childName = arg?.[0] 77 | const precedence = allOperators.findIndex((group) => name in group) 78 | const childPrecedence = allOperators.findIndex((group) => childName in group) 79 | const childParenthesis = 80 | precedence < childPrecedence || 81 | (precedence === childPrecedence && index > 0) || 82 | (name === childName && !allLeftAssociativeOperators.includes(op)) 83 | 84 | return _stringify(arg, indent + space, childParenthesis) 85 | }) 86 | 87 | return join(argsStr, [start, ` ${op} `, end], [start, `\n${indent + space}${op} `, end]) 88 | } 89 | 90 | // regular function like "sort(.age)" 91 | const childIndent = args.length === 1 ? indent : indent + space 92 | const argsStr = args.map((arg) => _stringify(arg, childIndent)) 93 | return join( 94 | argsStr, 95 | [`${name}(`, ', ', ')'], 96 | args.length === 1 97 | ? [`${name}(`, `,\n${indent}`, ')'] 98 | : [`${name}(\n${childIndent}`, `,\n${childIndent}`, `\n${indent})`] 99 | ) 100 | } 101 | 102 | const stringifyObject = (query: JSONQueryObject, indent: string) => { 103 | const childIndent = indent + space 104 | const entries = Object.entries(query).map(([key, value]) => { 105 | return `${stringifyProperty(key)}: ${_stringify(value, childIndent)}` 106 | }) 107 | 108 | return join( 109 | entries, 110 | ['{ ', ', ', ' }'], 111 | [`{\n${childIndent}`, `,\n${childIndent}`, `\n${indent}}`] 112 | ) 113 | } 114 | 115 | const stringifyPath = (path: JSONPath): string => 116 | path.map((prop) => `.${stringifyProperty(prop)}`).join('') 117 | 118 | const stringifyProperty = (prop: string): string => 119 | unquotedPropertyRegex.test(prop) ? prop : JSON.stringify(prop) 120 | 121 | type JoinDefinition = [start: string, separator: string, end: string] 122 | 123 | const join = ( 124 | items: string[], 125 | [compactStart, compactSeparator, compactEnd]: JoinDefinition, 126 | [formatStart, formatSeparator, formatEnd]: JoinDefinition 127 | ): string => { 128 | const compactLength = 129 | compactStart.length + 130 | items.reduce((sum: number, item: string) => sum + item.length + compactSeparator.length, 0) - 131 | compactSeparator.length + 132 | compactEnd.length 133 | 134 | return compactLength <= (options?.maxLineLength ?? DEFAULT_MAX_LINE_LENGTH) 135 | ? compactStart + items.join(compactSeparator) + compactEnd 136 | : formatStart + items.join(formatSeparator) + formatEnd 137 | } 138 | 139 | return _stringify(query, '') 140 | } 141 | -------------------------------------------------------------------------------- /src/types.ts: -------------------------------------------------------------------------------- 1 | export type JSONQueryPipe = JSONQuery[] 2 | export type JSONQueryFunction = [name: string, ...args: JSONQuery[]] 3 | export type JSONQueryObject = { [key: string]: JSONQuery } 4 | export type JSONQueryPrimitive = string | number | boolean | null 5 | export type JSONQuery = JSONQueryFunction | JSONQueryPipe | JSONQueryObject | JSONQueryPrimitive 6 | 7 | export type JSONProperty = string 8 | export type JSONPath = JSONProperty[] 9 | export type JSONQueryProperty = ['get', path?: string | JSONPath] 10 | 11 | export interface JSONQueryOptions { 12 | functions?: FunctionBuildersMap 13 | operators?: CustomOperator[] 14 | } 15 | 16 | export interface JSONQueryCompileOptions { 17 | functions?: FunctionBuildersMap 18 | } 19 | 20 | export interface JSONQueryStringifyOptions { 21 | operators?: CustomOperator[] 22 | maxLineLength?: number 23 | indentation?: string 24 | } 25 | 26 | export interface JSONQueryParseOptions { 27 | operators?: CustomOperator[] 28 | } 29 | 30 | export type Fun = (data: unknown) => unknown 31 | export type FunctionBuilder = (...args: JSONQuery[]) => Fun 32 | export type FunctionBuildersMap = Record 33 | export type Getter = [key: string, Fun] 34 | export type OperatorGroup = Record 35 | export type CustomOperator = 36 | | { name: string; op: string; at: string; vararg?: boolean; leftAssociative?: boolean } 37 | | { name: string; op: string; after: string; vararg?: boolean; leftAssociative?: boolean } 38 | | { name: string; op: string; before: string; vararg?: boolean; leftAssociative?: boolean } 39 | 40 | export interface Entry { 41 | key: string 42 | value: T 43 | } 44 | -------------------------------------------------------------------------------- /test-lib/apps/esmApp.mjs: -------------------------------------------------------------------------------- 1 | import { jsonquery } from '../../lib/jsonquery.js' 2 | 3 | const data = [ 4 | { name: 'Chris', age: 23, city: 'New York' }, 5 | { name: 'Emily', age: 19, city: 'Atlanta' }, 6 | { name: 'Joe', age: 32, city: 'New York' }, 7 | { name: 'Kevin', age: 19, city: 'Atlanta' }, 8 | { name: 'Michelle', age: 27, city: 'Los Angeles' }, 9 | { name: 'Robert', age: 45, city: 'Manhattan' }, 10 | { name: 'Sarah', age: 31, city: 'New York' } 11 | ] 12 | 13 | const result = jsonquery(data, [ 14 | 'pipe', 15 | ['filter', ['eq', ['get', 'city'], 'New York']], 16 | ['map', ['get', 'name']] 17 | ]) 18 | 19 | console.log(JSON.stringify(result)) 20 | -------------------------------------------------------------------------------- /test-lib/cli.test.js: -------------------------------------------------------------------------------- 1 | import cp from 'node:child_process' 2 | import { existsSync, readFileSync, rmSync, writeFileSync } from 'node:fs' 3 | import { dirname, join } from 'node:path' 4 | import { fileURLToPath } from 'node:url' 5 | import { afterEach, beforeEach, describe, expect, test } from 'vitest' 6 | import { help } from '../bin/help.js' 7 | 8 | const __filename = fileURLToPath(import.meta.url) 9 | const __dirname = dirname(__filename) 10 | 11 | describe('command line interface', () => { 12 | const cli = join(__dirname, '..', 'bin', 'cli.js') 13 | const inputFile = join(__dirname, 'data', 'input.json') 14 | const inputQueryText = join(__dirname, 'data', 'query.txt') 15 | const inputQueryJson = join(__dirname, 'data', 'query.json') 16 | const outputFile = join(__dirname, 'output', 'output.json') 17 | 18 | beforeEach(() => { 19 | if (existsSync(outputFile)) { 20 | rmSync(outputFile) 21 | } 22 | }) 23 | 24 | afterEach(() => { 25 | if (existsSync(outputFile)) { 26 | rmSync(outputFile) 27 | } 28 | }) 29 | 30 | test('should output version', async () => { 31 | // a version number like 0.0.0 or 3.1.0 32 | const versionRegex = /^\d+\.\d+\.\d+$/ 33 | 34 | expect(await run(`node "${cli}" --version`)).toMatch(versionRegex) 35 | expect(await run(`node "${cli}" -v`)).toMatch(versionRegex) 36 | }) 37 | 38 | test('should output help', async () => { 39 | expect(await run(`node "${cli}" --help`)).toBe(help) 40 | expect(await run(`node "${cli}" -h`)).toBe(help) 41 | }) 42 | 43 | describe('input', () => { 44 | test('should process input from stdin', async () => { 45 | expect(await run(`echo [3,1,2] | node "${cli}" "sort()"`)).toBe('[\n 1,\n 2,\n 3\n]') 46 | }) 47 | 48 | test('should process input from a file', async () => { 49 | expect(await run(`node "${cli}" --input "${inputFile}" "sort(.age)"`)).toBe(expectedOutput) 50 | }) 51 | }) 52 | 53 | describe('query', () => { 54 | test('should process an inline query', async () => { 55 | expect(await run(`node "${cli}" --input "${inputFile}" "sort(.age)"`)).toBe(expectedOutput) 56 | }) 57 | 58 | test('should process a query file', async () => { 59 | expect(await run(`node "${cli}" --input "${inputFile}" --query "${inputQueryText}"`)).toBe( 60 | expectedOutput 61 | ) 62 | }) 63 | 64 | test('should process a query file with --format text', async () => { 65 | expect( 66 | await run(`node "${cli}" --input "${inputFile}" --query "${inputQueryText}" --format text`) 67 | ).toBe(expectedOutput) 68 | }) 69 | 70 | test('should process a query file with --format json', async () => { 71 | expect( 72 | await run(`node "${cli}" --input "${inputFile}" --query "${inputQueryJson}" --format json`) 73 | ).toBe(expectedOutput) 74 | }) 75 | 76 | test('should throw an error in case of an unknown format', async () => { 77 | try { 78 | await run(`node "${cli}" --input "${inputFile}" --query "${inputQueryJson}" --format FOO`) 79 | expect.fail('Should not succeed') 80 | } catch (err) { 81 | expect(err.message).toContain( 82 | 'Error: Unknown format "FOO". Choose either "text" (default) or "json".' 83 | ) 84 | } 85 | }) 86 | 87 | test('should throw an error when query is undefined', async () => { 88 | try { 89 | await run(`node "${cli}" --input "${inputFile}"`) 90 | expect.fail('Should not succeed') 91 | } catch (err) { 92 | expect(err.message).toContain('Error: No query provided') 93 | } 94 | }) 95 | }) 96 | 97 | describe('output', () => { 98 | test('should output to stdout', async () => { 99 | expect(await run(`node "${cli}" --input "${inputFile}" "sort(.age)"`)).toBe(expectedOutput) 100 | }) 101 | 102 | test('should should output to a file via stdout', async () => { 103 | const result = await run( 104 | `node "${cli}" --input "${inputFile}" "sort(.age)" > "${outputFile}"` 105 | ) 106 | 107 | expect(result).toBe('') 108 | expect(String(readFileSync(outputFile))).toBe(expectedOutput) 109 | }) 110 | 111 | test('should output to a file', async () => { 112 | const result = await run( 113 | `node "${cli}" --input "${inputFile}" "sort(.age)" --output "${outputFile}"` 114 | ) 115 | 116 | expect(result).toBe('') 117 | expect(String(readFileSync(outputFile))).toBe(expectedOutput) 118 | }) 119 | 120 | test('should not overwrite an existing file', async () => { 121 | const originalContent = '"original"' 122 | writeFileSync(outputFile, originalContent) 123 | 124 | try { 125 | await run(`node "${cli}" --input "${inputFile}" "sort(.age)" --output "${outputFile}"`) 126 | expect.fail('Should not succeed') 127 | } catch (err) { 128 | expect(err.message).toContain(`Cannot overwrite existing file "${outputFile}"`) 129 | } 130 | 131 | expect(String(readFileSync(outputFile))).toBe(originalContent) 132 | }) 133 | 134 | test('should overwrite an existing file when --overwrite is provided', async () => { 135 | const originalContent = '"original"' 136 | writeFileSync(outputFile, originalContent) 137 | 138 | await run( 139 | `node "${cli}" --input "${inputFile}" "sort(.age)" --output "${outputFile}" --overwrite` 140 | ) 141 | 142 | expect(String(readFileSync(outputFile))).toBe(expectedOutput) 143 | }) 144 | }) 145 | 146 | describe('indentation', () => { 147 | test('should output default indentation', async () => { 148 | expect(await run(`echo [3,1,2] | node "${cli}" "sort()"`)).toBe('[\n 1,\n 2,\n 3\n]') 149 | }) 150 | 151 | test('should output custom indentation (4 spaces)', async () => { 152 | expect(await run(`echo [3,1,2] | node "${cli}" "sort()" --indentation " "`)).toBe( 153 | '[\n 1,\n 2,\n 3\n]' 154 | ) 155 | }) 156 | 157 | test('should output custom indentation (tabs)', async () => { 158 | expect(await run(`echo [3,1,2] | node "${cli}" "sort()" --indentation "\t"`)).toBe( 159 | '[\n\t1,\n\t2,\n\t3\n]' 160 | ) 161 | }) 162 | 163 | test('should output custom indentation (compact)', async () => { 164 | expect(await run(`echo [3,1,2] | node "${cli}" "sort()" --indentation ""`)).toBe('[1,2,3]') 165 | }) 166 | }) 167 | }) 168 | 169 | function run(command) { 170 | return new Promise((resolve, reject) => { 171 | cp.exec(command, (error, result) => { 172 | if (error) { 173 | reject(error) 174 | } else { 175 | resolve(result) 176 | } 177 | }) 178 | }) 179 | } 180 | 181 | const expectedOutput = `[ 182 | { 183 | "name": "Emily", 184 | "age": 19 185 | }, 186 | { 187 | "name": "Chris", 188 | "age": 23 189 | }, 190 | { 191 | "name": "Joe", 192 | "age": 32 193 | } 194 | ]` 195 | -------------------------------------------------------------------------------- /test-lib/data/input.json: -------------------------------------------------------------------------------- 1 | [{ "name": "Chris", "age": 23 }, { "name": "Emily", "age": 19 }, { "name": "Joe", "age": 32 }] 2 | -------------------------------------------------------------------------------- /test-lib/data/query.json: -------------------------------------------------------------------------------- 1 | ["sort", ["get", "age"]] 2 | -------------------------------------------------------------------------------- /test-lib/data/query.txt: -------------------------------------------------------------------------------- 1 | sort(.age) 2 | -------------------------------------------------------------------------------- /test-lib/lib.test.js: -------------------------------------------------------------------------------- 1 | import cp from 'node:child_process' 2 | import { dirname, join } from 'node:path' 3 | import { fileURLToPath } from 'node:url' 4 | import { describe, expect, test } from 'vitest' 5 | 6 | const __filename = fileURLToPath(import.meta.url) 7 | const __dirname = dirname(__filename) 8 | 9 | describe('lib', () => { 10 | test('should load the library using ESM', async () => { 11 | const filename = join(__dirname, 'apps/esmApp.mjs') 12 | const result = await run(`node ${filename}`) 13 | expect(result).toBe('["Chris","Joe","Sarah"]\n') 14 | }) 15 | }) 16 | 17 | function run(command) { 18 | return new Promise((resolve, reject) => { 19 | cp.exec(command, (error, result) => { 20 | if (error) { 21 | reject(error) 22 | } else { 23 | resolve(result) 24 | } 25 | }) 26 | }) 27 | } 28 | -------------------------------------------------------------------------------- /test-lib/output/.gitignore: -------------------------------------------------------------------------------- 1 | * 2 | -------------------------------------------------------------------------------- /test-lib/test-parse-stringify.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | jsonquery browser test - parse and stringify 6 | 38 | 39 | 40 |
41 |

jsonquery browser test - parse and stringify

42 |
43 |
44 | 45 | 46 | 47 | 48 |

 49 |         
50 | 51 |
52 | 53 | 54 | 55 | 56 |

 57 |         
58 |
59 |
60 | 61 | 107 | 108 | 109 | -------------------------------------------------------------------------------- /test-lib/test.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | jsonquery browser test - query 6 | 38 | 39 | 40 |
41 |

jsonquery browser test

42 |
43 |
44 | 45 | 56 |
57 | 58 |
59 | 60 | 67 |
68 | 69 |
70 | 71 | 72 |
73 |
74 |
75 | 76 | 103 | 104 | 105 | -------------------------------------------------------------------------------- /test-suite/README.md: -------------------------------------------------------------------------------- 1 | # JSON Query Test Suite 2 | 3 | This test suite contains the reference tests for the JSON Query language in a language agnostic JSON format. These tests can be used to implement JSON Query in a new programming language or environment. 4 | 5 | The test-suite contains three sections: 6 | - [`compile.test.json`](./compile.test.json) tests verifying the behavior of the compiler, the query engine, i.e.: 7 | 8 | ```js 9 | import { compile } from '@jsonquerylang/jsonquery' 10 | 11 | const queryIt = compile(["sort"]) 12 | const result = queryIt([3, 1, 5]) 13 | // result should be [1, 3, 5] 14 | ``` 15 | 16 | - [`parse.test.json`](./parse.test.json) tests verifying the parser that parses the text format into the JSON format, i.e.: 17 | 18 | ```js 19 | import { parse } from '@jsonquerylang/jsonquery' 20 | 21 | const query = parse('filter(.age > 65)') 22 | // query should be ["filter", ["gt", ["get", "age"], 65]] 23 | ``` 24 | 25 | - [`stringify.test.json`](./stringify.test.json) tests converting the JSON format into the test format (including indentation), i.e.: 26 | 27 | ```js 28 | import { stringify } from '@jsonquerylang/jsonquery' 29 | 30 | const text = stringify(["sort", ["get", "age"], "desc"]) 31 | // text should be 'sort(.age, "desc")' 32 | ``` 33 | 34 | The test suites are accompanied by a `.d.ts` file containing the TypeScript models of the test suites, and a `.schema.json` file containing a JSON schema file matching the test suites. These can be of help when implementing a model for the test suites in a new language. 35 | -------------------------------------------------------------------------------- /test-suite/compile.test.d.ts: -------------------------------------------------------------------------------- 1 | import type { JSONQuery } from '../src/types' 2 | 3 | export interface CompileTestOutput { 4 | input: unknown 5 | query: JSONQuery 6 | output: unknown 7 | } 8 | 9 | export interface CompileTestException { 10 | input: unknown 11 | query: JSONQuery 12 | throws: string 13 | } 14 | 15 | export interface CompileTestGroup { 16 | category: string 17 | description: string 18 | tests: Array 19 | } 20 | 21 | export interface CompileTestSuite { 22 | updated: string 23 | groups: CompileTestGroup[] 24 | } 25 | -------------------------------------------------------------------------------- /test-suite/compile.test.json: -------------------------------------------------------------------------------- 1 | { 2 | "source": "https://github.com/jsonquerylang/jsonquery/blob/v5.0.3/test-suite/compile.test.json", 3 | "version": "5.0.3", 4 | "groups": [ 5 | { 6 | "category": "value", 7 | "description": "should get a string", 8 | "tests": [{ "input": null, "query": "Hello", "output": "Hello" }] 9 | }, 10 | { 11 | "category": "value", 12 | "description": "should get a number", 13 | "tests": [{ "input": null, "query": 2.4, "output": 2.4 }] 14 | }, 15 | { 16 | "category": "value", 17 | "description": "should get a boolean (true)", 18 | "tests": [{ "input": null, "query": true, "output": true }] 19 | }, 20 | { 21 | "category": "value", 22 | "description": "should get a boolean (false)", 23 | "tests": [{ "input": null, "query": false, "output": false }] 24 | }, 25 | { 26 | "category": "value", 27 | "description": "should get null", 28 | "tests": [{ "input": null, "query": null, "output": null }] 29 | }, 30 | { 31 | "category": "pipe", 32 | "description": "should execute a pipe", 33 | "tests": [ 34 | { 35 | "input": [{ "user": { "name": "Joe" } }], 36 | "query": ["pipe", ["get", 0], ["get", "user"], ["get", "name"]], 37 | "output": "Joe" 38 | }, 39 | { 40 | "input": [1, -2, 3], 41 | "query": ["pipe", ["filter", ["gte", ["get"], 0]], ["sum"]], 42 | "output": 4 43 | } 44 | ] 45 | }, 46 | { 47 | "category": "pipe", 48 | "description": "should execute an empty pipe", 49 | "tests": [ 50 | { 51 | "input": [1, 2, 3], 52 | "query": ["pipe"], 53 | "output": [1, 2, 3] 54 | } 55 | ] 56 | }, 57 | { 58 | "category": "object", 59 | "description": "should create a static object", 60 | "tests": [ 61 | { 62 | "input": null, 63 | "query": ["object", { "a": 2, "b": 3 }], 64 | "output": { "a": 2, "b": 3 } 65 | } 66 | ] 67 | }, 68 | { 69 | "category": "object", 70 | "description": "should create a dynamic object with getters", 71 | "tests": [ 72 | { 73 | "input": { "name": "Joe", "age": 23, "city": "New York" }, 74 | "query": ["object", { "firstName": ["get", "name"], "age": ["get", "age"] }], 75 | "output": { "firstName": "Joe", "age": 23 } 76 | } 77 | ] 78 | }, 79 | { 80 | "category": "object", 81 | "description": "should create an object containing null and false", 82 | "tests": [ 83 | { 84 | "input": null, 85 | "query": ["object", { "nothing": null, "false": false }], 86 | "output": { "nothing": null, "false": false } 87 | } 88 | ] 89 | }, 90 | { 91 | "category": "object", 92 | "description": "should create an object containing pipelines", 93 | "tests": [ 94 | { 95 | "input": [1, -2, 3], 96 | "query": [ 97 | "object", 98 | { 99 | "total": ["pipe", ["filter", ["gte", ["get"], 0]], ["sum"]] 100 | } 101 | ], 102 | "output": { "total": 4 } 103 | } 104 | ] 105 | }, 106 | { 107 | "category": "array", 108 | "description": "should create a static array", 109 | "tests": [ 110 | { 111 | "input": null, 112 | "query": ["array", 1, 2, 3], 113 | "output": [1, 2, 3] 114 | } 115 | ] 116 | }, 117 | { 118 | "category": "array", 119 | "description": "should create a dynamic array", 120 | "tests": [ 121 | { 122 | "input": { "name": "Joe", "age": 23, "city": "New York" }, 123 | "query": ["array", ["get", "name"], ["get", "age"]], 124 | "output": ["Joe", 23] 125 | }, 126 | { 127 | "input": null, 128 | "query": ["array", ["add", 10, 9], 23], 129 | "output": [19, 23] 130 | } 131 | ] 132 | }, 133 | { 134 | "category": "get", 135 | "description": "should get a path with a single property as string", 136 | "tests": [ 137 | { 138 | "input": { "name": "Joe" }, 139 | "query": ["get", "name"], 140 | "output": "Joe" 141 | } 142 | ] 143 | }, 144 | { 145 | "category": "get", 146 | "description": "should get the full object itself", 147 | "tests": [ 148 | { 149 | "input": { "name": "Joe" }, 150 | "query": ["get"], 151 | "output": { "name": "Joe" } 152 | } 153 | ] 154 | }, 155 | { 156 | "category": "get", 157 | "description": "should return null in case of a non-existing path", 158 | "tests": [ 159 | { 160 | "input": {}, 161 | "query": ["get", "foo", "bar"], 162 | "output": null 163 | }, 164 | { 165 | "input": [1, 2, 3], 166 | "query": ["get", 5], 167 | "output": null 168 | } 169 | ] 170 | }, 171 | { 172 | "category": "get", 173 | "description": "should get a path using function get", 174 | "tests": [ 175 | { 176 | "input": { "name": "Joe" }, 177 | "query": ["get", "name"], 178 | "output": "Joe" 179 | } 180 | ] 181 | }, 182 | { 183 | "category": "get", 184 | "description": "should get a value 0", 185 | "tests": [ 186 | { 187 | "input": { "value": 0 }, 188 | "query": ["get", "value"], 189 | "output": 0 190 | } 191 | ] 192 | }, 193 | { 194 | "category": "get", 195 | "description": "should get a nested value 0", 196 | "tests": [ 197 | { 198 | "input": { "nested": { "value": 0 } }, 199 | "query": ["get", "nested", "value"], 200 | "output": 0 201 | } 202 | ] 203 | }, 204 | { 205 | "category": "get", 206 | "description": "should get a value false", 207 | "tests": [ 208 | { 209 | "input": { "value": false }, 210 | "query": ["get", "value"], 211 | "output": false 212 | } 213 | ] 214 | }, 215 | { 216 | "category": "get", 217 | "description": "should get a path that has the same name as a function", 218 | "tests": [ 219 | { 220 | "input": { "sort": "Joe" }, 221 | "query": ["get", "sort"], 222 | "output": "Joe" 223 | } 224 | ] 225 | }, 226 | { 227 | "category": "get", 228 | "description": "should get a nested value that has the same name as a function", 229 | "tests": [ 230 | { 231 | "input": { "sort": { "name": "Joe" } }, 232 | "query": ["get", "sort", "name"], 233 | "output": "Joe" 234 | } 235 | ] 236 | }, 237 | { 238 | "category": "get", 239 | "description": "should get an item from an array", 240 | "tests": [ 241 | { 242 | "input": ["A", "B", "C"], 243 | "query": ["get", 1], 244 | "output": "B" 245 | }, 246 | { 247 | "input": { "arr": ["A", "B", "C"] }, 248 | "query": ["get", "arr", 1], 249 | "output": "B" 250 | }, 251 | { 252 | "input": [{ "text": "A" }, { "text": "B" }, { "text": "C" }], 253 | "query": ["get", 1, "text"], 254 | "output": "B" 255 | } 256 | ] 257 | }, 258 | { 259 | "category": "filter", 260 | "description": "should filter an array with booleans and null", 261 | "tests": [ 262 | { 263 | "input": [ 264 | { "id": 1, "admin": true }, 265 | { "id": 2 }, 266 | { "id": 3, "admin": true }, 267 | { "id": 4, "admin": false } 268 | ], 269 | "query": ["filter", ["get", "admin"]], 270 | "output": [{ "id": 1, "admin": true }, { "id": 3, "admin": true }] 271 | } 272 | ] 273 | }, 274 | { 275 | "category": "filter", 276 | "description": "should filter an array with numbers", 277 | "tests": [ 278 | { 279 | "input": [-1, 0, 1, 2, 3], 280 | "query": ["filter", ["get"]], 281 | "output": [-1, 1, 2, 3] 282 | } 283 | ] 284 | }, 285 | { 286 | "category": "filter", 287 | "description": "should filter an array with strings", 288 | "tests": [ 289 | { 290 | "input": ["hello", "", "world", " "], 291 | "query": ["filter", ["get"]], 292 | "output": ["hello", "", "world", " "] 293 | } 294 | ] 295 | }, 296 | { 297 | "category": "sort", 298 | "description": "should sort an array with numbers", 299 | "tests": [ 300 | { 301 | "input": [5, 2, 3], 302 | "query": ["sort"], 303 | "output": [2, 3, 5] 304 | } 305 | ] 306 | }, 307 | { 308 | "category": "sort", 309 | "description": "should sort an array with numbers (asc)", 310 | "tests": [ 311 | { 312 | "input": [5, 2, 3], 313 | "query": ["sort", ["get"], "asc"], 314 | "output": [2, 3, 5] 315 | } 316 | ] 317 | }, 318 | { 319 | "category": "sort", 320 | "description": "should sort an array with numbers (desc)", 321 | "tests": [ 322 | { 323 | "input": [5, 2, 3], 324 | "query": ["sort", ["get"], "desc"], 325 | "output": [5, 3, 2] 326 | } 327 | ] 328 | }, 329 | { 330 | "category": "sort", 331 | "description": "should sort an array with strings", 332 | "tests": [ 333 | { 334 | "input": ["C", "c", "b", "a", "B", "A"], 335 | "query": ["sort"], 336 | "output": ["A", "B", "C", "a", "b", "c"] 337 | } 338 | ] 339 | }, 340 | { 341 | "category": "sort", 342 | "description": "should sort an array with booleans", 343 | "tests": [ 344 | { "input": [true, false], "query": ["sort"], "output": [false, true] }, 345 | { "input": [false, true], "query": ["sort"], "output": [false, true] }, 346 | { 347 | "input": [false, true, false, true], 348 | "query": ["sort"], 349 | "output": [false, false, true, true] 350 | } 351 | ] 352 | }, 353 | { 354 | "category": "sort", 355 | "description": "should sort an array with objects", 356 | "tests": [ 357 | { 358 | "input": [{ "score": -2 }, { "score": 5 }, { "score": 3 }], 359 | "query": ["sort", ["get", "score"]], 360 | "output": [{ "score": -2 }, { "score": 3 }, { "score": 5 }] 361 | } 362 | ] 363 | }, 364 | { 365 | "category": "sort", 366 | "description": "should sort an array with objects (desc)", 367 | "tests": [ 368 | { 369 | "input": [{ "score": -2 }, { "score": 5 }, { "score": 3 }], 370 | "query": ["sort", ["get", "score"], "desc"], 371 | "output": [{ "score": 5 }, { "score": 3 }, { "score": -2 }] 372 | } 373 | ] 374 | }, 375 | { 376 | "category": "sort", 377 | "description": "should leave content as-is when trying to sort nested arrays", 378 | "tests": [ 379 | { 380 | "input": [[3], [1], [2]], 381 | "query": ["sort"], 382 | "output": [[3], [1], [2]] 383 | } 384 | ] 385 | }, 386 | { 387 | "category": "sort", 388 | "description": "should leave content as-is when trying to sort nested objects", 389 | "tests": [ 390 | { 391 | "input": [{ "a": 1 }, { "c": 3 }, { "b": 2 }], 392 | "query": ["sort"], 393 | "output": [{ "a": 1 }, { "c": 3 }, { "b": 2 }] 394 | } 395 | ] 396 | }, 397 | { 398 | "category": "sort", 399 | "description": "should sort mixed types (boolean, number, string, other)", 400 | "tests": [ 401 | { "input": [true, 0], "query": ["sort"], "output": [true, 0] }, 402 | { "input": [0, false], "query": ["sort"], "output": [false, 0] }, 403 | { "input": [2.4, true], "query": ["sort"], "output": [true, 2.4] }, 404 | { "input": [3, "B"], "query": ["sort"], "output": [3, "B"] }, 405 | { "input": ["B", 3], "query": ["sort"], "output": [3, "B"] }, 406 | { "input": ["A", true], "query": ["sort"], "output": [true, "A"] }, 407 | { "input": [2, [1]], "query": ["sort"], "output": [2, [1]] }, 408 | { "input": [[1], 2], "query": ["sort"], "output": [2, [1]] }, 409 | { "input": [{ "id": 0 }, 2], "query": ["sort"], "output": [2, { "id": 0 }] }, 410 | { "input": [2, { "id": 0 }, 3], "query": ["sort"], "output": [2, 3, { "id": 0 }] }, 411 | { 412 | "input": [{ "id": 2 }, { "id": 1 }], 413 | "query": ["sort"], 414 | "output": [{ "id": 2 }, { "id": 1 }] 415 | }, 416 | { "input": [{ "id": 0 }, [1]], "query": ["sort"], "output": [{ "id": 0 }, [1]] }, 417 | { "input": [[1], { "id": 0 }], "query": ["sort"], "output": [[1], { "id": 0 }] }, 418 | { "input": [[2], [1]], "query": ["sort"], "output": [[2], [1]] }, 419 | { 420 | "input": [3, "B", true, -1, false, { "id": 2 }, "A", [3], { "id": 1 }, [2], 2], 421 | "query": ["sort"], 422 | "output": [false, true, -1, 2, 3, "A", "B", { "id": 2 }, [3], { "id": 1 }, [2]] 423 | } 424 | ] 425 | }, 426 | { 427 | "category": "sort", 428 | "description": "should sort mixed types (boolean, number, string, other) in ASC order", 429 | "tests": [ 430 | { "input": [true, 0], "query": ["sort", ["get"], "asc"], "output": [true, 0] }, 431 | { "input": [0, false], "query": ["sort", ["get"], "asc"], "output": [false, 0] }, 432 | { "input": [2.4, true], "query": ["sort", ["get"], "asc"], "output": [true, 2.4] }, 433 | { "input": [3, "B"], "query": ["sort", ["get"], "asc"], "output": [3, "B"] }, 434 | { "input": ["B", 3], "query": ["sort", ["get"], "asc"], "output": [3, "B"] }, 435 | { "input": ["A", true], "query": ["sort", ["get"], "asc"], "output": [true, "A"] }, 436 | { "input": [2, [1]], "query": ["sort", ["get"], "asc"], "output": [2, [1]] }, 437 | { "input": [[1], 2], "query": ["sort", ["get"], "asc"], "output": [2, [1]] }, 438 | { 439 | "input": [{ "id": 0 }, 2], 440 | "query": ["sort", ["get"], "asc"], 441 | "output": [2, { "id": 0 }] 442 | }, 443 | { 444 | "input": [2, { "id": 0 }, 3], 445 | "query": ["sort", ["get"], "asc"], 446 | "output": [2, 3, { "id": 0 }] 447 | }, 448 | { 449 | "input": [{ "id": 2 }, { "id": 1 }], 450 | "query": ["sort", ["get"], "asc"], 451 | "output": [{ "id": 2 }, { "id": 1 }] 452 | }, 453 | { 454 | "input": [{ "id": 0 }, [1]], 455 | "query": ["sort", ["get"], "asc"], 456 | "output": [{ "id": 0 }, [1]] 457 | }, 458 | { 459 | "input": [[1], { "id": 0 }], 460 | "query": ["sort", ["get"], "asc"], 461 | "output": [[1], { "id": 0 }] 462 | }, 463 | { "input": [[2], [1]], "query": ["sort", ["get"], "asc"], "output": [[2], [1]] }, 464 | { 465 | "input": [3, "B", true, -1, false, { "id": 2 }, "A", [3], { "id": 1 }, [2], 2], 466 | "query": ["sort", ["get"], "asc"], 467 | "output": [false, true, -1, 2, 3, "A", "B", { "id": 2 }, [3], { "id": 1 }, [2]] 468 | } 469 | ] 470 | }, 471 | { 472 | "category": "sort", 473 | "description": "should sort mixed types (boolean, number, string, other) in DESC order", 474 | "tests": [ 475 | { "input": [true, 0], "query": ["sort", ["get"], "desc"], "output": [0, true] }, 476 | { "input": [0, false], "query": ["sort", ["get"], "desc"], "output": [0, false] }, 477 | { "input": [2.4, true], "query": ["sort", ["get"], "desc"], "output": [2.4, true] }, 478 | { "input": [3, "B"], "query": ["sort", ["get"], "desc"], "output": ["B", 3] }, 479 | { "input": ["B", 3], "query": ["sort", ["get"], "desc"], "output": ["B", 3] }, 480 | { "input": ["A", true], "query": ["sort", ["get"], "desc"], "output": ["A", true] }, 481 | { "input": [2, [1]], "query": ["sort", ["get"], "desc"], "output": [[1], 2] }, 482 | { "input": [[1], 2], "query": ["sort", ["get"], "desc"], "output": [[1], 2] }, 483 | { 484 | "input": [{ "id": 0 }, 2], 485 | "query": ["sort", ["get"], "desc"], 486 | "output": [{ "id": 0 }, 2] 487 | }, 488 | { 489 | "input": [2, { "id": 0 }, 3], 490 | "query": ["sort", ["get"], "desc"], 491 | "output": [{ "id": 0 }, 3, 2] 492 | }, 493 | { 494 | "input": [{ "id": 2 }, { "id": 1 }], 495 | "query": ["sort", ["get"], "desc"], 496 | "output": [{ "id": 2 }, { "id": 1 }] 497 | }, 498 | { 499 | "input": [{ "id": 0 }, [1]], 500 | "query": ["sort", ["get"], "desc"], 501 | "output": [{ "id": 0 }, [1]] 502 | }, 503 | { 504 | "input": [[1], { "id": 0 }], 505 | "query": ["sort", ["get"], "desc"], 506 | "output": [[1], { "id": 0 }] 507 | }, 508 | { "input": [[2], [1]], "query": ["sort", ["get"], "desc"], "output": [[2], [1]] }, 509 | { 510 | "input": [3, "B", true, -1, false, { "id": 2 }, "A", [3], { "id": 1 }, [2], 2], 511 | "query": ["sort", ["get"], "desc"], 512 | "output": [{ "id": 2 }, [3], { "id": 1 }, [2], "B", "A", 3, 2, -1, true, false] 513 | } 514 | ] 515 | }, 516 | { 517 | "category": "reverse", 518 | "description": "should reverse an array", 519 | "tests": [ 520 | { 521 | "input": [4, 2, 3], 522 | "query": ["reverse"], 523 | "output": [3, 2, 4] 524 | } 525 | ] 526 | }, 527 | { 528 | "category": "reverse", 529 | "description": "should reverse an array without altering the original array", 530 | "tests": [ 531 | { 532 | "input": [4, 2, 3], 533 | "query": ["object", { "a": ["reverse"], "b": ["reverse"] }], 534 | "output": { "a": [3, 2, 4], "b": [3, 2, 4] } 535 | } 536 | ] 537 | }, 538 | { 539 | "category": "pick", 540 | "description": "should pick one property from an object", 541 | "tests": [ 542 | { 543 | "input": { "name": "Joe", "age": 23, "city": "New York" }, 544 | "query": ["pick", ["get", "name"]], 545 | "output": { "name": "Joe" } 546 | } 547 | ] 548 | }, 549 | { 550 | "category": "pick", 551 | "description": "should pick multiple properties from an object", 552 | "tests": [ 553 | { 554 | "input": { "name": "Joe", "age": 23, "city": "New York" }, 555 | "query": ["pick", ["get", "name"], ["get", "city"]], 556 | "output": { "name": "Joe", "city": "New York" } 557 | } 558 | ] 559 | }, 560 | { 561 | "category": "pick", 562 | "description": "should pick nested properties from an object", 563 | "tests": [ 564 | { 565 | "input": { 566 | "name": "Joe", 567 | "age": 23, 568 | "address": { "city": "New York" } 569 | }, 570 | "query": ["pick", ["get", "name"], ["get", "address", "city"]], 571 | "output": { "name": "Joe", "city": "New York" } 572 | } 573 | ] 574 | }, 575 | { 576 | "category": "pick", 577 | "description": "should pick one property from an array", 578 | "tests": [ 579 | { 580 | "input": [ 581 | { "name": "Joe", "age": 23, "city": "New York" }, 582 | { "name": "Sarah", "age": 21, "city": "Amsterdam" } 583 | ], 584 | "query": ["pick", ["get", "name"]], 585 | "output": [{ "name": "Joe" }, { "name": "Sarah" }] 586 | } 587 | ] 588 | }, 589 | { 590 | "category": "pick", 591 | "description": "should pick multiple properties from an array", 592 | "tests": [ 593 | { 594 | "input": [ 595 | { "name": "Joe", "age": 23, "city": "New York" }, 596 | { "name": "Sarah", "age": 21, "city": "Amsterdam" } 597 | ], 598 | "query": ["pick", ["get", "name"], ["get", "city"]], 599 | "output": [ 600 | { "name": "Joe", "city": "New York" }, 601 | { "name": "Sarah", "city": "Amsterdam" } 602 | ] 603 | } 604 | ] 605 | }, 606 | { 607 | "category": "pick", 608 | "description": "should pick nested properties from an array", 609 | "tests": [ 610 | { 611 | "input": [ 612 | { "name": "Joe", "age": 23, "address": { "city": "New York" } }, 613 | { "name": "Sarah", "age": 21, "address": { "city": "Amsterdam" } } 614 | ], 615 | "query": ["pick", ["get", "name"], ["get", "address", "city"]], 616 | "output": [ 617 | { "name": "Joe", "city": "New York" }, 618 | { "name": "Sarah", "city": "Amsterdam" } 619 | ] 620 | } 621 | ] 622 | }, 623 | { 624 | "category": "map", 625 | "description": "should map an array with objects", 626 | "tests": [ 627 | { 628 | "input": [ 629 | { "name": "Joe", "age": 23 }, 630 | { "name": "Oliver", "age": 27 }, 631 | { "name": "Sarah", "age": 21 } 632 | ], 633 | "query": ["map", ["get", "name"]], 634 | "output": ["Joe", "Oliver", "Sarah"] 635 | } 636 | ] 637 | }, 638 | { 639 | "category": "map", 640 | "description": "should map an array with numbers", 641 | "tests": [ 642 | { 643 | "input": [3, -4, 1, -7], 644 | "query": ["map", ["abs", ["get"]]], 645 | "output": [3, 4, 1, 7] 646 | } 647 | ] 648 | }, 649 | { 650 | "category": "mapObject", 651 | "description": "should map the entries of an object", 652 | "tests": [ 653 | { 654 | "input": { "a": 2, "b": 3 }, 655 | "query": [ 656 | "mapObject", 657 | [ 658 | "object", 659 | { 660 | "key": ["add", "#", ["get", "key"]], 661 | "value": ["add", ["get", "key"], ["get", "value"]] 662 | } 663 | ] 664 | ], 665 | "output": { "#a": "a2", "#b": "b3" } 666 | } 667 | ] 668 | }, 669 | { 670 | "category": "mapKeys", 671 | "description": "should map the keys of an object", 672 | "tests": [ 673 | { 674 | "input": { "a": 2, "b": 3 }, 675 | "query": ["mapKeys", ["add", "#", ["get"]]], 676 | "output": { "#a": 2, "#b": 3 } 677 | } 678 | ] 679 | }, 680 | { 681 | "category": "mapValues", 682 | "description": "should map the values of an object", 683 | "tests": [ 684 | { 685 | "input": { "a": 2, "b": 3 }, 686 | "query": ["mapValues", ["multiply", ["get"], 2]], 687 | "output": { "a": 4, "b": 6 } 688 | } 689 | ] 690 | }, 691 | { 692 | "category": "groupBy", 693 | "description": "should group items by a key", 694 | "tests": [ 695 | { 696 | "input": [ 697 | { "name": "Joe", "city": "New York" }, 698 | { "name": "Oliver", "city": "Amsterdam" }, 699 | { "name": "Sarah", "city": "Amsterdam" } 700 | ], 701 | "query": ["groupBy", ["get", "city"]], 702 | "output": { 703 | "New York": [{ "name": "Joe", "city": "New York" }], 704 | "Amsterdam": [ 705 | { "name": "Oliver", "city": "Amsterdam" }, 706 | { "name": "Sarah", "city": "Amsterdam" } 707 | ] 708 | } 709 | } 710 | ] 711 | }, 712 | { 713 | "category": "keyBy", 714 | "description": "should turn an array in an object by key", 715 | "tests": [ 716 | { 717 | "input": [ 718 | { "id": 1, "name": "Joe" }, 719 | { "id": 2, "name": "Oliver" }, 720 | { "id": 3, "name": "Sarah" } 721 | ], 722 | "query": ["keyBy", ["get", "id"]], 723 | "output": { 724 | "1": { "id": 1, "name": "Joe" }, 725 | "2": { "id": 2, "name": "Oliver" }, 726 | "3": { "id": 3, "name": "Sarah" } 727 | } 728 | } 729 | ] 730 | }, 731 | { 732 | "category": "keyBy", 733 | "description": "should handle duplicate keys in keyBy, keeping the first", 734 | "tests": [ 735 | { 736 | "input": [ 737 | { "id": 1, "name": "Joe" }, 738 | { "id": 2, "name": "Oliver" }, 739 | { "id": 1, "name": "Sarah" } 740 | ], 741 | "query": ["keyBy", ["get", "id"]], 742 | "output": { 743 | "1": { "id": 1, "name": "Joe" }, 744 | "2": { "id": 2, "name": "Oliver" } 745 | } 746 | } 747 | ] 748 | }, 749 | { 750 | "category": "keys", 751 | "description": "should extract the keys of an object", 752 | "tests": [ 753 | { 754 | "input": { "a": 2, "b": 3 }, 755 | "query": ["keys"], 756 | "output": ["a", "b"] 757 | } 758 | ] 759 | }, 760 | { 761 | "category": "values", 762 | "description": "should extract the values of an object", 763 | "tests": [ 764 | { 765 | "input": { "a": 2, "b": 3 }, 766 | "query": ["values"], 767 | "output": [2, 3] 768 | } 769 | ] 770 | }, 771 | { 772 | "category": "flatten", 773 | "description": "should flatten an array", 774 | "tests": [ 775 | { 776 | "input": [[1, 2], [3, 4, 5]], 777 | "query": ["flatten"], 778 | "output": [1, 2, 3, 4, 5] 779 | } 780 | ] 781 | }, 782 | { 783 | "category": "flatten", 784 | "description": "should not flatten arrays inside arrays", 785 | "tests": [ 786 | { 787 | "input": [[1, [2, 3]]], 788 | "query": ["flatten"], 789 | "output": [1, [2, 3]] 790 | } 791 | ] 792 | }, 793 | { 794 | "category": "join", 795 | "description": "should join an array", 796 | "tests": [ 797 | { 798 | "input": ["a", "b", "c"], 799 | "query": ["join"], 800 | "output": "abc" 801 | } 802 | ] 803 | }, 804 | { 805 | "category": "join", 806 | "description": "should join an array with a custom separator", 807 | "tests": [ 808 | { 809 | "input": ["a", "b", "c"], 810 | "query": ["join", ", "], 811 | "output": "a, b, c" 812 | } 813 | ] 814 | }, 815 | { 816 | "category": "split", 817 | "description": "should split a string", 818 | "tests": [ 819 | { 820 | "input": null, 821 | "query": ["split", "start with a b c"], 822 | "output": ["start", "with", "a", "b", "c"] 823 | }, 824 | { 825 | "input": { "message": "start with a b c" }, 826 | "query": ["split", ["get", "message"]], 827 | "output": ["start", "with", "a", "b", "c"] 828 | } 829 | ] 830 | }, 831 | { 832 | "category": "split", 833 | "description": "should split a string with multiple whitespaces between the words", 834 | "tests": [ 835 | { 836 | "input": null, 837 | "query": ["split", " \n\n\t start with a b \n\r\t c \n\n\t "], 838 | "output": ["start", "with", "a", "b", "c"] 839 | } 840 | ] 841 | }, 842 | { 843 | "category": "split", 844 | "description": "should split a string by individual characters", 845 | "tests": [ 846 | { 847 | "input": null, 848 | "query": ["split", "abc", ""], 849 | "output": ["a", "b", "c"] 850 | } 851 | ] 852 | }, 853 | { 854 | "category": "split", 855 | "description": "should split a string with a separator", 856 | "tests": [ 857 | { 858 | "input": null, 859 | "query": ["split", "a,b,c", ","], 860 | "output": ["a", "b", "c"] 861 | } 862 | ] 863 | }, 864 | { 865 | "category": "substring", 866 | "description": "should get a substring of a string with a start index", 867 | "tests": [ 868 | { 869 | "input": "123456", 870 | "query": ["substring", ["get"], 3], 871 | "output": "456" 872 | } 873 | ] 874 | }, 875 | { 876 | "category": "substring", 877 | "description": "should get a substring of a string with a start and end index", 878 | "tests": [ 879 | { 880 | "input": { "value": "123456" }, 881 | "query": ["substring", ["get", "value"], 2, 4], 882 | "output": "34" 883 | } 884 | ] 885 | }, 886 | { 887 | "category": "substring", 888 | "description": "should get a substring of a string with a start exceeding the string length", 889 | "tests": [ 890 | { 891 | "input": null, 892 | "query": ["substring", "123456", 10], 893 | "output": "" 894 | } 895 | ] 896 | }, 897 | { 898 | "category": "substring", 899 | "description": "should get a substring of a string with an end exceeding the string length", 900 | "tests": [ 901 | { 902 | "input": null, 903 | "query": ["substring", "123456", 0, 10], 904 | "output": "123456" 905 | } 906 | ] 907 | }, 908 | { 909 | "category": "substring", 910 | "description": "should get a substring of a string with an end index smaller than the start index", 911 | "tests": [ 912 | { 913 | "input": null, 914 | "query": ["substring", "123456", 3, 0], 915 | "output": "" 916 | } 917 | ] 918 | }, 919 | { 920 | "category": "substring", 921 | "description": "should get a substring of a string with a negative start index", 922 | "tests": [ 923 | { 924 | "input": null, 925 | "query": ["substring", "123456", -2], 926 | "output": "123456" 927 | } 928 | ] 929 | }, 930 | { 931 | "category": "uniq", 932 | "description": "should get unique values from a list with numbers", 933 | "tests": [ 934 | { 935 | "input": [2, 3, 2, 7, 1, 1], 936 | "query": ["uniq"], 937 | "output": [2, 3, 7, 1] 938 | } 939 | ] 940 | }, 941 | { 942 | "category": "uniq", 943 | "description": "should get unique values from a list with strings", 944 | "tests": [ 945 | { 946 | "input": ["hi", "hello", "hi", "HI", "bye", "bye"], 947 | "query": ["uniq"], 948 | "output": ["hi", "hello", "HI", "bye"] 949 | } 950 | ] 951 | }, 952 | { 953 | "category": "uniq", 954 | "description": "should get unique values from a list objects (deep comparison)", 955 | "tests": [ 956 | { 957 | "input": [{ "a": 1, "b": 2 }, { "b": 2 }, { "b": 2, "a": 1 }, [1], [1]], 958 | "query": ["uniq"], 959 | "output": [{ "a": 1, "b": 2 }, { "b": 2 }, [1]] 960 | } 961 | ] 962 | }, 963 | { 964 | "category": "uniq", 965 | "description": "should get unique values from a list with mixed nullish types", 966 | "tests": [ 967 | { 968 | "input": [null, null, false, 0, "", null, false, 0, ""], 969 | "query": ["uniq"], 970 | "output": [null, false, 0, ""] 971 | } 972 | ] 973 | }, 974 | { 975 | "category": "uniqBy", 976 | "description": "should get unique objects by key (keeping the first)", 977 | "tests": [ 978 | { 979 | "input": [ 980 | { "name": "Joe", "city": "New York" }, 981 | { "name": "Oliver", "city": "Amsterdam" }, 982 | { "name": "Sarah", "city": "Amsterdam" } 983 | ], 984 | "query": ["uniqBy", ["get", "city"]], 985 | "output": [ 986 | { "name": "Joe", "city": "New York" }, 987 | { "name": "Oliver", "city": "Amsterdam" } 988 | ] 989 | } 990 | ] 991 | }, 992 | { 993 | "category": "limit", 994 | "description": "should limit an array with numbers", 995 | "tests": [ 996 | { 997 | "input": [1, 2, 3, 4, 5], 998 | "query": ["limit", 3], 999 | "output": [1, 2, 3] 1000 | } 1001 | ] 1002 | }, 1003 | { 1004 | "category": "limit", 1005 | "description": "should limit an array with an index larger than the length of the array", 1006 | "tests": [ 1007 | { 1008 | "input": [1, 2, 3], 1009 | "query": ["limit", 10], 1010 | "output": [1, 2, 3] 1011 | } 1012 | ] 1013 | }, 1014 | { 1015 | "category": "limit", 1016 | "description": "should limit an array with objects", 1017 | "tests": [ 1018 | { 1019 | "input": [ 1020 | { "id": 1, "name": "Joe" }, 1021 | { "id": 2, "name": "Oliver" }, 1022 | { "id": 3, "name": "Sarah" } 1023 | ], 1024 | "query": ["limit", 2], 1025 | "output": [{ "id": 1, "name": "Joe" }, { "id": 2, "name": "Oliver" }] 1026 | } 1027 | ] 1028 | }, 1029 | { 1030 | "category": "limit", 1031 | "description": "should return an empty array when limit has a negative value", 1032 | "tests": [ 1033 | { 1034 | "input": [1, 2, 3], 1035 | "query": ["limit", -2], 1036 | "output": [] 1037 | } 1038 | ] 1039 | }, 1040 | { 1041 | "category": "size", 1042 | "description": "should return the size of an array", 1043 | "tests": [ 1044 | { "input": [], "query": ["size"], "output": 0 }, 1045 | { "input": [1, 2, 3], "query": ["size"], "output": 3 }, 1046 | { "input": [{}, {}, {}, {}], "query": ["size"], "output": 4 } 1047 | ] 1048 | }, 1049 | { 1050 | "category": "size", 1051 | "description": "should return the size of a string", 1052 | "tests": [{ "input": "12345", "query": ["size"], "output": 5 }] 1053 | }, 1054 | { 1055 | "category": "sum", 1056 | "description": "should calculate the sum of an array with integers", 1057 | "tests": [{ "input": [1, 2, 3], "query": ["sum"], "output": 6 }] 1058 | }, 1059 | { 1060 | "category": "sum", 1061 | "description": "should calculate the sum of an array with floats", 1062 | "tests": [{ "input": [2.4, 5.7], "query": ["sum"], "output": 8.1 }] 1063 | }, 1064 | { 1065 | "category": "sum", 1066 | "description": "should return 0 when calculating the sum of an empty array", 1067 | "tests": [{ "input": [], "query": ["sum"], "output": 0 }] 1068 | }, 1069 | { 1070 | "category": "sum", 1071 | "description": "should throw an error when calculating the sum of a string", 1072 | "tests": [ 1073 | { 1074 | "input": "abc", 1075 | "query": ["sum"], 1076 | "throws": "Array expected" 1077 | } 1078 | ] 1079 | }, 1080 | { 1081 | "category": "min", 1082 | "description": "should calculate the minimum value", 1083 | "tests": [ 1084 | { 1085 | "input": [3, -4, 1, -7], 1086 | "query": ["min"], 1087 | "output": -7 1088 | } 1089 | ] 1090 | }, 1091 | { 1092 | "category": "min", 1093 | "description": "should return null when calculating min of an empty array", 1094 | "tests": [{ "input": [], "query": ["min"], "output": null }] 1095 | }, 1096 | { 1097 | "category": "min", 1098 | "description": "should throw an error when calculating min on a string", 1099 | "tests": [ 1100 | { 1101 | "input": "abc", 1102 | "query": ["min"], 1103 | "throws": "Array expected" 1104 | } 1105 | ] 1106 | }, 1107 | { 1108 | "category": "max", 1109 | "description": "should calculate the maximum value", 1110 | "tests": [ 1111 | { 1112 | "input": [3, -4, 1, -7], 1113 | "query": ["max"], 1114 | "output": 3 1115 | } 1116 | ] 1117 | }, 1118 | { 1119 | "category": "max", 1120 | "description": "should return null when calculating max of an empty array", 1121 | "tests": [{ "input": [], "query": ["max"], "output": null }] 1122 | }, 1123 | { 1124 | "category": "max", 1125 | "description": "should throw an error when calculating max on a string", 1126 | "tests": [ 1127 | { 1128 | "input": "abc", 1129 | "query": ["max"], 1130 | "throws": "Array expected" 1131 | } 1132 | ] 1133 | }, 1134 | { 1135 | "category": "prod", 1136 | "description": "should calculate the product", 1137 | "tests": [{ "input": [2, 3, 5], "query": ["prod"], "output": 30 }] 1138 | }, 1139 | { 1140 | "category": "prod", 1141 | "description": "should return null when calculating the prod of an empty array", 1142 | "tests": [ 1143 | { 1144 | "input": [], 1145 | "query": ["prod"], 1146 | "output": null 1147 | } 1148 | ] 1149 | }, 1150 | { 1151 | "category": "prod", 1152 | "description": "should throw an error when calculating the prod of a string", 1153 | "tests": [ 1154 | { 1155 | "input": "abc", 1156 | "query": ["prod"], 1157 | "throws": "Array expected" 1158 | } 1159 | ] 1160 | }, 1161 | { 1162 | "category": "average", 1163 | "description": "should calculate the average", 1164 | "tests": [ 1165 | { "input": [2, 4], "query": ["average"], "output": 3 }, 1166 | { "input": [2, 3, 2, 7, 1], "query": ["average"], "output": 3 } 1167 | ] 1168 | }, 1169 | { 1170 | "category": "average", 1171 | "description": "should return null when calculating the average of an empty array", 1172 | "tests": [ 1173 | { 1174 | "input": [], 1175 | "query": ["average"], 1176 | "output": null 1177 | } 1178 | ] 1179 | }, 1180 | { 1181 | "category": "average", 1182 | "description": "should throw an error when calculating the average of a string", 1183 | "tests": [ 1184 | { 1185 | "input": "abc", 1186 | "query": ["average"], 1187 | "throws": "Array expected" 1188 | } 1189 | ] 1190 | }, 1191 | { 1192 | "category": "eq", 1193 | "description": "should calculate equal", 1194 | "tests": [ 1195 | { 1196 | "input": { "a": 1, "b": 2 }, 1197 | "query": ["eq", ["get", "a"], ["get", "b"]], 1198 | "output": false 1199 | }, 1200 | { 1201 | "input": { "a": 2, "b": 2 }, 1202 | "query": ["eq", ["get", "a"], ["get", "b"]], 1203 | "output": true 1204 | }, 1205 | { 1206 | "input": { "a": 3, "b": 2 }, 1207 | "query": ["eq", ["get", "a"], ["get", "b"]], 1208 | "output": false 1209 | }, 1210 | { "input": { "a": 0.1 }, "query": ["eq", ["get", "a"], 0.1], "output": true }, 1211 | { "input": { "a": 0.1 }, "query": ["eq", ["get", "a"], 0.2], "output": false }, 1212 | { "input": { "a": 0.1 }, "query": ["eq", ["get", "a"], -0.1], "output": false }, 1213 | { "input": null, "query": ["eq", "a", "a"], "output": true }, 1214 | { "input": null, "query": ["eq", "a", "b"], "output": false }, 1215 | { "input": null, "query": ["eq", "a", "A"], "output": false }, 1216 | { "input": null, "query": ["eq", "abc", "abc"], "output": true }, 1217 | { "input": null, "query": ["eq", "abc", "ab"], "output": false }, 1218 | { "input": null, "query": ["eq", true, true], "output": true }, 1219 | { "input": null, "query": ["eq", true, false], "output": false }, 1220 | { "input": null, "query": ["eq", false, true], "output": false }, 1221 | { "input": null, "query": ["eq", false, true], "output": false }, 1222 | { "input": null, "query": ["eq", false, false], "output": true }, 1223 | { "input": null, "query": ["eq", null, null], "output": true }, 1224 | { "input": null, "query": ["eq", 0, 0], "output": true }, 1225 | { "input": null, "query": ["eq", "", ""], "output": true } 1226 | ] 1227 | }, 1228 | { 1229 | "category": "eq", 1230 | "description": "should calculate equal comparing mixed types (no type coercion)", 1231 | "tests": [ 1232 | { "input": null, "query": ["eq", "2", 2], "output": false }, 1233 | { "input": null, "query": ["eq", "", 0], "output": false }, 1234 | { "input": null, "query": ["eq", 0, ""], "output": false }, 1235 | { "input": null, "query": ["eq", "", null], "output": false }, 1236 | { "input": null, "query": ["eq", null, ""], "output": false }, 1237 | { "input": null, "query": ["eq", 0, null], "output": false }, 1238 | { "input": null, "query": ["eq", 0, false], "output": false }, 1239 | { "input": null, "query": ["eq", ["array"], null], "output": false }, 1240 | { "input": null, "query": ["eq", ["array", 2], 2], "output": false } 1241 | ] 1242 | }, 1243 | { 1244 | "category": "eq", 1245 | "description": "should calculate (deep) equal on objects", 1246 | "tests": [ 1247 | { 1248 | "input": null, 1249 | "query": ["eq", ["object", { "a": 2, "b": 3 }], ["object", { "b": 3, "a": 2 }]], 1250 | "output": true 1251 | }, 1252 | { 1253 | "input": null, 1254 | "query": ["eq", ["object", { "a": 2, "b": 3 }], ["object", { "b": 3, "a": 2, "c": 4 }]], 1255 | "output": false 1256 | }, 1257 | { 1258 | "input": null, 1259 | "query": ["eq", ["object", { "a": 2, "b": 3, "c": 4 }], ["object", { "b": 3, "a": 2 }]], 1260 | "output": false 1261 | } 1262 | ] 1263 | }, 1264 | { 1265 | "category": "eq", 1266 | "description": "should calculate (deep) equal on arrays", 1267 | "tests": [ 1268 | { 1269 | "input": null, 1270 | "query": ["eq", ["array", 1, 2, 3], ["array", 1, 2, 3]], 1271 | "output": true 1272 | }, 1273 | { 1274 | "input": null, 1275 | "query": ["eq", ["array", 1, 2], ["array", 1, 2, 3]], 1276 | "output": false 1277 | }, 1278 | { 1279 | "input": null, 1280 | "query": ["eq", ["array", 1, 2, 3], ["array", 1, 2]], 1281 | "output": false 1282 | } 1283 | ] 1284 | }, 1285 | { 1286 | "category": "eq", 1287 | "description": "should calculate (deep) equal on nested objects and arrays", 1288 | "tests": [ 1289 | { 1290 | "input": null, 1291 | "query": [ 1292 | "eq", 1293 | ["object", { "arr": ["array", 1, 2, 3] }], 1294 | ["object", { "arr": ["array", 1, 2, 3] }] 1295 | ], 1296 | "output": true 1297 | }, 1298 | { 1299 | "input": null, 1300 | "query": [ 1301 | "eq", 1302 | ["object", { "arr": ["array", 1, 2] }], 1303 | ["object", { "arr": ["array", 1, 2, 3] }] 1304 | ], 1305 | "output": false 1306 | } 1307 | ] 1308 | }, 1309 | { 1310 | "category": "gt", 1311 | "description": "should calculate greater than", 1312 | "tests": [ 1313 | { 1314 | "input": { "a": 1, "b": 2 }, 1315 | "query": ["gt", ["get", "a"], ["get", "b"]], 1316 | "output": false 1317 | }, 1318 | { 1319 | "input": { "a": 2, "b": 2 }, 1320 | "query": ["gt", ["get", "a"], ["get", "b"]], 1321 | "output": false 1322 | }, 1323 | { 1324 | "input": { "a": 3, "b": 2 }, 1325 | "query": ["gt", ["get", "a"], ["get", "b"]], 1326 | "output": true 1327 | }, 1328 | { "input": null, "query": ["gt", 3, 2], "output": true } 1329 | ] 1330 | }, 1331 | { 1332 | "category": "gt", 1333 | "description": "should calculate greater than for strings", 1334 | "tests": [ 1335 | { "input": null, "query": ["gt", "abd", "abc"], "output": true }, 1336 | { "input": null, "query": ["gt", "abc", "abc"], "output": false }, 1337 | { "input": null, "query": ["gt", "abcd", "abc"], "output": true }, 1338 | { "input": null, "query": ["gt", "A", "a"], "output": false }, 1339 | { "input": null, "query": ["gt", "20", "3"], "output": false } 1340 | ] 1341 | }, 1342 | { 1343 | "category": "gt", 1344 | "description": "should calculate greater than for booleans", 1345 | "tests": [ 1346 | { "input": null, "query": ["gt", true, true], "output": false }, 1347 | { "input": null, "query": ["gt", true, false], "output": true }, 1348 | { "input": null, "query": ["gt", false, true], "output": false }, 1349 | { "input": null, "query": ["gt", false, false], "output": false } 1350 | ] 1351 | }, 1352 | { 1353 | "category": "gt", 1354 | "description": "should return false when calculating greater than with mixed data types", 1355 | "tests": [{ "input": null, "query": ["gt", "3", 2], "output": false }] 1356 | }, 1357 | { 1358 | "category": "gt", 1359 | "description": "should return false when calculating greater than with an unsupported data type", 1360 | "tests": [ 1361 | { "input": null, "query": ["gt", 2, ["array", 1, 2, 3]], "output": false }, 1362 | { "input": null, "query": ["gt", ["array", 1, 2, 4], ["array", 1, 2, 3]], "output": false }, 1363 | { "input": null, "query": ["gt", 2, ["object", { "a": 1 }]], "output": false } 1364 | ] 1365 | }, 1366 | { 1367 | "category": "gte", 1368 | "description": "should calculate greater than or equal to", 1369 | "tests": [ 1370 | { 1371 | "input": { "a": 1, "b": 2 }, 1372 | "query": ["gte", ["get", "a"], ["get", "b"]], 1373 | "output": false 1374 | }, 1375 | { 1376 | "input": { "a": 2, "b": 2 }, 1377 | "query": ["gte", ["get", "a"], ["get", "b"]], 1378 | "output": true 1379 | }, 1380 | { 1381 | "input": { "a": 3, "b": 2 }, 1382 | "query": ["gte", ["get", "a"], ["get", "b"]], 1383 | "output": true 1384 | }, 1385 | { "input": null, "query": ["gte", 3, 2], "output": true } 1386 | ] 1387 | }, 1388 | { 1389 | "category": "gte", 1390 | "description": "should calculate greater than or equal to for strings", 1391 | "tests": [ 1392 | { "input": null, "query": ["gte", "abd", "abc"], "output": true }, 1393 | { "input": null, "query": ["gte", "abc", "abc"], "output": true }, 1394 | { "input": null, "query": ["gte", "abcd", "abc"], "output": true }, 1395 | { "input": null, "query": ["gte", "A", "a"], "output": false }, 1396 | { "input": null, "query": ["gte", "20", "3"], "output": false } 1397 | ] 1398 | }, 1399 | { 1400 | "category": "gte", 1401 | "description": "should calculate greater than or equal for booleans", 1402 | "tests": [ 1403 | { "input": null, "query": ["gte", true, true], "output": true }, 1404 | { "input": null, "query": ["gte", true, false], "output": true }, 1405 | { "input": null, "query": ["gte", false, true], "output": false }, 1406 | { "input": null, "query": ["gte", false, false], "output": true } 1407 | ] 1408 | }, 1409 | { 1410 | "category": "gte", 1411 | "description": "should return false when calculating greater than or equal to with mixed data types", 1412 | "tests": [{ "input": null, "query": ["gte", "3", 2], "output": false }] 1413 | }, 1414 | { 1415 | "category": "gte", 1416 | "description": "should return false when calculating greater than or equal to with an unsupported data type", 1417 | "tests": [ 1418 | { "input": null, "query": ["gte", 2, ["array", 1, 2, 3]], "output": false }, 1419 | { 1420 | "input": null, 1421 | "query": ["gte", ["array", 1, 2, 4], ["array", 1, 2, 3]], 1422 | "output": false 1423 | }, 1424 | { "input": null, "query": ["gte", 2, ["object", { "a": 1 }]], "output": false } 1425 | ] 1426 | }, 1427 | { 1428 | "category": "lt", 1429 | "description": "should calculate less than", 1430 | "tests": [ 1431 | { 1432 | "input": { "a": 1, "b": 2 }, 1433 | "query": ["lt", ["get", "a"], ["get", "b"]], 1434 | "output": true 1435 | }, 1436 | { 1437 | "input": { "a": 2, "b": 2 }, 1438 | "query": ["lt", ["get", "a"], ["get", "b"]], 1439 | "output": false 1440 | }, 1441 | { 1442 | "input": { "a": 3, "b": 2 }, 1443 | "query": ["lt", ["get", "a"], ["get", "b"]], 1444 | "output": false 1445 | }, 1446 | { "input": null, "query": ["lt", 1, 2], "output": true } 1447 | ] 1448 | }, 1449 | { 1450 | "category": "lt", 1451 | "description": "should calculate less than for strings", 1452 | "tests": [ 1453 | { "input": null, "query": ["lt", "abc", "abd"], "output": true }, 1454 | { "input": null, "query": ["lt", "abc", "abc"], "output": false }, 1455 | { "input": null, "query": ["lt", "abc", "abcd"], "output": true }, 1456 | { "input": null, "query": ["lt", "a", "A"], "output": false }, 1457 | { "input": null, "query": ["lt", "3", "20"], "output": false } 1458 | ] 1459 | }, 1460 | { 1461 | "category": "lt", 1462 | "description": "should calculate less than for booleans", 1463 | "tests": [ 1464 | { "input": null, "query": ["lt", true, true], "output": false }, 1465 | { "input": null, "query": ["lt", true, false], "output": false }, 1466 | { "input": null, "query": ["lt", false, true], "output": true }, 1467 | { "input": null, "query": ["lt", false, false], "output": false } 1468 | ] 1469 | }, 1470 | { 1471 | "category": "lt", 1472 | "description": "should return false when calculating less than with mixed data types", 1473 | "tests": [{ "input": null, "query": ["lt", 2, "3"], "output": false }] 1474 | }, 1475 | { 1476 | "category": "lt", 1477 | "description": "should return false when calculating less than with an unsupported data type", 1478 | "tests": [ 1479 | { "input": null, "query": ["lt", 2, ["array", 1, 2, 3]], "output": false }, 1480 | { "input": null, "query": ["lt", ["array", 1, 2, 4], ["array", 1, 2, 3]], "output": false }, 1481 | { "input": null, "query": ["lt", 2, ["object", { "a": 1 }]], "output": false } 1482 | ] 1483 | }, 1484 | { 1485 | "category": "lte", 1486 | "description": "should calculate less than or equal to", 1487 | "tests": [ 1488 | { 1489 | "input": { "a": 1, "b": 2 }, 1490 | "query": ["lte", ["get", "a"], ["get", "b"]], 1491 | "output": true 1492 | }, 1493 | { 1494 | "input": { "a": 2, "b": 2 }, 1495 | "query": ["lte", ["get", "a"], ["get", "b"]], 1496 | "output": true 1497 | }, 1498 | { 1499 | "input": { "a": 3, "b": 2 }, 1500 | "query": ["lte", ["get", "a"], ["get", "b"]], 1501 | "output": false 1502 | }, 1503 | { 1504 | "input": null, 1505 | "query": ["lte", 2, 2], 1506 | "output": true 1507 | } 1508 | ] 1509 | }, 1510 | { 1511 | "category": "lte", 1512 | "description": "should calculate less than or equal to for strings", 1513 | "tests": [ 1514 | { "input": null, "query": ["lte", "abc", "abd"], "output": true }, 1515 | { "input": null, "query": ["lte", "abc", "abc"], "output": true }, 1516 | { "input": null, "query": ["lte", "abc", "abcd"], "output": true }, 1517 | { "input": null, "query": ["lte", "a", "A"], "output": false }, 1518 | { "input": null, "query": ["lte", "3", "20"], "output": false } 1519 | ] 1520 | }, 1521 | { 1522 | "category": "lte", 1523 | "description": "should calculate less than or equal for booleans", 1524 | "tests": [ 1525 | { "input": null, "query": ["lte", true, true], "output": true }, 1526 | { "input": null, "query": ["lte", true, false], "output": false }, 1527 | { "input": null, "query": ["lte", false, true], "output": true }, 1528 | { "input": null, "query": ["lte", false, false], "output": true } 1529 | ] 1530 | }, 1531 | { 1532 | "category": "lte", 1533 | "description": "should return false when calculating less than or equal to with mixed data types", 1534 | "tests": [{ "input": null, "query": ["lte", "3", 2], "output": false }] 1535 | }, 1536 | { 1537 | "category": "lte", 1538 | "description": "should return false when calculating less than or equal to with an unsupported data type", 1539 | "tests": [ 1540 | { "input": null, "query": ["lte", 2, ["array", 1, 2, 3]], "output": false }, 1541 | { 1542 | "input": null, 1543 | "query": ["lte", ["array", 1, 2, 4], ["array", 1, 2, 3]], 1544 | "output": false 1545 | }, 1546 | { "input": null, "query": ["lte", 2, ["object", { "a": 1 }]], "output": false } 1547 | ] 1548 | }, 1549 | { 1550 | "category": "ne", 1551 | "description": "should calculate not equal", 1552 | "tests": [ 1553 | { 1554 | "input": { "a": 1, "b": 2 }, 1555 | "query": ["ne", ["get", "a"], ["get", "b"]], 1556 | "output": true 1557 | }, 1558 | { 1559 | "input": { "a": 2, "b": 2 }, 1560 | "query": ["ne", ["get", "a"], ["get", "b"]], 1561 | "output": false 1562 | }, 1563 | { 1564 | "input": { "a": 3, "b": 2 }, 1565 | "query": ["ne", ["get", "a"], ["get", "b"]], 1566 | "output": true 1567 | }, 1568 | { "input": null, "query": ["ne", 3, 2], "output": true }, 1569 | { "input": null, "query": ["ne", false, false], "output": false }, 1570 | { "input": null, "query": ["ne", null, null], "output": false }, 1571 | { "input": null, "query": ["ne", 0, 0], "output": false }, 1572 | { "input": null, "query": ["ne", "", ""], "output": false } 1573 | ] 1574 | }, 1575 | { 1576 | "category": "ne", 1577 | "description": "should calculate not equal comparing mixed types (no type coercion)", 1578 | "tests": [ 1579 | { "input": null, "query": ["ne", "2", 2], "output": true }, 1580 | { "input": null, "query": ["ne", "", 0], "output": true }, 1581 | { "input": null, "query": ["ne", "", null], "output": true }, 1582 | { "input": null, "query": ["ne", 0, null], "output": true }, 1583 | { "input": null, "query": ["ne", false, null], "output": true }, 1584 | { "input": null, "query": ["ne", ["array", 2], 2], "output": true } 1585 | ] 1586 | }, 1587 | { 1588 | "category": "ne", 1589 | "description": "should calculate (deep) not equal on objects", 1590 | "tests": [ 1591 | { 1592 | "input": null, 1593 | "query": ["ne", ["object", { "a": 2, "b": 3 }], ["object", { "b": 3, "a": 2 }]], 1594 | "output": false 1595 | }, 1596 | { 1597 | "input": null, 1598 | "query": ["ne", ["object", { "a": 2, "b": 3 }], ["object", { "b": 3, "a": 2, "c": 4 }]], 1599 | "output": true 1600 | }, 1601 | { 1602 | "input": null, 1603 | "query": ["ne", ["object", { "a": 2, "b": 3, "c": 4 }], ["object", { "b": 3, "a": 2 }]], 1604 | "output": true 1605 | } 1606 | ] 1607 | }, 1608 | { 1609 | "category": "ne", 1610 | "description": "should calculate (deep) not equal on arrays", 1611 | "tests": [ 1612 | { "input": null, "query": ["ne", ["array", 1, 2, 3], ["array", 1, 2, 3]], "output": false }, 1613 | { "input": null, "query": ["ne", ["array", 1, 2], ["array", 1, 2, 3]], "output": true }, 1614 | { "input": null, "query": ["ne", ["array", 1, 2, 3], ["array", 1, 2]], "output": true } 1615 | ] 1616 | }, 1617 | { 1618 | "category": "ne", 1619 | "description": "should calculate (deep) not equal on nested objects and arrays", 1620 | "tests": [ 1621 | { 1622 | "input": null, 1623 | "query": [ 1624 | "ne", 1625 | ["object", { "arr": ["array", 1, 2, 3] }], 1626 | ["object", { "arr": ["array", 1, 2, 3] }] 1627 | ], 1628 | "output": false 1629 | }, 1630 | { 1631 | "input": null, 1632 | "query": [ 1633 | "ne", 1634 | ["object", { "arr": ["array", 1, 2] }], 1635 | ["object", { "arr": ["array", 1, 2, 3] }] 1636 | ], 1637 | "output": true 1638 | } 1639 | ] 1640 | }, 1641 | { 1642 | "category": "and", 1643 | "description": "should calculate and", 1644 | "tests": [ 1645 | { 1646 | "input": { "a": false, "b": false }, 1647 | "query": ["and", ["get", "a"], ["get", "b"]], 1648 | "output": false 1649 | }, 1650 | { 1651 | "input": { "a": false, "b": true }, 1652 | "query": ["and", ["get", "a"], ["get", "b"]], 1653 | "output": false 1654 | }, 1655 | { 1656 | "input": { "a": true, "b": false }, 1657 | "query": ["and", ["get", "a"], ["get", "b"]], 1658 | "output": false 1659 | }, 1660 | { 1661 | "input": { "a": true, "b": true }, 1662 | "query": ["and", ["get", "a"], ["get", "b"]], 1663 | "output": true 1664 | }, 1665 | { 1666 | "input": { "a": 0, "b": 1 }, 1667 | "query": ["and", ["get", "a"], ["get", "b"]], 1668 | "output": false 1669 | }, 1670 | { 1671 | "input": { "a": 1, "b": 1 }, 1672 | "query": ["and", ["get", "a"], ["get", "b"]], 1673 | "output": true 1674 | }, 1675 | { 1676 | "input": null, 1677 | "query": ["and", true, true], 1678 | "output": true 1679 | } 1680 | ] 1681 | }, 1682 | { 1683 | "category": "and", 1684 | "description": "should calculate and with more than two arguments", 1685 | "tests": [ 1686 | { 1687 | "input": null, 1688 | "query": ["and", true, true, false], 1689 | "output": false 1690 | } 1691 | ] 1692 | }, 1693 | { 1694 | "category": "and", 1695 | "description": "should calculate and with one argument", 1696 | "tests": [ 1697 | { "input": null, "query": ["and", false], "output": false }, 1698 | { "input": null, "query": ["and", true], "output": true } 1699 | ] 1700 | }, 1701 | { 1702 | "category": "and", 1703 | "description": "should return null calculating and with no arguments", 1704 | "tests": [ 1705 | { 1706 | "input": null, 1707 | "query": ["and"], 1708 | "output": null 1709 | } 1710 | ] 1711 | }, 1712 | { 1713 | "category": "or", 1714 | "description": "should calculate or", 1715 | "tests": [ 1716 | { 1717 | "input": { "a": false, "b": false }, 1718 | "query": ["or", ["get", "a"], ["get", "b"]], 1719 | "output": false 1720 | }, 1721 | { 1722 | "input": { "a": false, "b": true }, 1723 | "query": ["or", ["get", "a"], ["get", "b"]], 1724 | "output": true 1725 | }, 1726 | { 1727 | "input": { "a": true, "b": false }, 1728 | "query": ["or", ["get", "a"], ["get", "b"]], 1729 | "output": true 1730 | }, 1731 | { 1732 | "input": { "a": true, "b": true }, 1733 | "query": ["or", ["get", "a"], ["get", "b"]], 1734 | "output": true 1735 | }, 1736 | { 1737 | "input": { "a": 0, "b": 1 }, 1738 | "query": ["or", ["get", "a"], ["get", "b"]], 1739 | "output": true 1740 | }, 1741 | { 1742 | "input": { "a": 1, "b": 1 }, 1743 | "query": ["or", ["get", "a"], ["get", "b"]], 1744 | "output": true 1745 | }, 1746 | { 1747 | "input": null, 1748 | "query": ["or", false, true], 1749 | "output": true 1750 | } 1751 | ] 1752 | }, 1753 | { 1754 | "category": "or", 1755 | "description": "should calculate or with more than two arguments", 1756 | "tests": [ 1757 | { 1758 | "input": null, 1759 | "query": ["or", false, false, true], 1760 | "output": true 1761 | } 1762 | ] 1763 | }, 1764 | { 1765 | "category": "or", 1766 | "description": "should calculate or with one argument", 1767 | "tests": [ 1768 | { "input": null, "query": ["or", false], "output": false }, 1769 | { "input": null, "query": ["or", true], "output": true } 1770 | ] 1771 | }, 1772 | { 1773 | "category": "or", 1774 | "description": "should return null when calculating or with no arguments", 1775 | "tests": [ 1776 | { 1777 | "input": null, 1778 | "query": ["or"], 1779 | "output": null 1780 | } 1781 | ] 1782 | }, 1783 | { 1784 | "category": "not", 1785 | "description": "should calculate not", 1786 | "tests": [ 1787 | { "input": { "a": false }, "query": ["not", ["get", "a"]], "output": true }, 1788 | { "input": { "a": true }, "query": ["not", ["get", "a"]], "output": false }, 1789 | { "input": null, "query": ["not", true], "output": false }, 1790 | { "input": { "a": 1 }, "query": ["not", ["get", "a"]], "output": false }, 1791 | { "input": { "a": 0 }, "query": ["not", ["get", "a"]], "output": true } 1792 | ] 1793 | }, 1794 | { 1795 | "category": "exists", 1796 | "description": "should calculate exists", 1797 | "tests": [ 1798 | { "input": { "a": false }, "query": ["exists", ["get", "a"]], "output": true }, 1799 | { "input": { "a": null }, "query": ["exists", ["get", "a"]], "output": true }, 1800 | { "input": { "a": 2 }, "query": ["exists", ["get", "a"]], "output": true }, 1801 | { "input": { "a": 0 }, "query": ["exists", ["get", "a"]], "output": true }, 1802 | { "input": { "a": "" }, "query": ["exists", ["get", "a"]], "output": true }, 1803 | { 1804 | "input": { "nested": { "a": 2 } }, 1805 | "query": ["exists", ["get", "nested", "a"]], 1806 | "output": true 1807 | }, 1808 | { "input": {}, "query": ["exists", ["get", "a"]], "output": false }, 1809 | { "input": {}, "query": ["exists", ["get", "nested", "a"]], "output": false }, 1810 | { "input": {}, "query": ["exists", ["get", "sort"]], "output": false } 1811 | ] 1812 | }, 1813 | { 1814 | "category": "if", 1815 | "description": "should calculate if", 1816 | "tests": [ 1817 | { 1818 | "input": { 1819 | "status": true, 1820 | "messageOk": "Welcome!", 1821 | "messageFail": "Sorry, you're too young" 1822 | }, 1823 | "query": ["if", ["get", "status"], ["get", "messageOk"], ["get", "messageFail"]], 1824 | "output": "Welcome!" 1825 | }, 1826 | { "input": null, "query": ["if", true, true, false], "output": true }, 1827 | { "input": null, "query": ["if", false, true, false], "output": false }, 1828 | { "input": null, "query": ["if", 1, true, false], "output": true }, 1829 | { "input": null, "query": ["if", 0, true, false], "output": false }, 1830 | { "input": null, "query": ["if", "message", true, false], "output": true }, 1831 | { "input": null, "query": ["if", "", true, false], "output": true }, 1832 | { "input": null, "query": ["if", null, true, false], "output": false } 1833 | ] 1834 | }, 1835 | { 1836 | "category": "in", 1837 | "description": "should calculate in", 1838 | "tests": [ 1839 | { 1840 | "input": { "score": 5 }, 1841 | "query": ["in", ["get", "score"], ["array", 1, 2, 5, 8]], 1842 | "output": true 1843 | }, 1844 | { 1845 | "input": { "score": 7 }, 1846 | "query": ["in", ["get", "score"], ["array", 1, 2, 5, 8]], 1847 | "output": false 1848 | }, 1849 | { "input": null, "query": ["in", 5, ["array", 1, 2, 5, 8]], "output": true }, 1850 | { "input": null, "query": ["in", false, ["array", true, false]], "output": true }, 1851 | { "input": null, "query": ["in", true, ["array", true, false]], "output": true }, 1852 | { "input": null, "query": ["in", false, ["array", true]], "output": false }, 1853 | { "input": null, "query": ["in", true, ["array", false]], "output": false }, 1854 | { "input": null, "query": ["in", null, ["array", 1, 2, 3]], "output": false }, 1855 | { "input": null, "query": ["in", null, ["array", null, 1, 2, 3]], "output": true }, 1856 | { "input": null, "query": ["in", "", ["array", "A", "", "B"]], "output": true }, 1857 | { "input": null, "query": ["in", 0, ["array", 1, 2, 3]], "output": false }, 1858 | { "input": null, "query": ["in", 0, ["array", 0, 1, 2, 3]], "output": true } 1859 | ] 1860 | }, 1861 | { 1862 | "category": "in", 1863 | "description": "should calculate in finding a string", 1864 | "tests": [ 1865 | { "input": null, "query": ["in", "b", ["array", "a", "b", "c"]], "output": true }, 1866 | { "input": null, "query": ["in", "d", ["array", "a", "b", "c"]], "output": false }, 1867 | { "input": null, "query": ["in", "A", ["array", "a", "b", "c"]], "output": false } 1868 | ] 1869 | }, 1870 | { 1871 | "category": "in", 1872 | "description": "should calculate in finding an object", 1873 | "tests": [ 1874 | { 1875 | "input": null, 1876 | "query": [ 1877 | "in", 1878 | ["object", { "a": 1, "b": 2 }], 1879 | [ 1880 | "array", 1881 | ["object", { "b": 2 }], 1882 | ["object", { "a": 1 }], 1883 | ["object", { "a": 1, "b": 2 }] 1884 | ] 1885 | ], 1886 | "output": true 1887 | } 1888 | ] 1889 | }, 1890 | { 1891 | "category": "in", 1892 | "description": "should calculate in finding an object", 1893 | "tests": [ 1894 | { 1895 | "input": null, 1896 | "query": [ 1897 | "in", 1898 | ["object", { "a": 1, "b": 3 }], 1899 | [ 1900 | "array", 1901 | ["object", { "b": 2 }], 1902 | ["object", { "a": 1 }], 1903 | ["object", { "a": 1, "b": 2 }] 1904 | ] 1905 | ], 1906 | "output": false 1907 | } 1908 | ] 1909 | }, 1910 | { 1911 | "category": "not in", 1912 | "description": "should calculate not in", 1913 | "tests": [ 1914 | { 1915 | "input": { "score": 5 }, 1916 | "query": ["not in", ["get", "score"], ["array", 1, 2, 5, 8]], 1917 | "output": false 1918 | }, 1919 | { 1920 | "input": { "score": 7 }, 1921 | "query": ["not in", ["get", "score"], ["array", 1, 2, 5, 8]], 1922 | "output": true 1923 | }, 1924 | { "input": null, "query": ["not in", 7, ["array", 1, 2, 5, 8]], "output": true }, 1925 | { "input": null, "query": ["not in", true, ["array", true, false]], "output": false }, 1926 | { "input": null, "query": ["not in", false, ["array", true]], "output": true }, 1927 | { "input": null, "query": ["not in", true, ["array", false]], "output": true }, 1928 | { "input": null, "query": ["not in", null, ["array", 1, 2, 3]], "output": true }, 1929 | { "input": null, "query": ["not in", null, ["array", null, 1, 2, 3]], "output": false }, 1930 | { "input": null, "query": ["not in", "", ["array", "A", "", "B"]], "output": false }, 1931 | { "input": null, "query": ["not in", 0, ["array", 1, 2, 3]], "output": true }, 1932 | { "input": null, "query": ["not in", 0, ["array", 0, 1, 2, 3]], "output": false } 1933 | ] 1934 | }, 1935 | { 1936 | "category": "not in", 1937 | "description": "should calculate not in finding a string", 1938 | "tests": [ 1939 | { "input": null, "query": ["not in", "b", ["array", "a", "b", "c"]], "output": false }, 1940 | { "input": null, "query": ["not in", "d", ["array", "a", "b", "c"]], "output": true }, 1941 | { "input": null, "query": ["not in", "A", ["array", "a", "b", "c"]], "output": true } 1942 | ] 1943 | }, 1944 | { 1945 | "category": "not in", 1946 | "description": "should calculate not in finding an object", 1947 | "tests": [ 1948 | { 1949 | "input": null, 1950 | "query": [ 1951 | "not in", 1952 | ["object", { "a": 1, "b": 2 }], 1953 | [ 1954 | "array", 1955 | ["object", { "b": 2 }], 1956 | ["object", { "a": 1 }], 1957 | ["object", { "a": 1, "b": 2 }] 1958 | ] 1959 | ], 1960 | "output": false 1961 | } 1962 | ] 1963 | }, 1964 | { 1965 | "category": "not in", 1966 | "description": "should calculate not in finding an object", 1967 | "tests": [ 1968 | { 1969 | "input": null, 1970 | "query": [ 1971 | "not in", 1972 | ["object", { "a": 1, "b": 3 }], 1973 | [ 1974 | "array", 1975 | ["object", { "b": 2 }], 1976 | ["object", { "a": 1 }], 1977 | ["object", { "a": 1, "b": 2 }] 1978 | ] 1979 | ], 1980 | "output": true 1981 | } 1982 | ] 1983 | }, 1984 | { 1985 | "category": "regex", 1986 | "description": "should calculate a regex without flags", 1987 | "tests": [ 1988 | { 1989 | "input": [{ "name": "Joe" }, { "name": "Oliver42" }, { "name": "Sarah" }], 1990 | "query": ["filter", ["regex", ["get", "name"], "^[A-z]{2,4}$"]], 1991 | "output": [{ "name": "Joe" }] 1992 | } 1993 | ] 1994 | }, 1995 | { 1996 | "category": "regex", 1997 | "description": "should calculate a regex with flags", 1998 | "tests": [ 1999 | { 2000 | "input": [{ "name": "Joe" }, { "name": "Oliver42" }, { "name": "Sarah" }], 2001 | "query": ["filter", ["regex", ["get", "name"], "^[a-z]+$", "i"]], 2002 | "output": [{ "name": "Joe" }, { "name": "Sarah" }] 2003 | } 2004 | ] 2005 | }, 2006 | { 2007 | "category": "regex", 2008 | "description": "should calculate a regex with a static text", 2009 | "tests": [ 2010 | { 2011 | "input": null, 2012 | "query": ["regex", "Joe", "^[A-z]+$"], 2013 | "output": true 2014 | } 2015 | ] 2016 | }, 2017 | { 2018 | "category": "add", 2019 | "description": "should add two properties", 2020 | "tests": [ 2021 | { 2022 | "input": { "a": 6, "b": 2 }, 2023 | "query": ["add", ["get", "a"], ["get", "b"]], 2024 | "output": 8 2025 | } 2026 | ] 2027 | }, 2028 | { 2029 | "category": "add", 2030 | "description": "should add two numbers", 2031 | "tests": [{ "input": null, "query": ["add", 6, 2], "output": 8 }] 2032 | }, 2033 | { 2034 | "category": "add", 2035 | "description": "should concatenate two strings", 2036 | "tests": [ 2037 | { 2038 | "input": null, 2039 | "query": ["add", "a", "b"], 2040 | "output": "ab" 2041 | } 2042 | ] 2043 | }, 2044 | { 2045 | "category": "add", 2046 | "description": "should concatenate a string and a number", 2047 | "tests": [ 2048 | { 2049 | "input": null, 2050 | "query": ["add", "a", 2], 2051 | "output": "a2" 2052 | } 2053 | ] 2054 | }, 2055 | { 2056 | "category": "add", 2057 | "description": "should concatenate a number and a string", 2058 | "tests": [ 2059 | { 2060 | "input": null, 2061 | "query": ["add", 2, "a"], 2062 | "output": "2a" 2063 | } 2064 | ] 2065 | }, 2066 | { 2067 | "category": "add", 2068 | "description": "should concatenate a string and a boolean", 2069 | "tests": [ 2070 | { 2071 | "input": null, 2072 | "query": ["add", "is:", true], 2073 | "output": "is:true" 2074 | } 2075 | ] 2076 | }, 2077 | { 2078 | "category": "subtract", 2079 | "description": "should subtract two properties", 2080 | "tests": [ 2081 | { 2082 | "input": { "a": 6, "b": 2 }, 2083 | "query": ["subtract", ["get", "a"], ["get", "b"]], 2084 | "output": 4 2085 | } 2086 | ] 2087 | }, 2088 | { 2089 | "category": "subtract", 2090 | "description": "should subtract two numbers", 2091 | "tests": [ 2092 | { 2093 | "input": null, 2094 | "query": ["subtract", 6, 2], 2095 | "output": 4 2096 | } 2097 | ] 2098 | }, 2099 | { 2100 | "category": "multiply", 2101 | "description": "should multiply two properties", 2102 | "tests": [ 2103 | { 2104 | "input": { "a": 6, "b": 2 }, 2105 | "query": ["multiply", ["get", "a"], ["get", "b"]], 2106 | "output": 12 2107 | } 2108 | ] 2109 | }, 2110 | { 2111 | "category": "multiply", 2112 | "description": "should multiply two numbers", 2113 | "tests": [ 2114 | { 2115 | "input": null, 2116 | "query": ["multiply", 6, 2], 2117 | "output": 12 2118 | } 2119 | ] 2120 | }, 2121 | { 2122 | "category": "divide", 2123 | "description": "should divide two properties", 2124 | "tests": [ 2125 | { 2126 | "input": { "a": 6, "b": 2 }, 2127 | "query": ["divide", ["get", "a"], ["get", "b"]], 2128 | "output": 3 2129 | } 2130 | ] 2131 | }, 2132 | { 2133 | "category": "divide", 2134 | "description": "should divide two numbers", 2135 | "tests": [ 2136 | { 2137 | "input": null, 2138 | "query": ["divide", 6, 2], 2139 | "output": 3 2140 | } 2141 | ] 2142 | }, 2143 | { 2144 | "category": "pow", 2145 | "description": "should calculate the exponent of two properties", 2146 | "tests": [ 2147 | { 2148 | "input": { "a": 6, "b": 2 }, 2149 | "query": ["pow", ["get", "a"], ["get", "b"]], 2150 | "output": 36 2151 | } 2152 | ] 2153 | }, 2154 | { 2155 | "category": "pow", 2156 | "description": "should calculate the exponent of two numbers", 2157 | "tests": [{ "input": null, "query": ["pow", 6, 2], "output": 36 }] 2158 | }, 2159 | { 2160 | "category": "pow", 2161 | "description": "should calculate the square root using pow", 2162 | "tests": [ 2163 | { 2164 | "input": 25, 2165 | "query": ["pow", ["get"], 0.5], 2166 | "output": 5 2167 | } 2168 | ] 2169 | }, 2170 | { 2171 | "category": "mod", 2172 | "description": "should calculate the remainder (the modulus) of two properties", 2173 | "tests": [ 2174 | { 2175 | "input": { "a": 8, "b": 3 }, 2176 | "query": ["mod", ["get", "a"], ["get", "b"]], 2177 | "output": 2 2178 | } 2179 | ] 2180 | }, 2181 | { 2182 | "category": "mod", 2183 | "description": "should calculate the remainder (the modulus) of two numbers", 2184 | "tests": [{ "input": null, "query": ["mod", 8, 3], "output": 2 }] 2185 | }, 2186 | { 2187 | "category": "abs", 2188 | "description": "should calculate the absolute value", 2189 | "tests": [ 2190 | { "input": { "a": -3 }, "query": ["abs", ["get", "a"]], "output": 3 }, 2191 | { "input": { "a": 3 }, "query": ["abs", ["get", "a"]], "output": 3 }, 2192 | { "input": null, "query": ["abs", -5], "output": 5 } 2193 | ] 2194 | }, 2195 | { 2196 | "category": "round", 2197 | "description": "should round a property", 2198 | "tests": [ 2199 | { "input": { "a": 23.1345 }, "query": ["round", ["get", "a"]], "output": 23 }, 2200 | { "input": { "a": 23.761 }, "query": ["round", ["get", "a"]], "output": 24 } 2201 | ] 2202 | }, 2203 | { 2204 | "category": "round", 2205 | "description": "should round a property to two digits", 2206 | "tests": [ 2207 | { 2208 | "input": { "a": 23.1348 }, 2209 | "query": ["round", ["get", "a"], 2], 2210 | "output": 23.13 2211 | } 2212 | ] 2213 | }, 2214 | { 2215 | "category": "round", 2216 | "description": "should round a property to three digits", 2217 | "tests": [ 2218 | { 2219 | "input": { "a": 23.1348 }, 2220 | "query": ["round", ["get", "a"], 3], 2221 | "output": 23.135 2222 | } 2223 | ] 2224 | }, 2225 | { 2226 | "category": "round", 2227 | "description": "should round a number to two digits", 2228 | "tests": [ 2229 | { 2230 | "input": null, 2231 | "query": ["round", 23.1348, 2], 2232 | "output": 23.13 2233 | } 2234 | ] 2235 | }, 2236 | { 2237 | "category": "number", 2238 | "description": "should convert a string into a number", 2239 | "tests": [ 2240 | { "input": "2.3", "query": ["number", ["get"]], "output": 2.3 }, 2241 | { "input": "-2.3e-4", "query": ["number", ["get"]], "output": -2.3e-4 }, 2242 | { "input": "-2.3E+4", "query": ["number", ["get"]], "output": -2.3e4 }, 2243 | { "input": "1e4", "query": ["number", ["get"]], "output": 1e4 } 2244 | ] 2245 | }, 2246 | { 2247 | "category": "number", 2248 | "description": "should convert a string containing padding into a number", 2249 | "tests": [ 2250 | { 2251 | "input": " 123 ", 2252 | "query": ["number", ["get"]], 2253 | "output": 123 2254 | } 2255 | ] 2256 | }, 2257 | { 2258 | "category": "number", 2259 | "description": "should convert a string null when it doesn't contain a valid numeric value", 2260 | "tests": [ 2261 | { "input": " foo ", "query": ["number", ["get"]], "output": null }, 2262 | { "input": "2.4 foo", "query": ["number", ["get"]], "output": null } 2263 | ] 2264 | }, 2265 | { 2266 | "category": "string", 2267 | "description": "should convert a number into a string", 2268 | "tests": [ 2269 | { "input": 2.4, "query": ["string", ["get"]], "output": "2.4" }, 2270 | { "input": -24000, "query": ["string", ["get"]], "output": "-24000" } 2271 | ] 2272 | }, 2273 | { 2274 | "category": "string", 2275 | "description": "should convert a boolean into a string", 2276 | "tests": [ 2277 | { "input": false, "query": ["string", ["get"]], "output": "false" }, 2278 | { "input": true, "query": ["string", ["get"]], "output": "true" } 2279 | ] 2280 | }, 2281 | { 2282 | "category": "string", 2283 | "description": "should convert null into a string", 2284 | "tests": [ 2285 | { 2286 | "input": null, 2287 | "query": ["string", ["get"]], 2288 | "output": "null" 2289 | } 2290 | ] 2291 | }, 2292 | { 2293 | "category": "string", 2294 | "description": "should convert a string into a string", 2295 | "tests": [ 2296 | { 2297 | "input": "Hi", 2298 | "query": ["string", ["get"]], 2299 | "output": "Hi" 2300 | } 2301 | ] 2302 | }, 2303 | { 2304 | "category": "composed query", 2305 | "description": "should filter using and, gte, and lte", 2306 | "tests": [ 2307 | { 2308 | "input": [ 2309 | { "age": 23 }, 2310 | { "age": 19 }, 2311 | { "age": 32 }, 2312 | { "age": 19 }, 2313 | { "age": 27 }, 2314 | { "age": 45 }, 2315 | { "age": 31 }, 2316 | { "age": 25 } 2317 | ], 2318 | "query": ["filter", ["and", ["gte", ["get", "age"], 23], ["lte", ["get", "age"], 27]]], 2319 | "output": [{ "age": 23 }, { "age": 27 }, { "age": 25 }] 2320 | } 2321 | ] 2322 | }, 2323 | { 2324 | "category": "composed query", 2325 | "description": "should create an object containing pipelines and various functions", 2326 | "tests": [ 2327 | { 2328 | "input": [ 2329 | { "name": "Chris", "age": 23, "city": "New York" }, 2330 | { "name": "Emily", "age": 19, "city": "Atlanta" }, 2331 | { "name": "Joe", "age": 32, "city": "New York" }, 2332 | { "name": "Kevin", "age": 19, "city": "Atlanta" }, 2333 | { "name": "Michelle", "age": 27, "city": "Los Angeles" }, 2334 | { "name": "Robert", "age": 45, "city": "Manhattan" }, 2335 | { "name": "Sarah", "age": 31, "city": "New York" } 2336 | ], 2337 | "query": [ 2338 | "object", 2339 | { 2340 | "names": ["map", ["get", "name"]], 2341 | "count": ["size"], 2342 | "averageAge": ["pipe", ["map", ["get", "age"]], ["average"]] 2343 | } 2344 | ], 2345 | "output": { 2346 | "names": ["Chris", "Emily", "Joe", "Kevin", "Michelle", "Robert", "Sarah"], 2347 | "count": 7, 2348 | "averageAge": 28 2349 | } 2350 | } 2351 | ] 2352 | }, 2353 | { 2354 | "category": "composed query", 2355 | "description": "should process multiple operations", 2356 | "tests": [ 2357 | { 2358 | "input": { 2359 | "friends": [ 2360 | { "name": "Chris", "age": 23, "city": "New York" }, 2361 | { "name": "Emily", "age": 19, "city": "Atlanta" }, 2362 | { "name": "Joe", "age": 32, "city": "New York" }, 2363 | { "name": "Kevin", "age": 19, "city": "Atlanta" }, 2364 | { "name": "Michelle", "age": 27, "city": "Los Angeles" }, 2365 | { "name": "Robert", "age": 45, "city": "Manhattan" }, 2366 | { "name": "Sarah", "age": 31, "city": "New York" } 2367 | ] 2368 | }, 2369 | "query": [ 2370 | "pipe", 2371 | ["get", "friends"], 2372 | ["filter", ["eq", ["get", "city"], "New York"]], 2373 | ["sort", ["get", "age"]], 2374 | ["map", ["get", "name"]], 2375 | ["limit", 2] 2376 | ], 2377 | "output": ["Chris", "Sarah"] 2378 | } 2379 | ] 2380 | }, 2381 | { 2382 | "category": "composed query", 2383 | "description": "should use functions to calculate a shopping cart", 2384 | "tests": [ 2385 | { 2386 | "input": [ 2387 | { "name": "bread", "price": 2.5, "quantity": 2 }, 2388 | { "name": "milk", "price": 1.2, "quantity": 3 } 2389 | ], 2390 | "query": ["pipe", ["map", ["multiply", ["get", "price"], ["get", "quantity"]]], ["sum"]], 2391 | "output": 8.6 2392 | } 2393 | ] 2394 | } 2395 | ] 2396 | } 2397 | -------------------------------------------------------------------------------- /test-suite/compile.test.schema.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "http://json-schema.org/draft-07/schema", 3 | "$id": "https://github.com/jsonquerylang/jsonquery/blob/v5.0.3/test-suite/compile.test.schema.json", 4 | "type": "object", 5 | "properties": { 6 | "source": { 7 | "const": "https://github.com/jsonquerylang/jsonquery/blob/v5.0.3/test-suite/compile.test.json" 8 | }, 9 | "version": { 10 | "const": "5.0.3" 11 | }, 12 | "groups": { 13 | "type": "array", 14 | "items": { 15 | "type": "object", 16 | "properties": { 17 | "category": { "type": "string" }, 18 | "description": { "type": "string" }, 19 | "tests": { 20 | "type": "array", 21 | "items": { 22 | "oneOf": [ 23 | { 24 | "type": "object", 25 | "properties": { 26 | "input": {}, 27 | "query": {}, 28 | "output": {} 29 | }, 30 | "required": ["input", "query", "output"], 31 | "additionalProperties": false 32 | }, 33 | { 34 | "type": "object", 35 | "properties": { 36 | "input": {}, 37 | "query": {}, 38 | "throws": { "type": "string" } 39 | }, 40 | "required": ["input", "query", "throws"], 41 | "additionalProperties": false 42 | } 43 | ] 44 | } 45 | }, 46 | "query": {}, 47 | "output": {} 48 | }, 49 | "required": ["category", "description", "tests"], 50 | "additionalProperties": false 51 | } 52 | } 53 | }, 54 | "required": ["source", "version", "groups"], 55 | "additionalProperties": false 56 | } 57 | -------------------------------------------------------------------------------- /test-suite/parse.test.d.ts: -------------------------------------------------------------------------------- 1 | import type { JSONQuery } from '../src/types' 2 | 3 | export interface ParseTest { 4 | input: string 5 | output: JSONQuery 6 | } 7 | 8 | export interface ParseTestException { 9 | input: string 10 | throws: string 11 | } 12 | 13 | export interface ParseTestGroup { 14 | category: string 15 | description: string 16 | tests: Array 17 | } 18 | 19 | export interface ParseTestSuite { 20 | updated: string 21 | groups: ParseTestGroup[] 22 | } 23 | -------------------------------------------------------------------------------- /test-suite/parse.test.json: -------------------------------------------------------------------------------- 1 | { 2 | "source": "https://github.com/jsonquerylang/jsonquery/blob/v5.0.0/test-suite/parse.test.json", 3 | "version": "5.0.0", 4 | "groups": [ 5 | { 6 | "category": "property", 7 | "description": "should parse a property without quotes", 8 | "tests": [ 9 | { "input": ".name", "output": ["get", "name"] }, 10 | { "input": ".AaZz_$", "output": ["get", "AaZz_$"] }, 11 | { "input": ".AaZz09_$", "output": ["get", "AaZz09_$"] }, 12 | { "input": ".9", "output": ["get", 9] }, 13 | { "input": ".123", "output": ["get", 123] }, 14 | { "input": ".0", "output": ["get", 0] }, 15 | { "input": " .name ", "output": ["get", "name"] } 16 | ] 17 | }, 18 | { 19 | "category": "property", 20 | "description": "should throw an error in case of an invalid unquoted property", 21 | "tests": [ 22 | { "input": ".", "throws": "Property expected (pos: 1)" }, 23 | { "input": ".01", "throws": "Unexpected part '1'" }, 24 | { "input": ".1abc", "throws": "Unexpected part 'abc'" }, 25 | { "input": ".[", "throws": "Property expected (pos: 1)" }, 26 | { "input": ".foo#", "throws": "Unexpected part '#'" }, 27 | { "input": ".foo#bar", "throws": "Unexpected part '#bar'" } 28 | ] 29 | }, 30 | { 31 | "category": "property", 32 | "description": "should parse a property with quotes", 33 | "tests": [ 34 | { "input": ".\"name\"", "output": ["get", "name"] }, 35 | { "input": " .\"name\" ", "output": ["get", "name"] }, 36 | { "input": ".\"escape \\n \\\"chars\"", "output": ["get", "escape \n \"chars"] } 37 | ] 38 | }, 39 | { 40 | "category": "property", 41 | "description": "should parse a nested property", 42 | "tests": [ 43 | { "input": ".address.city", "output": ["get", "address", "city"] }, 44 | { "input": ".\"address\".\"city\"", "output": ["get", "address", "city"] }, 45 | { "input": ".\"address\".\"city\"", "output": ["get", "address", "city"] }, 46 | { "input": ".array.2", "output": ["get", "array", 2] } 47 | ] 48 | }, 49 | { 50 | "category": "property", 51 | "description": "should throw an error when a property misses an end quote", 52 | "tests": [{ "input": ".\"name", "throws": "Property expected (pos: 1)" }] 53 | }, 54 | { 55 | "category": "property", 56 | "description": "should throw an error when there is whitespace between the dot and the property name", 57 | "tests": [ 58 | { "input": ". \"name\"", "throws": "Property expected (pos: 1)" }, 59 | { "input": ".\"address\" .\"city\"", "throws": "Unexpected part '.\"city\"' (pos: 11)" }, 60 | { "input": ".address .city", "throws": "Unexpected part '.city' (pos: 9)" } 61 | ] 62 | }, 63 | { 64 | "category": "function", 65 | "description": "should parse a function without arguments", 66 | "tests": [ 67 | { "input": "sort()", "output": ["sort"] }, 68 | { "input": "sort( )", "output": ["sort"] }, 69 | { "input": "sort ( )", "output": ["sort"] }, 70 | { "input": " sort ( ) ", "output": ["sort"] } 71 | ] 72 | }, 73 | { 74 | "category": "function", 75 | "description": "should parse a function with one argument", 76 | "tests": [ 77 | { "input": "sort(.age)", "output": ["sort", ["get", "age"]] }, 78 | { "input": "sort(get())", "output": ["sort", ["get"]] }, 79 | { "input": "sort ( .age ) ", "output": ["sort", ["get", "age"]] } 80 | ] 81 | }, 82 | { 83 | "category": "function", 84 | "description": "should parse a function with multiple arguments", 85 | "tests": [ 86 | { "input": "sort(.age, \"desc\")", "output": ["sort", ["get", "age"], "desc"] }, 87 | { "input": "sort(get(), \"desc\")", "output": ["sort", ["get"], "desc"] } 88 | ] 89 | }, 90 | { 91 | "category": "function", 92 | "description": "should throw an error when the end bracket is missing", 93 | "tests": [{ "input": "sort(.age, \"desc\"", "throws": "Character ')' expected (pos: 17)" }] 94 | }, 95 | { 96 | "category": "function", 97 | "description": "should throw an error when a comma is missing", 98 | "tests": [{ "input": "sort(.age \"desc\")", "throws": "Character ',' expected (pos: 10)" }] 99 | }, 100 | { 101 | "category": "operator", 102 | "description": "should parse all operators", 103 | "tests": [ 104 | { "input": ".score==8", "output": ["eq", ["get", "score"], 8] }, 105 | { "input": ".score == 8", "output": ["eq", ["get", "score"], 8] }, 106 | { "input": ".score < 8", "output": ["lt", ["get", "score"], 8] }, 107 | { "input": ".score <= 8", "output": ["lte", ["get", "score"], 8] }, 108 | { "input": ".score > 8", "output": ["gt", ["get", "score"], 8] }, 109 | { "input": ".score >= 8", "output": ["gte", ["get", "score"], 8] }, 110 | { "input": ".score != 8", "output": ["ne", ["get", "score"], 8] }, 111 | { "input": ".score + 8", "output": ["add", ["get", "score"], 8] }, 112 | { "input": ".score - 8", "output": ["subtract", ["get", "score"], 8] }, 113 | { "input": ".score * 8", "output": ["multiply", ["get", "score"], 8] }, 114 | { "input": ".score / 8", "output": ["divide", ["get", "score"], 8] }, 115 | { "input": ".score ^ 8", "output": ["pow", ["get", "score"], 8] }, 116 | { "input": ".score % 8", "output": ["mod", ["get", "score"], 8] }, 117 | { "input": ".a and .b", "output": ["and", ["get", "a"], ["get", "b"]] }, 118 | { "input": ".a or .b", "output": ["or", ["get", "a"], ["get", "b"]] }, 119 | { 120 | "input": ".name in [\"Joe\", \"Sarah\"]", 121 | "output": ["in", ["get", "name"], ["array", "Joe", "Sarah"]] 122 | }, 123 | { 124 | "input": ".name not in [\"Joe\", \"Sarah\"]", 125 | "output": ["not in", ["get", "name"], ["array", "Joe", "Sarah"]] 126 | } 127 | ] 128 | }, 129 | { 130 | "category": "operator", 131 | "description": "should parse an operator having the same name as a function", 132 | "tests": [ 133 | { "input": "0 and 1", "output": ["and", 0, 1] }, 134 | { "input": ".a and .b", "output": ["and", ["get", "a"], ["get", "b"]] } 135 | ] 136 | }, 137 | { 138 | "category": "operator", 139 | "description": "should parse nested operators", 140 | "tests": [ 141 | { 142 | "input": "(.a == \"A\") and (.b == \"B\")", 143 | "output": ["and", ["eq", ["get", "a"], "A"], ["eq", ["get", "b"], "B"]] 144 | }, 145 | { 146 | "input": "(.a == \"A\") or (.b == \"B\")", 147 | "output": ["or", ["eq", ["get", "a"], "A"], ["eq", ["get", "b"], "B"]] 148 | }, 149 | { 150 | "input": "(.a * 2) + 3", 151 | "output": ["add", ["multiply", ["get", "a"], 2], 3] 152 | }, 153 | { 154 | "input": "3 + (.a * 2)", 155 | "output": ["add", 3, ["multiply", ["get", "a"], 2]] 156 | } 157 | ] 158 | }, 159 | { 160 | "category": "operator", 161 | "description": "should parse operators and and or with more than two arguments", 162 | "tests": [ 163 | { "input": "1 and 2 and 3", "output": ["and", 1, 2, 3] }, 164 | { "input": "1 or 2 or 3", "output": ["or", 1, 2, 3] }, 165 | { "input": "1 * 2 * 3", "output": ["multiply", ["multiply", 1, 2], 3] }, 166 | { "input": "1 / 2 / 3", "output": ["divide", ["divide", 1, 2], 3] }, 167 | { "input": "1 % 2 % 3", "output": ["mod", ["mod", 1, 2], 3] }, 168 | { "input": "1 + 2 + 3", "output": ["add", ["add", 1, 2], 3] }, 169 | { "input": "1 - 2 - 3", "output": ["subtract", ["subtract", 1, 2], 3] } 170 | ] 171 | }, 172 | { 173 | "category": "operator", 174 | "description": "should throw when chaining operators without vararg support", 175 | "tests": [ 176 | { "input": "1 ^ 2 ^ 3", "throws": "Unexpected part '^ 3'" }, 177 | { "input": "1 == 2 == 3", "throws": "Unexpected part '== 3'" }, 178 | { "input": "1 != 2 != 3", "throws": "Unexpected part '!= 3'" }, 179 | { "input": "1 < 2 < 3", "throws": "Unexpected part '< 3'" }, 180 | { "input": "1 <= 2 <= 3", "throws": "Unexpected part '<= 3'" }, 181 | { "input": "1 > 2 > 3", "throws": "Unexpected part '> 3'" }, 182 | { "input": "1 >= 2 >= 3", "throws": "Unexpected part '>= 3'" }, 183 | { "input": "1 == 2 == 3", "throws": "Unexpected part '== 3'" } 184 | ] 185 | }, 186 | { 187 | "category": "operator", 188 | "description": "should parse operators with the same precedence", 189 | "tests": [ 190 | { "input": "2 * 3 / 4", "output": ["divide", ["multiply", 2, 3], 4] }, 191 | { "input": "2 / 3 * 4", "output": ["multiply", ["divide", 2, 3], 4] }, 192 | { "input": "2 * 3 % 4", "output": ["mod", ["multiply", 2, 3], 4] }, 193 | { "input": "2 % 3 * 4", "output": ["multiply", ["mod", 2, 3], 4] }, 194 | { "input": "2 + 3 - 4", "output": ["subtract", ["add", 2, 3], 4] }, 195 | { "input": "2 - 3 + 4", "output": ["add", ["subtract", 2, 3], 4] } 196 | ] 197 | }, 198 | { 199 | "category": "operator", 200 | "description": "should parse operators with differing precedence", 201 | "tests": [ 202 | { "input": "2 ^ 3 * 4", "output": ["multiply", ["pow", 2, 3], 4] }, 203 | { "input": "2 * 3 ^ 4", "output": ["multiply", 2, ["pow", 3, 4]] }, 204 | { "input": "2 * 3 + 4", "output": ["add", ["multiply", 2, 3], 4] }, 205 | { "input": "2 + 3 * 4", "output": ["add", 2, ["multiply", 3, 4]] }, 206 | { "input": "2 + 3 > 4", "output": ["gt", ["add", 2, 3], 4] }, 207 | { "input": "2 > 3 + 4", "output": ["gt", 2, ["add", 3, 4]] }, 208 | { "input": "2 > 3 == 4", "output": ["eq", ["gt", 2, 3], 4] }, 209 | { "input": "2 == 3 > 4", "output": ["eq", 2, ["gt", 3, 4]] }, 210 | { "input": "2 == 3 and 4", "output": ["and", ["eq", 2, 3], 4] }, 211 | { "input": "2 and 3 == 4", "output": ["and", 2, ["eq", 3, 4]] }, 212 | { "input": "2 and 3 or 4", "output": ["or", ["and", 2, 3], 4] }, 213 | { "input": "2 or 3 and 4", "output": ["or", 2, ["and", 3, 4]] }, 214 | { "input": "2 > 3 and 4", "output": ["and", ["gt", 2, 3], 4] }, 215 | { "input": "2 and 3 > 4", "output": ["and", 2, ["gt", 3, 4]] }, 216 | { "input": "2 or 3 | 4", "output": ["pipe", ["or", 2, 3], 4] }, 217 | { "input": "2 | 3 or 4", "output": ["pipe", 2, ["or", 3, 4]] } 218 | ] 219 | }, 220 | { 221 | "category": "operator", 222 | "description": "should override operator precedence using parenthesis", 223 | "tests": [ 224 | { "input": "2 + 3 * 4", "output": ["add", 2, ["multiply", 3, 4]] }, 225 | { "input": "2 + (3 * 4)", "output": ["add", 2, ["multiply", 3, 4]] }, 226 | { "input": "(2 + 3) * 4", "output": ["multiply", ["add", 2, 3], 4] }, 227 | { "input": "2 * (3 + 4)", "output": ["multiply", 2, ["add", 3, 4]] } 228 | ] 229 | }, 230 | { 231 | "category": "operator", 232 | "description": "should keep the structure based on parenthesis", 233 | "tests": [ 234 | { "input": "(2 * 3) * 4", "output": ["multiply", ["multiply", 2, 3], 4] }, 235 | { 236 | "input": "((2 * 3) * 4) * 5", 237 | "output": ["multiply", ["multiply", ["multiply", 2, 3], 4], 5] 238 | }, 239 | { 240 | "input": "(2 * 3) * (4 * 5)", 241 | "output": ["multiply", ["multiply", 2, 3], ["multiply", 4, 5]] 242 | }, 243 | { "input": "2 * (3 * 4)", "output": ["multiply", 2, ["multiply", 3, 4]] }, 244 | { "input": "(2 + 3) + 4", "output": ["add", ["add", 2, 3], 4] }, 245 | { "input": "2 + (3 + 4)", "output": ["add", 2, ["add", 3, 4]] }, 246 | { "input": "(2 - 3) - 4", "output": ["subtract", ["subtract", 2, 3], 4] }, 247 | { "input": "2 - (3 - 4)", "output": ["subtract", 2, ["subtract", 3, 4]] } 248 | ] 249 | }, 250 | { 251 | "category": "operator", 252 | "description": "should throw an error in case of an unknown operator", 253 | "tests": [ 254 | { "input": ".a === \"A\"", "throws": "Value expected (pos: 5)" }, 255 | { "input": ".a <> \"A\"", "throws": "Value expected (pos: 4)" } 256 | ] 257 | }, 258 | { 259 | "category": "operator", 260 | "description": "should throw an error in case a missing right hand side", 261 | "tests": [{ "input": ".a ==", "throws": "Value expected (pos: 5)" }] 262 | }, 263 | { 264 | "category": "operator", 265 | "description": "should throw an error in case a missing left and right hand side", 266 | "tests": [ 267 | { "input": "+", "throws": "Value expected (pos: 0)" }, 268 | { "input": " +", "throws": "Value expected (pos: 1)" } 269 | ] 270 | }, 271 | { 272 | "category": "pipe", 273 | "description": "should parse a pipe", 274 | "tests": [ 275 | { 276 | "input": ".friends | sort(.age)", 277 | "output": ["pipe", ["get", "friends"], ["sort", ["get", "age"]]] 278 | }, 279 | { 280 | "input": ".friends | sort(.age) | filter(.age >= 18)", 281 | "output": [ 282 | "pipe", 283 | ["get", "friends"], 284 | ["sort", ["get", "age"]], 285 | ["filter", ["gte", ["get", "age"], 18]] 286 | ] 287 | } 288 | ] 289 | }, 290 | { 291 | "category": "pipe", 292 | "description": "should throw an error when a value is missing after a pipe", 293 | "tests": [{ "input": ".friends |", "throws": "Value expected (pos: 10)" }] 294 | }, 295 | { 296 | "category": "pipe", 297 | "description": "should throw an error when a value is missing before a pipe", 298 | "tests": [{ "input": "| .friends ", "throws": "Value expected (pos: 0)" }] 299 | }, 300 | { 301 | "category": "parenthesis", 302 | "description": "should parse parenthesis", 303 | "tests": [ 304 | { "input": "(.friends)", "output": ["get", "friends"] }, 305 | { "input": "( .friends)", "output": ["get", "friends"] }, 306 | { "input": "(.friends )", "output": ["get", "friends"] }, 307 | { "input": "(.age == 18)", "output": ["eq", ["get", "age"], 18] }, 308 | { "input": "(42)", "output": 42 }, 309 | { "input": " ( 42 ) ", "output": 42 }, 310 | { "input": "((42))", "output": 42 } 311 | ] 312 | }, 313 | { 314 | "category": "parenthesis", 315 | "description": "should throw an error when missing closing parenthesis", 316 | "tests": [{ "input": "(.friends", "throws": "Character ')' expected (pos: 9)" }] 317 | }, 318 | { 319 | "category": "object", 320 | "description": "should parse an object", 321 | "tests": [ 322 | { "input": "{}", "output": ["object", {}] }, 323 | { "input": "{ }", "output": ["object", {}] }, 324 | { "input": "{a:1}", "output": ["object", { "a": 1 }] }, 325 | { "input": "{a1:1}", "output": ["object", { "a1": 1 }] }, 326 | { "input": "{AaZz_$019:1}", "output": ["object", { "AaZz_$019": 1 }] }, 327 | { "input": " { a : 1 } ", "output": ["object", { "a": 1 }] }, 328 | { "input": "{a:1,b:2}", "output": ["object", { "a": 1, "b": 2 }] }, 329 | { "input": "{ a : 1 , b : 2 }", "output": ["object", { "a": 1, "b": 2 }] }, 330 | { 331 | "input": "{ ok: .error == null }", 332 | "output": ["object", { "ok": ["eq", ["get", "error"], null] }] 333 | }, 334 | { "input": "{ \"a\" : 1 , \"b\" : 2 }", "output": ["object", { "a": 1, "b": 2 }] }, 335 | { "input": "{ 2: \"two\" }", "output": ["object", { "2": "two" }] }, 336 | { "input": "{ 0: \"zero\" }", "output": ["object", { "0": "zero" }] }, 337 | { "input": "{ \"\": \"empty\" }", "output": ["object", { "": "empty" }] }, 338 | { "input": "{ \" \": \"space\" }", "output": ["object", { " ": "space" }] }, 339 | { "input": "{null:null}", "output": ["object", { "null": null }] }, 340 | { 341 | "input": "{\n name: .name,\n city: .address.city,\n averageAge: map(.age) | average()\n }", 342 | "output": [ 343 | "object", 344 | { 345 | "name": ["get", "name"], 346 | "city": ["get", "address", "city"], 347 | "averageAge": ["pipe", ["map", ["get", "age"]], ["average"]] 348 | } 349 | ] 350 | } 351 | ] 352 | }, 353 | { 354 | "category": "object", 355 | "description": "should throw an error when missing closing parenthesis", 356 | "tests": [{ "input": "{a:1", "throws": "Character '}' expected (pos: 4)" }] 357 | }, 358 | { 359 | "category": "object", 360 | "description": "should throw an error when missing a comma", 361 | "tests": [{ "input": "{a:1 b:2}", "throws": "Character ',' expected (pos: 5)" }] 362 | }, 363 | { 364 | "category": "object", 365 | "description": "should throw an error when missing a comma", 366 | "tests": [{ "input": "{a", "throws": "Character ':' expected (pos: 2)" }] 367 | }, 368 | { 369 | "category": "object", 370 | "description": "should throw an error when missing a key", 371 | "tests": [ 372 | { "input": "{{", "throws": "Key expected (pos: 1)" }, 373 | { "input": "{a:2,{", "throws": "Key expected (pos: 5)" } 374 | ] 375 | }, 376 | { 377 | "category": "object", 378 | "description": "should throw an error when missing a value", 379 | "tests": [ 380 | { "input": "{a:", "throws": "Value expected (pos: 3)" }, 381 | { "input": "{a:2,b:}", "throws": "Value expected (pos: 7)" } 382 | ] 383 | }, 384 | { 385 | "category": "object", 386 | "description": "should throw an error in case of a trailing comma", 387 | "tests": [{ "input": "{a:2,}", "throws": "Key expected (pos: 5)" }] 388 | }, 389 | { 390 | "category": "array", 391 | "description": "should parse an array", 392 | "tests": [ 393 | { "input": "[]", "output": ["array"] }, 394 | { "input": " [ ] ", "output": ["array"] }, 395 | { "input": "[1, 2, 3]", "output": ["array", 1, 2, 3] }, 396 | { "input": " [ 1 , 2 , 3 ] ", "output": ["array", 1, 2, 3] }, 397 | { "input": "[(1 + 3), 2, 4]", "output": ["array", ["add", 1, 3], 2, 4] }, 398 | { "input": "[2, (1 + 2), 4]", "output": ["array", 2, ["add", 1, 2], 4] } 399 | ] 400 | }, 401 | { 402 | "category": "array", 403 | "description": "should throw an error when missing closing bracket", 404 | "tests": [{ "input": "[1,2", "throws": "Character ']' expected (pos: 4)" }] 405 | }, 406 | { 407 | "category": "array", 408 | "description": "should throw an error when missing a comma", 409 | "tests": [{ "input": "[1 2]", "throws": "Character ',' expected (pos: 3)" }] 410 | }, 411 | { 412 | "category": "array", 413 | "description": "should throw an error when missing a value", 414 | "tests": [{ "input": "[1,", "throws": "Value expected (pos: 3)" }] 415 | }, 416 | { 417 | "category": "array", 418 | "description": "should throw an error in case of a trailing comma", 419 | "tests": [{ "input": "[1,2,]", "throws": "Value expected (pos: 5)" }] 420 | }, 421 | { 422 | "category": "string", 423 | "description": "should parse a string", 424 | "tests": [ 425 | { "input": "\"hello\"", "output": "hello" }, 426 | { "input": " \"hello\"", "output": "hello" }, 427 | { "input": "\"hello\" ", "output": "hello" }, 428 | { "input": "\"hello \\\"world\\\"\"", "output": "hello \"world\"" } 429 | ] 430 | }, 431 | { 432 | "category": "string", 433 | "description": "should throw an error when missing closing quote", 434 | "tests": [{ "input": "\"hello", "throws": "Value expected (pos: 0)" }] 435 | }, 436 | { 437 | "category": "number", 438 | "description": "should parse a number", 439 | "tests": [ 440 | { "input": "42", "output": 42 }, 441 | { "input": "-42", "output": -42 }, 442 | { "input": "2.3", "output": 2.3 }, 443 | { "input": "-2.3", "output": -2.3 }, 444 | { "input": "2.3e2", "output": 230 }, 445 | { "input": "2.3e+2", "output": 230 }, 446 | { "input": "2.3e-2", "output": 0.023 }, 447 | { "input": "2.3E+2", "output": 230 }, 448 | { "input": "2.3E-2", "output": 0.023 } 449 | ] 450 | }, 451 | { 452 | "category": "number", 453 | "description": "should throw an error in case of an invalid number", 454 | "tests": [ 455 | { "input": "-", "throws": "Value expected (pos: 0)" }, 456 | { "input": "2.", "throws": "Unexpected part '.' (pos: 1)" }, 457 | { "input": "2.3e", "throws": "Unexpected part 'e' (pos: 3)" }, 458 | { "input": "2.3e+", "throws": "Unexpected part 'e+' (pos: 3)" }, 459 | { "input": "2.3e-", "throws": "Unexpected part 'e-' (pos: 3)" }, 460 | { "input": "2.", "throws": "Unexpected part '.' (pos: 1)" } 461 | ] 462 | }, 463 | { 464 | "category": "boolean", 465 | "description": "should parse a boolean", 466 | "tests": [ 467 | { "input": "true", "output": true }, 468 | { "input": " true ", "output": true }, 469 | { "input": "false", "output": false } 470 | ] 471 | }, 472 | { 473 | "category": "null", 474 | "description": "should parse null", 475 | "tests": [{ "input": "null", "output": null }, { "input": " null ", "output": null }] 476 | }, 477 | { 478 | "category": "garbage", 479 | "description": "should throw an error in case of garbage at the end", 480 | "tests": [ 481 | { "input": "null 2", "throws": "Unexpected part '2' (pos: 5)" }, 482 | { "input": "sort() 2", "throws": "Unexpected part '2' (pos: 7)" } 483 | ] 484 | }, 485 | { 486 | "category": "whitespace", 487 | "description": "should skip whitespace characters", 488 | "tests": [{ "input": " \n\r\t\"hello\" \n\r\t", "output": "hello" }] 489 | }, 490 | { 491 | "category": "empty", 492 | "description": "should throw when the query is empty", 493 | "tests": [ 494 | { "input": "", "throws": "Value expected (pos: 0)" }, 495 | { "input": " ", "throws": "Value expected (pos: 1)" } 496 | ] 497 | } 498 | ] 499 | } 500 | -------------------------------------------------------------------------------- /test-suite/parse.test.schema.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "http://json-schema.org/draft-07/schema", 3 | "$id": "https://github.com/jsonquerylang/jsonquery/blob/v5.0.0/test-suite/parse.test.schema.json", 4 | "type": "object", 5 | "properties": { 6 | "source": { 7 | "const": "https://github.com/jsonquerylang/jsonquery/blob/v5.0.0/test-suite/parse.test.json" 8 | }, 9 | "version": { 10 | "const": "5.0.0" 11 | }, 12 | "groups": { 13 | "type": "array", 14 | "items": { 15 | "type": "object", 16 | "properties": { 17 | "category": { "type": "string" }, 18 | "description": { "type": "string" }, 19 | "tests": { 20 | "type": "array", 21 | "items": { 22 | "oneOf": [ 23 | { 24 | "type": "object", 25 | "properties": { 26 | "input": { "type": "string" }, 27 | "output": {} 28 | }, 29 | "required": ["input", "output"], 30 | "additionalProperties": false 31 | }, 32 | { 33 | "type": "object", 34 | "properties": { 35 | "input": { "type": "string" }, 36 | "throws": { "type": "string" } 37 | }, 38 | "required": ["input", "throws"], 39 | "additionalProperties": false 40 | } 41 | ] 42 | } 43 | } 44 | }, 45 | "required": ["category", "description", "tests"], 46 | "additionalProperties": false 47 | } 48 | } 49 | }, 50 | "required": ["source", "version", "groups"], 51 | "additionalProperties": false 52 | } 53 | -------------------------------------------------------------------------------- /test-suite/stringify.test.d.ts: -------------------------------------------------------------------------------- 1 | import type { JSONQuery, JSONQueryStringifyOptions } from '../src/types' 2 | 3 | export interface StringifyTest { 4 | input: JSONQuery 5 | output: string 6 | } 7 | 8 | export interface StringifyTestGroup { 9 | category: string 10 | description: string 11 | options?: JSONQueryStringifyOptions 12 | tests: StringifyTest[] 13 | } 14 | 15 | export interface StringifyTestSuite { 16 | updated: string 17 | groups: StringifyTestGroup[] 18 | } 19 | -------------------------------------------------------------------------------- /test-suite/stringify.test.json: -------------------------------------------------------------------------------- 1 | { 2 | "source": "https://github.com/jsonquerylang/jsonquery/blob/v5.0.0/test-suite/stringify.test.json", 3 | "version": "5.0.0", 4 | "groups": [ 5 | { 6 | "category": "property", 7 | "description": "should stringify a property", 8 | "tests": [ 9 | { "input": ["get"], "output": "get()" }, 10 | { "input": ["get", "age"], "output": ".age" }, 11 | { "input": ["get", "address", "city"], "output": ".address.city" }, 12 | { "input": ["get", "with space"], "output": ".\"with space\"" }, 13 | { "input": ["get", "with special !"], "output": ".\"with special !\"" }, 14 | { "input": ["get", 2, "name"], "output": ".2.name" }, 15 | { "input": ["get", 0, "name"], "output": ".0.name" }, 16 | { "input": ["get", "AaZz09_$"], "output": ".AaZz09_$" } 17 | ] 18 | }, 19 | { 20 | "category": "operator", 21 | "description": "should stringify all operators", 22 | "tests": [ 23 | { "input": ["eq", ["get", "score"], 8], "output": ".score == 8" }, 24 | { "input": ["lt", ["get", "score"], 8], "output": ".score < 8" }, 25 | { "input": ["lte", ["get", "score"], 8], "output": ".score <= 8" }, 26 | { "input": ["gt", ["get", "score"], 8], "output": ".score > 8" }, 27 | { "input": ["gte", ["get", "score"], 8], "output": ".score >= 8" }, 28 | { "input": ["ne", ["get", "score"], 8], "output": ".score != 8" }, 29 | { "input": ["add", ["get", "score"], 8], "output": ".score + 8" }, 30 | { "input": ["subtract", ["get", "score"], 8], "output": ".score - 8" }, 31 | { "input": ["multiply", ["get", "score"], 8], "output": ".score * 8" }, 32 | { "input": ["divide", ["get", "score"], 8], "output": ".score / 8" }, 33 | { "input": ["pow", ["get", "score"], 8], "output": ".score ^ 8" }, 34 | { "input": ["mod", ["get", "score"], 8], "output": ".score % 8" }, 35 | { "input": ["and", ["get", "score"], 8], "output": ".score and 8" }, 36 | { "input": ["or", ["get", "score"], 8], "output": ".score or 8" }, 37 | { 38 | "input": ["in", ["get", "score"], ["array", 8, 9, 10]], 39 | "output": ".score in [8, 9, 10]" 40 | }, 41 | { 42 | "input": ["not in", ["get", "score"], ["array", 8, 9, 10]], 43 | "output": ".score not in [8, 9, 10]" 44 | } 45 | ] 46 | }, 47 | { 48 | "category": "operator", 49 | "description": "should wrap operators with the same precedence in parenthesis when needed", 50 | "tests": [ 51 | { "input": ["pow", ["pow", 2, 3], 4], "output": "(2 ^ 3) ^ 4" }, 52 | { "input": ["pow", 2, ["pow", 3, 4]], "output": "2 ^ (3 ^ 4)" }, 53 | { "input": ["multiply", ["multiply", 2, 3], 4], "output": "2 * 3 * 4" }, 54 | { "input": ["multiply", 2, ["multiply", 3, 4]], "output": "2 * (3 * 4)" }, 55 | { "input": ["divide", ["divide", 2, 3], 4], "output": "2 / 3 / 4" }, 56 | { "input": ["divide", 2, ["divide", 3, 4]], "output": "2 / (3 / 4)" }, 57 | { "input": ["divide", ["multiply", 2, 3], 4], "output": "2 * 3 / 4" }, 58 | { "input": ["divide", 2, ["multiply", 3, 4]], "output": "2 / (3 * 4)" }, 59 | { "input": ["divide", 2, 3, ["multiply", 4, 5]], "output": "2 / 3 / (4 * 5)" }, 60 | { 61 | "input": ["divide", 2, ["multiply", 3, 4], ["multiply", 5, 6]], 62 | "output": "2 / (3 * 4) / (5 * 6)" 63 | }, 64 | { "input": ["multiply", ["divide", 2, 3], 4], "output": "2 / 3 * 4" }, 65 | { "input": ["mod", ["mod", 2, 3], 4], "output": "2 % 3 % 4" }, 66 | { "input": ["mod", 2, ["mod", 3, 4]], "output": "2 % (3 % 4)" }, 67 | { "input": ["mod", ["multiply", 2, 3], 4], "output": "2 * 3 % 4" }, 68 | { "input": ["multiply", ["mod", 2, 3], 4], "output": "2 % 3 * 4" }, 69 | { "input": ["add", ["add", 2, 3], 4], "output": "2 + 3 + 4" }, 70 | { "input": ["add", 2, ["add", 3, 4]], "output": "2 + (3 + 4)" }, 71 | { "input": ["subtract", ["subtract", 2, 3], 4], "output": "2 - 3 - 4" }, 72 | { "input": ["subtract", 2, ["subtract", 3, 4]], "output": "2 - (3 - 4)" }, 73 | { "input": ["subtract", ["add", 2, 3], 4], "output": "2 + 3 - 4" }, 74 | { "input": ["subtract", 2, ["add", 3, 4]], "output": "2 - (3 + 4)" }, 75 | { "input": ["add", ["subtract", 2, 3], 4], "output": "2 - 3 + 4" }, 76 | { "input": ["eq", ["eq", 2, 3], 4], "output": "(2 == 3) == 4" }, 77 | { "input": ["eq", 2, ["eq", 3, 4]], "output": "2 == (3 == 4)" } 78 | ] 79 | }, 80 | { 81 | "category": "operator", 82 | "description": "should wrap operators with differing precedence in parenthesis when needed", 83 | "tests": [ 84 | { "input": ["abs", ["add", 2, 3]], "output": "abs(2 + 3)" }, 85 | { "input": ["multiply", ["pow", 2, 3], 4], "output": "2 ^ 3 * 4" }, 86 | { "input": ["multiply", 2, ["pow", 3, 4]], "output": "2 * 3 ^ 4" }, 87 | { "input": ["pow", 2, ["multiply", 3, 4]], "output": "2 ^ (3 * 4)" }, 88 | { "input": ["pow", ["multiply", 2, 3], 4], "output": "(2 * 3) ^ 4" }, 89 | { "input": ["add", ["multiply", 2, 3], 4], "output": "2 * 3 + 4" }, 90 | { "input": ["add", 2, ["multiply", 3, 4]], "output": "2 + 3 * 4" }, 91 | { "input": ["multiply", 2, ["add", 3, 4]], "output": "2 * (3 + 4)" }, 92 | { "input": ["multiply", ["add", 2, 3], 4], "output": "(2 + 3) * 4" }, 93 | { "input": ["gt", ["add", 2, 3], 4], "output": "2 + 3 > 4" }, 94 | { "input": ["gt", 2, ["add", 3, 4]], "output": "2 > 3 + 4" }, 95 | { "input": ["add", 2, ["gt", 3, 4]], "output": "2 + (3 > 4)" }, 96 | { "input": ["add", ["gt", 2, 3], 4], "output": "(2 > 3) + 4" }, 97 | { "input": ["eq", ["gt", 2, 3], 4], "output": "2 > 3 == 4" }, 98 | { "input": ["gt", 2, ["eq", 3, 4]], "output": "2 > (3 == 4)" }, 99 | { "input": ["and", ["eq", 2, 3], 4], "output": "2 == 3 and 4" }, 100 | { "input": ["eq", 2, ["and", 3, 4]], "output": "2 == (3 and 4)" }, 101 | { "input": ["eq", ["and", 2, 3], 4], "output": "(2 and 3) == 4" }, 102 | { "input": ["or", ["and", 2, 3], 4], "output": "2 and 3 or 4" }, 103 | { "input": ["and", 2, ["or", 3, 4]], "output": "2 and (3 or 4)" }, 104 | { "input": ["and", ["gt", 2, 3], 4], "output": "2 > 3 and 4" }, 105 | { "input": ["gt", 2, ["and", 3, 4]], "output": "2 > (3 and 4)" }, 106 | { "input": ["gt", ["and", 2, 3], 4], "output": "(2 and 3) > 4" }, 107 | { "input": ["pipe", ["and", 2, 3], 4], "output": "2 and 3 | 4" }, 108 | { "input": ["pipe", 2, ["and", 3, 4]], "output": "2 | 3 and 4" }, 109 | { "input": ["and", ["pipe", 2, 3], 4], "output": "(2 | 3) and 4" }, 110 | { "input": ["and", 2, ["pipe", 3, 4]], "output": "2 and (3 | 4)" } 111 | ] 112 | }, 113 | { 114 | "category": "operator", 115 | "description": "should stringify a variable number of arguments in operators", 116 | "tests": [ 117 | { "input": ["pipe", 2, 3, 4], "output": "2 | 3 | 4" }, 118 | { "input": ["get", 2, 3, 4], "output": ".2.3.4" }, 119 | { "input": ["and", 2, 3, 4], "output": "2 and 3 and 4" }, 120 | { "input": ["and", 2, 3, 4, 5], "output": "2 and 3 and 4 and 5" }, 121 | { "input": ["or", 2, 3, 4], "output": "2 or 3 or 4" }, 122 | { "input": ["add", 2, 3, 4], "output": "2 + 3 + 4" }, 123 | { "input": ["subtract", 2, 3, 4], "output": "2 - 3 - 4" }, 124 | { "input": ["multiply", 2, 3, 4], "output": "2 * 3 * 4" }, 125 | { "input": ["divide", 2, 3, 4], "output": "2 / 3 / 4" }, 126 | { "input": ["mod", 2, 3, 4], "output": "2 % 3 % 4" } 127 | ] 128 | }, 129 | { 130 | "category": "function", 131 | "description": "should stringify a function", 132 | "tests": [ 133 | { "input": ["sort", ["get", "age"], "desc"], "output": "sort(.age, \"desc\")" }, 134 | { "input": ["filter", ["gt", ["get", "age"], 18]], "output": "filter(.age > 18)" } 135 | ] 136 | }, 137 | { 138 | "category": "function", 139 | "description": "should stringify a function with indentation", 140 | "options": { 141 | "indentation": " ", 142 | "maxLineLength": 4 143 | }, 144 | "tests": [ 145 | { 146 | "input": ["sort", ["get", "age"], "desc"], 147 | "output": "sort(\n .age,\n \"desc\"\n)" 148 | } 149 | ] 150 | }, 151 | { 152 | "category": "function", 153 | "description": "should stringify a function inside an object with indentation", 154 | "options": { 155 | "indentation": " ", 156 | "maxLineLength": 4 157 | }, 158 | "tests": [ 159 | { 160 | "input": ["object", { "sorted": ["sort", ["get", "age"], "desc"] }], 161 | "output": "{\n sorted: sort(\n .age,\n \"desc\"\n )\n}" 162 | } 163 | ] 164 | }, 165 | { 166 | "category": "function", 167 | "description": "should stringify a nested function having one argument with indentation", 168 | "options": { 169 | "indentation": " ", 170 | "maxLineLength": 4 171 | }, 172 | "tests": [ 173 | { 174 | "input": [ 175 | "map", 176 | ["object", { "name": ["get", "name"], "city": ["get", "address", "city"] }] 177 | ], 178 | "output": "map({\n name: .name,\n city: .address.city\n})" 179 | } 180 | ] 181 | }, 182 | { 183 | "category": "pipe", 184 | "description": "should stringify a pipe", 185 | "tests": [{ "input": ["pipe", ["get", "age"], ["average"]], "output": ".age | average()" }] 186 | }, 187 | { 188 | "category": "pipe", 189 | "description": "should stringify a pipe with indentation", 190 | "options": { "maxLineLength": 10 }, 191 | "tests": [{ "input": ["pipe", ["get", "age"], ["average"]], "output": ".age\n | average()" }] 192 | }, 193 | { 194 | "category": "pipe", 195 | "description": "should stringify a nested pipe with indentation", 196 | "options": { "maxLineLength": 10 }, 197 | "tests": [ 198 | { 199 | "input": ["object", { "nested": ["pipe", ["get", "age"], ["average"]] }], 200 | "output": "{\n nested: .age\n | average()\n}" 201 | } 202 | ] 203 | }, 204 | { 205 | "category": "object", 206 | "description": "should stringify an object", 207 | "tests": [ 208 | { 209 | "input": ["object", { "name": ["get", "name"], "city": ["get", "address", "city"] }], 210 | "output": "{ name: .name, city: .address.city }" 211 | } 212 | ] 213 | }, 214 | { 215 | "category": "object", 216 | "description": "should stringify an object with indentation", 217 | "options": { "maxLineLength": 20 }, 218 | "tests": [ 219 | { 220 | "input": ["object", { "name": ["get", "name"], "city": ["get", "address", "city"] }], 221 | "output": "{\n name: .name,\n city: .address.city\n}" 222 | } 223 | ] 224 | }, 225 | { 226 | "category": "object", 227 | "description": "should stringify a nested object with indentation", 228 | "options": { "maxLineLength": 4 }, 229 | "tests": [ 230 | { 231 | "input": [ 232 | "object", 233 | { 234 | "name": ["get", "name"], 235 | "address": ["object", { "city": ["get", "city"], "street": ["get", "street"] }] 236 | } 237 | ], 238 | "output": "{\n name: .name,\n address: {\n city: .city,\n street: .street\n }\n}" 239 | } 240 | ] 241 | }, 242 | { 243 | "category": "object", 244 | "description": "should stringify a nested object with custom indentation (1)", 245 | "options": { 246 | "maxLineLength": 20, 247 | "indentation": " " 248 | }, 249 | "tests": [ 250 | { 251 | "input": ["object", { "name": ["get", "name"], "city": ["get", "address", "city"] }], 252 | "output": "{\n name: .name,\n city: .address.city\n}" 253 | } 254 | ] 255 | }, 256 | { 257 | "category": "object", 258 | "description": "should stringify a nested object with custom indentation (2)", 259 | "options": { 260 | "maxLineLength": 20, 261 | "indentation": "\t" 262 | }, 263 | "tests": [ 264 | { 265 | "input": ["object", { "name": ["get", "name"], "city": ["get", "address", "city"] }], 266 | "output": "{\n\tname: .name,\n\tcity: .address.city\n}" 267 | } 268 | ] 269 | }, 270 | { 271 | "category": "array", 272 | "description": "should stringify an array with indentation", 273 | "options": { "maxLineLength": 4 }, 274 | "tests": [{ "input": ["array", 1, 2, 3], "output": "[\n 1,\n 2,\n 3\n]" }] 275 | }, 276 | { 277 | "category": "array", 278 | "description": "should stringify a nested array with indentation", 279 | "options": { "maxLineLength": 4 }, 280 | "tests": [ 281 | { 282 | "input": ["object", { "array": ["array", 1, 2, 3] }], 283 | "output": "{\n array: [\n 1,\n 2,\n 3\n ]\n}" 284 | } 285 | ] 286 | }, 287 | { 288 | "category": "composed query", 289 | "description": "should stringify a composed query", 290 | "tests": [ 291 | { 292 | "input": ["pipe", ["map", ["multiply", ["get", "price"], ["get", "quantity"]]], ["sum"]], 293 | "output": "map(.price * .quantity) | sum()" 294 | }, 295 | { 296 | "input": [ 297 | "pipe", 298 | ["get", "friends"], 299 | ["filter", ["eq", ["get", "city"], "New York"]], 300 | ["sort", ["get", "age"]], 301 | ["pick", ["get", "name"], ["get", "age"]] 302 | ], 303 | "output": ".friends\n | filter(.city == \"New York\")\n | sort(.age)\n | pick(.name, .age)" 304 | }, 305 | { 306 | "input": ["filter", ["and", ["gte", ["get", "age"], 23], ["lte", ["get", "age"], 27]]], 307 | "output": "filter(.age >= 23 and .age <= 27)" 308 | }, 309 | { 310 | "input": [ 311 | "pipe", 312 | ["get", "friends"], 313 | [ 314 | "object", 315 | { 316 | "names": ["map", ["get", "name"]], 317 | "count": ["size"], 318 | "averageAge": ["pipe", ["map", ["get", "age"]], ["average"]] 319 | } 320 | ] 321 | ], 322 | "output": ".friends\n | {\n names: map(.name),\n count: size(),\n averageAge: map(.age) | average()\n }" 323 | }, 324 | { 325 | "input": [ 326 | "object", 327 | { 328 | "name": ["get", "name"], 329 | "city": ["get", "address", "city"], 330 | "averageAge": ["pipe", ["map", ["get", "age"]], ["average"]] 331 | } 332 | ], 333 | "output": "{\n name: .name,\n city: .address.city,\n averageAge: map(.age) | average()\n}" 334 | } 335 | ] 336 | } 337 | ] 338 | } 339 | -------------------------------------------------------------------------------- /test-suite/stringify.test.schema.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "http://json-schema.org/draft-07/schema", 3 | "$id": "https://github.com/jsonquerylang/jsonquery/blob/v5.0.0/test-suite/stringify.test.schema.json", 4 | "type": "object", 5 | "properties": { 6 | "source": { 7 | "const": "https://github.com/jsonquerylang/jsonquery/blob/v5.0.0/test-suite/stringify.test.json" 8 | }, 9 | "version": { 10 | "const": "5.0.0" 11 | }, 12 | "groups": { 13 | "type": "array", 14 | "items": { 15 | "type": "object", 16 | "properties": { 17 | "category": { "type": "string" }, 18 | "description": { "type": "string" }, 19 | "options": { 20 | "type": "object", 21 | "properties": { 22 | "indentation": { "type": "string" }, 23 | "maxLineLength": { "type": "number" } 24 | } 25 | }, 26 | "tests": { 27 | "type": "array", 28 | "items": { 29 | "type": "object", 30 | "properties": { 31 | "input": {}, 32 | "output": { "type": "string" } 33 | }, 34 | "required": ["input", "output"], 35 | "additionalProperties": false 36 | } 37 | } 38 | }, 39 | "required": ["category", "description", "tests"], 40 | "additionalProperties": false 41 | } 42 | } 43 | }, 44 | "required": ["source", "version", "groups"], 45 | "additionalProperties": false 46 | } 47 | -------------------------------------------------------------------------------- /tsconfig-types.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "emitDeclarationOnly": true 5 | }, 6 | "exclude": ["src/**/*.test.ts"] 7 | } 8 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "moduleResolution": "bundler", 4 | "module": "ESNext", 5 | "lib": ["ESNext"], 6 | "target": "ESNext", 7 | "sourceMap": true, 8 | "esModuleInterop": true, 9 | "allowJs": false, 10 | "checkJs": false, 11 | "strict": false, 12 | "noImplicitAny": false, 13 | "declaration": true, 14 | "declarationDir": "lib", 15 | "declarationMap": true, 16 | "skipLibCheck": true 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /vite.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('vite').UserConfig} */ 2 | export default { 3 | build: { 4 | lib: { 5 | entry: 'src/jsonquery.ts', 6 | formats: ['es'] 7 | }, 8 | outDir: './lib', 9 | sourcemap: true 10 | }, 11 | coverage: { 12 | provider: 'v8' 13 | } 14 | } 15 | --------------------------------------------------------------------------------