diff --git a/README.md b/README.md index 1f1017d..c447638 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@ Try it out on the online playground: ## Features -- Small: just `2.9 kB` when minified and gzipped! The JSON query engine without parse/stringify is only `1.3 kB`. +- Small: just `3.0 kB` when minified and gzipped! The JSON query engine without parse/stringify is only `1.4 kB`. - Feature rich (40+ powerful functions) - Easy to interoperate with thanks to the intermediate JSON format. - Expressive @@ -34,6 +34,7 @@ On this page: External pages: - [Function reference](reference/functions.md) +- [Test Suite](test-suite/README.md) ## Installation @@ -623,7 +624,9 @@ Another gotcha is that unlike some other query languages, you need to use the `m ## Development -To develop, check out the repo, install dependencies once, and then use the following scripts: +### JavaScript + +To develop, check out the JavaScript repo, install dependencies once, and then use the following scripts: ```text npm run test @@ -637,6 +640,12 @@ npm run build-and-test 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). +### Implement in a new language + +Support for JSON Query language can be implemented in new programming languages. Implementing the query engine is most straight forward: this boils down to implementing each of the functions (`sort`, `filter`, `groupBy`, etc.), and creating a compiler which can go through a JSON Query like `["sort", ["get", "name"], "desc"]` look up the function `sort`, and pass the arguments to it. Implementing a parser and stringifier is a bit more work, but the parser and stringifier of the JavaScript implementation can be used as a reference. + +There is a JSON based Test Suite available that can be used to ensure that your implementation matches the behavior of the reference implementation, see: [Test Suite](test-suite/README.md). + ## Motivation There are many powerful query languages out there, so why the need to develop `jsonquery`? There are a couple of reasons for this. diff --git a/package-lock.json b/package-lock.json index ec91e5a..da3eceb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,6 +14,7 @@ "devDependencies": { "@biomejs/biome": "1.9.3", "@vitest/coverage-v8": "2.1.2", + "ajv": "8.17.1", "npm-run-all": "4.1.5", "semantic-release": "24.1.2", "typescript": "5.6.3", @@ -1724,6 +1725,23 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/ajv": { + "version": "8.17.1", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz", + "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, "node_modules/ansi-escapes": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-7.0.0.tgz", @@ -3207,6 +3225,13 @@ "node": ">= 8" } }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, "node_modules/fast-glob": { "version": "3.3.2", "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.2.tgz", @@ -3224,6 +3249,13 @@ "node": ">=8.6.0" } }, + "node_modules/fast-uri": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.0.3.tgz", + "integrity": "sha512-aLrHthzCjH5He4Z2H9YZ+v6Ujb9ocRuW6ZzkJQOrTxleEijANq4v1TsaPaVG1PZcuurEzrLcWRyYBYXD5cEiaw==", + "dev": true, + "license": "BSD-3-Clause" + }, "node_modules/fastq": { "version": "1.17.1", "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.17.1.tgz", @@ -4504,6 +4536,13 @@ "dev": true, "license": "MIT" }, + "node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "dev": true, + "license": "MIT" + }, "node_modules/jsonfile": { "version": "6.1.0", "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.1.0.tgz", @@ -8759,6 +8798,16 @@ "node": ">=0.10.0" } }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/resolve": { "version": "1.22.8", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.8.tgz", diff --git a/package.json b/package.json index d0831a4..8abda23 100644 --- a/package.json +++ b/package.json @@ -55,6 +55,7 @@ "devDependencies": { "@biomejs/biome": "1.9.3", "@vitest/coverage-v8": "2.1.2", + "ajv": "8.17.1", "npm-run-all": "4.1.5", "semantic-release": "24.1.2", "typescript": "5.6.3", diff --git a/src/compile.test.ts b/src/compile.test.ts index 094e431..12abed9 100644 --- a/src/compile.test.ts +++ b/src/compile.test.ts @@ -1,4 +1,8 @@ +import Ajv from 'ajv' import { describe, expect, test } from 'vitest' +import type { CompileTestSuite } from '../test-suite/compile.test' +import suite from '../test-suite/compile.test.json' +import schema from '../test-suite/compile.test.schema.json' import { compile } from './compile' import { buildFunction } from './functions' import type { JSONQuery, JSONQueryCompileOptions } from './types' @@ -13,26 +17,6 @@ const data = [ { name: 'Sarah', age: 31, city: 'New York' } ] -const friendsData = { - friends: data -} - -const nestedData = [ - { name: 'Chris', age: 23, address: { city: 'New York' } }, - { name: 'Emily', age: 19, address: { city: 'Atlanta' } }, - { name: 'Joe', age: 32, address: { city: 'New York' } }, - { name: 'Kevin', age: 19, address: { city: 'Atlanta' } }, - { name: 'Michelle', age: 27, address: { city: 'Los Angeles' } }, - { name: 'Robert', age: 45, address: { city: 'Manhattan' } }, - { name: 'Sarah', age: 31, address: { city: 'New York' } } -] - -const scoresData = [ - { name: 'Chris', scores: [5, 7, 3] }, - { name: 'Emily', scores: [8, 5, 2, 5] }, - { name: 'Joe', scores: [1, 1, 5, 6] } -] - /** * Compile and execute */ @@ -41,774 +25,83 @@ function go(data: unknown, query: JSONQuery, options?: JSONQueryCompileOptions) return exec(data) } -describe('compile', () => { - describe('prop', () => { - test('should get a path with a single property as string', () => { - expect(go({ name: 'Joe' }, ['get', 'name'])).toEqual('Joe') - }) - - test('should get the full object itself', () => { - expect(go({ name: 'Joe' }, ['get'])).toEqual({ name: 'Joe' }) - expect(go(2, ['get'])).toEqual(2) - }) - - test('should return null in case of a non existing path', () => { - expect(go({}, ['get', 'foo', 'bar'])).toEqual(null) - }) - - test('should get a path using function get', () => { - expect(go({ name: 'Joe' }, ['get', 'name'])).toEqual('Joe') - }) - - test('should get a path that has the same name as a function', () => { - expect(go({ sort: 'Joe' }, ['get', 'sort'])).toEqual('Joe') - }) - - test('should get a nested value that has the same name as a function', () => { - expect(go({ sort: { name: 'Joe' } }, ['get', 'sort', 'name'])).toEqual('Joe') - }) - - test('should get in item from an array', () => { - expect(go(['A', 'B', 'C'], ['get', 1])).toEqual('B') - expect(go({ arr: ['A', 'B', 'C'] }, ['get', 'arr', 1])).toEqual('B') - expect(go([{ text: 'A' }, { text: 'B' }, { text: 'C' }], ['get', 1, 'text'])).toEqual('B') - }) - }) - - test('should execute a function', () => { - expect(go([3, 1, 2], ['sort'])).toEqual([1, 2, 3]) - }) - - describe('object', () => { - test('should create an object', () => { - expect( - go({ a: 2, b: 3 }, [ - 'object', - { - aa: ['get', 'a'], - bb: 42 - } - ]) - ).toEqual({ - aa: 2, - bb: 42 - }) - }) - - test('should create a nested object', () => { - expect( - go(data, [ - 'object', - { - names: ['map', ['get', 'name']], - stats: [ - 'object', - { - count: ['size'], - averageAge: ['pipe', ['map', ['get', 'age']], ['average']] - } - ] - } - ]) - ).toEqual({ - names: ['Chris', 'Emily', 'Joe', 'Kevin', 'Michelle', 'Robert', 'Sarah'], - stats: { - count: 7, - averageAge: 28 - } - }) - }) - }) - - describe('array', () => { - test('should create an array', () => { - expect(go(null, ['array', 1, 2, 3])).toEqual([1, 2, 3]) - expect(go(null, ['array', ['add', 1, 3], 2, 4])).toEqual([4, 2, 4]) - }) - }) - - describe('pipe', () => { - test('should execute a pipeline', () => { - expect(go({ user: { name: 'Joe' } }, ['pipe', ['get', 'user'], ['get', 'name']])).toEqual( - 'Joe' - ) - }) - - test('should create an object containing pipelines', () => { - expect( - go(data, [ - 'object', - { - names: ['map', ['get', 'name']], - count: ['size'], - averageAge: ['pipe', ['map', ['get', 'age']], ['average']] - } - ]) - ).toEqual({ - names: ['Chris', 'Emily', 'Joe', 'Kevin', 'Michelle', 'Robert', 'Sarah'], - count: 7, - averageAge: 28 - }) - }) - - test('should throw a helpful error when a pipe contains a compile time error', () => { - let actualErr = undefined - try { - go(data, ['foo', 42]) - } catch (err) { - actualErr = err - } - - expect(actualErr?.message).toBe("Unknown function 'foo'") - }) - - test('should throw a helpful error when a pipe contains a runtime error', () => { - const scoreData = { - participants: [ - { name: 'Chris', age: 23, scores: [7.2, 5, 8.0] }, - { name: 'Emily', age: 19 }, - { name: 'Joe', age: 32, scores: [6.1, 8.1] } - ] - } - const query = ['pipe', ['get', 'participants'], ['map', ['pipe', ['get', 'scores'], ['sum']]]] - - let actualErr = undefined - try { - go(scoreData, query) - } catch (err) { - actualErr = err - } - - expect(actualErr?.message).toBe("Cannot read properties of null (reading 'reduce')") - expect(actualErr?.jsonquery).toEqual([ - { data: scoreData, query }, - { - data: scoreData.participants, - query: ['map', ['pipe', ['get', 'scores'], ['sum']]] - }, - { data: { name: 'Emily', age: 19 }, query: ['pipe', ['get', 'scores'], ['sum']] }, - { data: null, query: ['sum'] } - ]) - }) - }) - - describe('map', () => { - test('should map over an array', () => { - expect( - go(scoresData, [ - 'pipe', - [ - 'map', - [ - 'object', - { - name: ['get', 'name'], - maxScore: ['pipe', ['get', 'scores'], ['max']], - minScore: ['pipe', ['get', 'scores'], ['min']] - } - ] - ], - ['sort', ['get', 'maxScore'], 'desc'] - ]) - ).toEqual([ - { name: 'Emily', maxScore: 8, minScore: 2 }, - { name: 'Chris', maxScore: 7, minScore: 3 }, - { name: 'Joe', maxScore: 6, minScore: 1 } - ]) - }) - - test('should map a path', () => { - expect(go(data, ['map', ['get', 'name']])).toEqual([ - 'Chris', - 'Emily', - 'Joe', - 'Kevin', - 'Michelle', - 'Robert', - 'Sarah' - ]) - }) - - test('should map over an array using pick', () => { - expect(go(data, ['map', ['pick', ['get', 'name']]])).toEqual([ - { name: 'Chris' }, - { name: 'Emily' }, - { name: 'Joe' }, - { name: 'Kevin' }, - { name: 'Michelle' }, - { name: 'Robert' }, - { name: 'Sarah' } - ]) - }) - }) - - test('should flatten an array', () => { - expect( - go( - [ - [1, 2], - [3, 4, 5] - ], - ['flatten'] - ) - ).toEqual([1, 2, 3, 4, 5]) - }) - - test('should resolve an function', () => { - expect(go([], ['and', true, false])).toEqual(false) - expect(go([], ['or', true, false])).toEqual(true) - expect(go({ city: 'New York' }, ['eq', ['get', 'city'], 'New York'])).toEqual(true) - }) +const groupByCategory = compile(['groupBy', ['get', 'category']]) +const testsByCategory = groupByCategory(suite.tests) as Record - describe('filter', () => { - test('should filter data using equal', () => { - expect(go(data, ['filter', ['eq', ['get', 'city'], 'New York']])).toEqual([ - { name: 'Chris', age: 23, city: 'New York' }, - { name: 'Joe', age: 32, city: 'New York' }, - { name: 'Sarah', age: 31, city: 'New York' } - ]) - }) - - test('should filter nested data using equal', () => { - expect(go(nestedData, ['filter', ['eq', ['get', 'address', 'city'], 'New York']])).toEqual([ - { name: 'Chris', age: 23, address: { city: 'New York' } }, - { name: 'Joe', age: 32, address: { city: 'New York' } }, - { name: 'Sarah', age: 31, address: { city: 'New York' } } - ]) - }) - - test('should filter multiple conditions (and)', () => { - expect( - go(nestedData, [ - 'pipe', - ['filter', ['gt', ['get', 'age'], 30]], - ['filter', ['eq', ['get', 'address', 'city'], 'New York']] - ]) - ).toEqual([ - { name: 'Joe', age: 32, address: { city: 'New York' } }, - { name: 'Sarah', age: 31, address: { city: 'New York' } } - ]) - }) - - test('should filter with a condition being a function', () => { - expect(go(scoresData, ['filter', ['gte', ['pipe', ['get', 'scores'], ['max']], 7]])).toEqual([ - { name: 'Chris', scores: [5, 7, 3] }, - { name: 'Emily', scores: [8, 5, 2, 5] } - ]) - }) - - test('should filter data using ne', () => { - expect(go(data, ['filter', ['ne', ['get', 'city'], 'New York']])).toEqual([ - { name: 'Emily', age: 19, city: 'Atlanta' }, - { name: 'Kevin', age: 19, city: 'Atlanta' }, - { name: 'Michelle', age: 27, city: 'Los Angeles' }, - { name: 'Robert', age: 45, city: 'Manhattan' } - ]) - }) - - test('should filter data using gt', () => { - expect(go(data, ['filter', ['gt', ['get', 'age'], 45]])).toEqual([]) - }) - - test('should filter data using gte', () => { - expect(go(data, ['filter', ['gte', ['get', 'age'], 45]])).toEqual([ - { name: 'Robert', age: 45, city: 'Manhattan' } - ]) - }) - - test('should filter data using lt', () => { - expect(go(data, ['filter', ['lt', ['get', 'age'], 19]])).toEqual([]) - }) - - test('should filter data using lte', () => { - expect(go(data, ['filter', ['lte', ['get', 'age'], 19]])).toEqual([ - { name: 'Emily', age: 19, city: 'Atlanta' }, - { name: 'Kevin', age: 19, city: 'Atlanta' } - ]) - }) - - test('should filter data using gte and lte', () => { - expect( - go(data, [ - 'pipe', - ['filter', ['gte', ['get', 'age'], 23]], - ['filter', ['lte', ['get', 'age'], 27]] - ]) - ).toEqual([ - { name: 'Chris', age: 23, city: 'New York' }, - { name: 'Michelle', age: 27, city: 'Los Angeles' } - ]) - - expect( - go(data, ['filter', ['and', ['gte', ['get', 'age'], 23], ['lte', ['get', 'age'], 27]]]) - ).toEqual([ - { name: 'Chris', age: 23, city: 'New York' }, - { name: 'Michelle', age: 27, city: 'Los Angeles' } - ]) - }) - - test('should filter data using "in"', () => { - expect(go(data, ['filter', ['in', ['get', 'age'], ['array', ['add', 10, 9], 23]]])).toEqual([ - { name: 'Chris', age: 23, city: 'New York' }, - { name: 'Emily', age: 19, city: 'Atlanta' }, - { name: 'Kevin', age: 19, city: 'Atlanta' } - ]) - }) - - test('should filter data using "not in"', () => { - expect( - go(data, ['filter', ['not in', ['get', 'age'], ['array', ['add', 10, 9], 23]]]) - ).toEqual([ - { name: 'Joe', age: 32, city: 'New York' }, - { name: 'Michelle', age: 27, city: 'Los Angeles' }, - { name: 'Robert', age: 45, city: 'Manhattan' }, - { name: 'Sarah', age: 31, city: 'New York' } - ]) - }) - - test('should filter data using "regex"', () => { - // search for a name containing 3 to 5 letters - expect(go(data, ['filter', ['regex', ['get', 'name'], '^[A-z]{3,5}$']])).toEqual([ - { name: 'Chris', age: 23, city: 'New York' }, - { name: 'Emily', age: 19, city: 'Atlanta' }, - { name: 'Joe', age: 32, city: 'New York' }, - { name: 'Kevin', age: 19, city: 'Atlanta' }, - { name: 'Sarah', age: 31, city: 'New York' } - ]) - }) - - test('should filter data using "regex" with flags', () => { - // search for a name containing a case-insensitive character "m" - expect(go(data, ['filter', ['regex', ['get', 'name'], 'm', 'i']])).toEqual([ - { name: 'Emily', age: 19, city: 'Atlanta' }, - { name: 'Michelle', age: 27, city: 'Los Angeles' } - ]) - }) - - test('should filter multiple conditions using "and" and "or"', () => { - const item1 = { a: 1, b: 1 } - const item2 = { a: 2, b: 22 } - const item3 = { a: 3, b: 33 } - const data = [item1, item2, item3] - - expect(go(data, ['filter', ['eq', ['get', 'a'], 2]])).toEqual([item2]) - expect(go(data, ['filter', ['eq', ['get', 'a'], 3]])).toEqual([item3]) - expect(go(data, ['filter', ['eq', 3, ['get', 'a']]])).toEqual([item3]) - expect(go(data, ['filter', ['eq', 3, ['get', 'a']]])).toEqual([item3]) - - expect(go(data, ['filter', ['eq', ['get', 'a'], ['get', 'b']]])).toEqual([item1]) - expect(go(data, ['filter', ['gte', 2, ['get', 'a']]])).toEqual([item1, item2]) - - expect( - go(data, ['filter', ['and', ['eq', ['get', 'a'], 2], ['eq', ['get', 'b'], 22]]]) - ).toEqual([item2]) - expect( - go(data, ['filter', ['or', ['eq', ['get', 'a'], 1], ['eq', ['get', 'b'], 22]]]) - ).toEqual([item1, item2]) - expect( - go(data, ['filter', ['or', ['eq', ['get', 'a'], 1], ['eq', ['get', 'b'], 4]]]) - ).toEqual([item1]) - expect( - go(data, [ - 'filter', - [ - 'or', - ['and', ['eq', ['get', 'a'], 1], ['eq', ['get', 'b'], 1]], - ['and', ['eq', ['get', 'a'], 2], ['eq', ['get', 'b'], 22]] - ] - ]) - ).toEqual([item1, item2]) - // FIXME: support multiple and/or in one go? - - const dataMsg = [{ message: 'hello' }] - expect(go(dataMsg, ['filter', ['eq', ['get', 'message'], 'hello']])).toEqual(dataMsg) - expect(go(dataMsg, ['filter', ['eq', 'hello', ['get', 'message']]])).toEqual(dataMsg) - }) - }) +for (const [category, tests] of Object.entries(testsByCategory)) { + describe(category, () => { + for (const currentTest of tests) { + const { description, input, query, output } = currentTest - describe('sort', () => { - test('should sort data (default direction)', () => { - expect(go(data, ['sort', ['get', 'age']])).toEqual([ - { name: 'Emily', age: 19, city: 'Atlanta' }, - { name: 'Kevin', age: 19, city: 'Atlanta' }, - { name: 'Chris', age: 23, city: 'New York' }, - { name: 'Michelle', age: 27, city: 'Los Angeles' }, - { name: 'Sarah', age: 31, city: 'New York' }, - { name: 'Joe', age: 32, city: 'New York' }, - { name: 'Robert', age: 45, city: 'Manhattan' } - ]) - }) - - test('should sort data (asc)', () => { - expect(go(data, ['sort', ['get', 'age'], 'asc'])).toEqual([ - { name: 'Emily', age: 19, city: 'Atlanta' }, - { name: 'Kevin', age: 19, city: 'Atlanta' }, - { name: 'Chris', age: 23, city: 'New York' }, - { name: 'Michelle', age: 27, city: 'Los Angeles' }, - { name: 'Sarah', age: 31, city: 'New York' }, - { name: 'Joe', age: 32, city: 'New York' }, - { name: 'Robert', age: 45, city: 'Manhattan' } - ]) - }) - - test('should sort data (desc)', () => { - expect(go(data, ['sort', ['get', 'age'], 'desc'])).toEqual([ - { name: 'Robert', age: 45, city: 'Manhattan' }, - { name: 'Joe', age: 32, city: 'New York' }, - { name: 'Sarah', age: 31, city: 'New York' }, - { name: 'Michelle', age: 27, city: 'Los Angeles' }, - { name: 'Chris', age: 23, city: 'New York' }, - { name: 'Emily', age: 19, city: 'Atlanta' }, - { name: 'Kevin', age: 19, city: 'Atlanta' } - ]) - }) - - test('should sort data (strings)', () => { - expect(go(data, ['sort', 'name'])).toEqual([ - { name: 'Chris', age: 23, city: 'New York' }, - { name: 'Emily', age: 19, city: 'Atlanta' }, - { name: 'Joe', age: 32, city: 'New York' }, - { name: 'Kevin', age: 19, city: 'Atlanta' }, - { name: 'Michelle', age: 27, city: 'Los Angeles' }, - { name: 'Robert', age: 45, city: 'Manhattan' }, - { name: 'Sarah', age: 31, city: 'New York' } - ]) - }) - - test('should sort nested data', () => { - expect(go(nestedData, ['sort', ['get', 'address', 'city']])).toEqual([ - { name: 'Emily', age: 19, address: { city: 'Atlanta' } }, - { name: 'Kevin', age: 19, address: { city: 'Atlanta' } }, - { name: 'Michelle', age: 27, address: { city: 'Los Angeles' } }, - { name: 'Robert', age: 45, address: { city: 'Manhattan' } }, - { name: 'Chris', age: 23, address: { city: 'New York' } }, - { name: 'Joe', age: 32, address: { city: 'New York' } }, - { name: 'Sarah', age: 31, address: { city: 'New York' } } - ]) - }) - - test('should sort a list with numbers rather than objects', () => { - expect(go([3, 7, 2, 6], ['sort'])).toEqual([2, 3, 6, 7]) - expect(go([3, 7, 2, 6], ['sort', ['get'], 'desc'])).toEqual([7, 6, 3, 2]) - }) - - test('should not crash when sorting a list with nested arrays', () => { - expect(go([[3], [7], [4]], ['sort'])).toEqual([[3], [4], [7]]) - expect(go([[], [], []], ['sort'])).toEqual([[], [], []]) - }) - - test('should not crash when sorting a list with nested objects', () => { - expect(go([{ a: 1 }, { c: 3 }, { b: 2 }], ['sort'])).toEqual([{ a: 1 }, { c: 3 }, { b: 2 }]) - expect(go([{}, {}, {}], ['sort'])).toEqual([{}, {}, {}]) - }) - }) + test(description, () => { + const actualOutput = compile(query)(input) - describe('pick', () => { - test('should pick data from an array (single field)', () => { - expect(go(data, ['pick', ['get', 'name']])).toEqual([ - { name: 'Chris' }, - { name: 'Emily' }, - { name: 'Joe' }, - { name: 'Kevin' }, - { name: 'Michelle' }, - { name: 'Robert' }, - { name: 'Sarah' } - ]) - }) - - test('should pick data from an object', () => { - expect(go({ a: 1, b: 2, c: 3 }, ['pick', ['get', 'b']])).toEqual({ b: 2 }) - expect(go({ a: 1, b: 2, c: 3 }, ['pick', ['get', 'b'], ['get', 'a']])).toEqual({ - b: 2, - a: 1 + expect({ input, query, output: actualOutput }).toEqual({ input, query, output }) }) - }) - - test('should pick data from an array (multiple fields)', () => { - expect(go(data, ['pick', ['get', 'name'], ['get', 'city']])).toEqual([ - { name: 'Chris', city: 'New York' }, - { name: 'Emily', city: 'Atlanta' }, - { name: 'Joe', city: 'New York' }, - { name: 'Kevin', city: 'Atlanta' }, - { name: 'Michelle', city: 'Los Angeles' }, - { name: 'Robert', city: 'Manhattan' }, - { name: 'Sarah', city: 'New York' } - ]) - }) - - test('should pick data from an array (a single nested field)', () => { - expect(go(nestedData, ['pick', ['get', 'address', 'city']])).toEqual([ - { city: 'New York' }, - { city: 'Atlanta' }, - { city: 'New York' }, - { city: 'Atlanta' }, - { city: 'Los Angeles' }, - { city: 'Manhattan' }, - { city: 'New York' } - ]) - }) - - test('should pick data from an array (multiple fields with nested fields)', () => { - expect(go(nestedData, ['pick', ['get', 'name'], ['get', 'address', 'city']])).toEqual([ - { name: 'Chris', city: 'New York' }, - { name: 'Emily', city: 'Atlanta' }, - { name: 'Joe', city: 'New York' }, - { name: 'Kevin', city: 'Atlanta' }, - { name: 'Michelle', city: 'Los Angeles' }, - { name: 'Robert', city: 'Manhattan' }, - { name: 'Sarah', city: 'New York' } - ]) - }) - }) - - test('should group items by a key', () => { - expect(go(data, ['groupBy', ['get', 'city']])).toEqual({ - 'New York': [ - { name: 'Chris', age: 23, city: 'New York' }, - { name: 'Joe', age: 32, city: 'New York' }, - { name: 'Sarah', age: 31, city: 'New York' } - ], - Atlanta: [ - { name: 'Emily', age: 19, city: 'Atlanta' }, - { name: 'Kevin', age: 19, city: 'Atlanta' } - ], - 'Los Angeles': [{ name: 'Michelle', age: 27, city: 'Los Angeles' }], - Manhattan: [{ name: 'Robert', age: 45, city: 'Manhattan' }] - }) - }) - - test('should turn an array in an object by key', () => { - const users = [ - { id: 1, name: 'Joe' }, - { id: 2, name: 'Sarah' }, - { id: 3, name: 'Chris' } - ] - - expect(go(users, ['keyBy', ['get', 'id']])).toEqual({ - 1: { id: 1, name: 'Joe' }, - 2: { id: 2, name: 'Sarah' }, - 3: { id: 3, name: 'Chris' } - }) - }) - - test('should handle duplicate keys in keyBy', () => { - const users = [ - { id: 1, name: 'Joe' }, - { id: 2, name: 'Sarah' }, - { id: 1, name: 'Chris' } - ] - - // keep the first occurrence - expect(go(users, ['keyBy', ['get', 'id']])).toEqual({ - 1: { id: 1, name: 'Joe' }, - 2: { id: 2, name: 'Sarah' } - }) - }) - - test('should get nested data from an object', () => { - expect(go(friendsData, ['get', 'friends'])).toEqual(data) - }) - - test('should get nested data from an array with objects', () => { - expect(go(nestedData, ['map', ['get', 'address', 'city']])).toEqual([ - 'New York', - 'Atlanta', - 'New York', - 'Atlanta', - 'Los Angeles', - 'Manhattan', - 'New York' - ]) - }) - - test('should get unique values from a list', () => { - expect(go([2, 3, 2, 7, 1, 1], ['uniq'])).toEqual([2, 3, 7, 1]) - }) - - test('should get unique objects by key', () => { - // keep the first occurrence - expect(go(data, ['uniqBy', ['get', 'city']])).toEqual([ - { name: 'Chris', age: 23, city: 'New York' }, - { name: 'Emily', age: 19, city: 'Atlanta' }, - { name: 'Michelle', age: 27, city: 'Los Angeles' }, - { name: 'Robert', age: 45, city: 'Manhattan' } - ]) - }) - - test('should calculate the sum', () => { - expect(go([2, 3, 2, 7, 1, 1], ['sum'])).toEqual(16) - }) - - test('should round a value', () => { - expect(go(null, ['round', 23.1345])).toEqual(23) - expect(go(null, ['round', 23.761])).toEqual(24) - expect(go(null, ['round', 23.1345, 2])).toEqual(23.13) - expect(go(null, ['round', 23.1345, 3])).toEqual(23.135) - expect(go({ a: 23.1345 }, ['round', ['get', 'a']])).toEqual(23) - }) - - test('should round an array with values', () => { - expect(go([2.24, 3.77, 4.49], ['map', ['round', ['get']]])).toEqual([2, 4, 4]) - expect(go([2.24, 3.77, 4.49], ['map', ['round', ['get'], 1]])).toEqual([2.2, 3.8, 4.5]) - }) - - test('should calculate the product', () => { - expect(go([2, 3, 2, 7, 1, 1], ['prod'])).toEqual(84) - }) - - test('should calculate the average', () => { - expect(go([2, 3, 2, 7, 1], ['average'])).toEqual(3) - }) - - test('should count the size of an array', () => { - expect(go([], ['size'])).toEqual(0) - expect(go([1, 2, 3], ['size'])).toEqual(3) - expect(go([1, 2, 3, 4, 5], ['size'])).toEqual(5) + } }) +} - test('should extract the keys of an object', () => { - expect(go({ a: 2, b: 3 }, ['keys'])).toEqual(['a', 'b']) - }) +describe('error handling', () => { + test('should throw a helpful error when a pipe contains a compile time error', () => { + let actualErr = undefined + try { + go(data, ['foo', 42]) + } catch (err) { + actualErr = err + } - test('should extract the values of an object', () => { - expect(go({ a: 2, b: 3 }, ['values'])).toEqual([2, 3]) + expect(actualErr?.message).toBe("Unknown function 'foo'") }) - test('should limit data', () => { - expect(go(data, ['limit', 2])).toEqual([ - { name: 'Chris', age: 23, city: 'New York' }, - { name: 'Emily', age: 19, city: 'Atlanta' } - ]) - }) + test('should throw a helpful error when a pipe contains a runtime error', () => { + const scoreData = { + participants: [ + { name: 'Chris', age: 23, scores: [7.2, 5, 8.0] }, + { name: 'Emily', age: 19 }, + { name: 'Joe', age: 32, scores: [6.1, 8.1] } + ] + } + const query = ['pipe', ['get', 'participants'], ['map', ['pipe', ['get', 'scores'], ['sum']]]] - test('should process "not"', () => { - expect(go(data, ['not', 2])).toEqual(false) - expect(go({ a: false }, ['not', ['get', 'a']])).toEqual(true) - expect(go({ a: true }, ['not', ['get', 'a']])).toEqual(false) - expect(go({ nested: { a: false } }, ['not', ['get', 'nested', 'a']])).toEqual(true) - expect(go({ nested: { a: true } }, ['not', ['get', 'nested', 'a']])).toEqual(false) - - expect(go(data, ['filter', ['not', ['eq', ['get', 'city'], 'New York']]])).toEqual([ - { name: 'Emily', age: 19, city: 'Atlanta' }, - { name: 'Kevin', age: 19, city: 'Atlanta' }, - { name: 'Michelle', age: 27, city: 'Los Angeles' }, - { name: 'Robert', age: 45, city: 'Manhattan' } - ]) - }) + let actualErr = undefined + try { + go(scoreData, query) + } catch (err) { + actualErr = err + } - test('should process "exists"', () => { - expect(go({ a: false }, ['exists', ['get', 'a']])).toEqual(true) - expect(go({ a: null }, ['exists', ['get', 'a']])).toEqual(true) - expect(go({ a: 2 }, ['exists', ['get', 'a']])).toEqual(true) - expect(go({ a: 0 }, ['exists', ['get', 'a']])).toEqual(true) - expect(go({ a: '' }, ['exists', ['get', 'a']])).toEqual(true) - expect(go({ nested: { a: 2 } }, ['exists', ['get', 'nested', 'a']])).toEqual(true) - - expect(go({ a: undefined }, ['exists', ['get', 'a']])).toEqual(true) - expect(go({}, ['exists', ['get', 'a']])).toEqual(false) - expect(go({}, ['exists', ['get', 'nested', 'a']])).toEqual(false) - expect(go({}, ['exists', ['get', 'sort']])).toEqual(false) - - const detailsData = [ - { name: 'Chris', details: { age: 16 } }, - { name: 'Emily' }, - { name: 'Joe', details: { age: 18 } } - ] - expect(go(detailsData, ['filter', ['exists', ['get', 'details']]])).toEqual([ - { name: 'Chris', details: { age: 16 } }, - { name: 'Joe', details: { age: 18 } } + expect(actualErr?.message).toBe("Cannot read properties of null (reading 'reduce')") + expect(actualErr?.jsonquery).toEqual([ + { data: scoreData, query }, + { + data: scoreData.participants, + query: ['map', ['pipe', ['get', 'scores'], ['sum']]] + }, + { data: { name: 'Emily', age: 19 }, query: ['pipe', ['get', 'scores'], ['sum']] }, + { data: null, query: ['sum'] } ]) }) - test('should process function eq', () => { - expect(go({ a: 6 }, ['eq', ['get', 'a'], 6])).toEqual(true) - expect(go({ a: 6 }, ['eq', ['get', 'a'], 2])).toEqual(false) - expect(go({ a: 6 }, ['eq', ['get', 'a'], '6'])).toEqual(false) - expect(go({ a: 'Hi' }, ['eq', ['get', 'a'], 'Hi'])).toEqual(true) - expect(go({ a: 'Hi' }, ['eq', ['get', 'a'], 'Hello'])).toEqual(false) - }) - - test('should process function gt', () => { - expect(go({ a: 6 }, ['gt', ['get', 'a'], 5])).toEqual(true) - expect(go({ a: 6 }, ['gt', ['get', 'a'], 6])).toEqual(false) - expect(go({ a: 6 }, ['gt', ['get', 'a'], 7])).toEqual(false) - }) - - test('should process function gte', () => { - expect(go({ a: 6 }, ['gte', ['get', 'a'], 5])).toEqual(true) - expect(go({ a: 6 }, ['gte', ['get', 'a'], 6])).toEqual(true) - expect(go({ a: 6 }, ['gte', ['get', 'a'], 7])).toEqual(false) - }) - - test('should process function lt', () => { - expect(go({ a: 6 }, ['lt', ['get', 'a'], 5])).toEqual(false) - expect(go({ a: 6 }, ['lt', ['get', 'a'], 6])).toEqual(false) - expect(go({ a: 6 }, ['lt', ['get', 'a'], 7])).toEqual(true) - }) - - test('should process function lte', () => { - expect(go({ a: 6 }, ['lte', ['get', 'a'], 5])).toEqual(false) - expect(go({ a: 6 }, ['lte', ['get', 'a'], 6])).toEqual(true) - expect(go({ a: 6 }, ['lte', ['get', 'a'], 7])).toEqual(true) + test('should not crash when sorting a list with nested arrays', () => { + expect(go([[3], [7], [4]], ['sort'])).toEqual([[3], [4], [7]]) + expect(go([[], [], []], ['sort'])).toEqual([[], [], []]) }) - test('should process function ne', () => { - expect(go({ a: 6 }, ['ne', ['get', 'a'], 6])).toEqual(false) - expect(go({ a: 6 }, ['ne', ['get', 'a'], 2])).toEqual(true) - expect(go({ a: 6 }, ['ne', ['get', 'a'], '6'])).toEqual(true) - expect(go({ a: 'Hi' }, ['ne', ['get', 'a'], 'Hi'])).toEqual(false) - expect(go({ a: 'Hi' }, ['ne', ['get', 'a'], 'Hello'])).toEqual(true) + test('should throw an error when calculating the sum of an empty array', () => { + expect(() => go([], ['sum'])).toThrow('Reduce of empty array with no initial value') }) - test('should process function add', () => { - expect(go({ a: 6, b: 2 }, ['add', ['get', 'a'], ['get', 'b']])).toEqual(8) + test('should throw an error when calculating the prod of an empty array', () => { + expect(() => go([], ['prod'])).toThrow('Reduce of empty array with no initial value') }) - test('should process function subtract', () => { - expect(go({ a: 6, b: 2 }, ['subtract', ['get', 'a'], ['get', 'b']])).toEqual(4) - }) - - test('should process function multiply', () => { - expect(go({ a: 6, b: 2 }, ['multiply', ['get', 'a'], ['get', 'b']])).toEqual(12) - }) - - test('should process function divide', () => { - expect(go({ a: 6, b: 2 }, ['divide', ['get', 'a'], ['get', 'b']])).toEqual(3) - }) - - test('should process function pow', () => { - expect(go({ a: 2, b: 3 }, ['pow', ['get', 'a'], ['get', 'b']])).toEqual(8) - expect(go({ a: 25, b: 1 / 2 }, ['pow', ['get', 'a'], ['get', 'b']])).toEqual(5) // sqrt - }) - - test('should process function mod (remainder)', () => { - expect(go({ a: 8, b: 3 }, ['mod', ['get', 'a'], ['get', 'b']])).toEqual(2) - }) - - test('should calculate the minimum value', () => { - expect(go([3, -4, 1, -7], ['min'])).toEqual(-7) - }) - - test('should calculate the absolute value', () => { - expect(go(null, ['abs', 2])).toEqual(2) - expect(go(null, ['abs', -2])).toEqual(2) - expect(go({ a: -3 }, ['abs', ['get', 'a']])).toEqual(3) - expect(go([3, -4, 1, -7], ['map', ['abs', ['get']]])).toEqual([3, 4, 1, 7]) - }) - - test('should process multiple operations', () => { - expect( - go(friendsData, [ - 'pipe', - ['get', 'friends'], - ['filter', ['eq', ['get', 'city'], 'New York']], - ['sort', ['get', 'age']], - ['map', ['get', 'name']], - ['limit', 2] - ]) - ).toEqual(['Chris', 'Sarah']) + test('should throw an error when calculating the average of an empty array', () => { + expect(() => go([], ['average'])).toThrow('Reduce of empty array with no initial value') }) +}) +describe('customization', () => { test('should extend with a custom function "times"', () => { const options = { functions: { @@ -892,53 +185,12 @@ describe('compile', () => { expect(go({ a: 2 }, ['aboutEq', ['get', 'a'], 2], options)).toEqual(true) expect(go({ a: 2 }, ['aboutEq', ['get', 'a'], '2'], options)).toEqual(true) }) +}) - test('should use functions to calculate a shopping cart', () => { - const data = [ - { name: 'bread', price: 2.5, quantity: 2 }, - { name: 'milk', price: 1.2, quantity: 3 } - ] - - expect( - go(data, ['pipe', ['map', ['multiply', ['get', 'price'], ['get', 'quantity']]], ['sum']]) - ).toEqual(8.6) - }) - - test('should be able to query the jmespath example', () => { - const options = { - functions: { - join: - (separator = ', ') => - (data: unknown[]) => - data.join(separator) - } - } - - const data = { - locations: [ - { name: 'Seattle', state: 'WA' }, - { name: 'New York', state: 'NY' }, - { name: 'Bellevue', state: 'WA' }, - { name: 'Olympia', state: 'WA' } - ] - } +test('should validate the compile test-suite against its JSON schema', () => { + const ajv = new Ajv({ allErrors: false }) + const valid = ajv.validate(schema, suite) - // locations[?state == 'WA'].name | sort(@) | {WashingtonCities: join(', ', @)} - expect( - go( - data, - [ - 'pipe', - ['get', 'locations'], - ['filter', ['eq', ['get', 'state'], 'WA']], - ['map', ['get', 'name']], - ['sort'], - ['object', { WashingtonCities: ['join'] }] - ], - options - ) - ).toEqual({ - WashingtonCities: 'Bellevue, Olympia, Seattle' - }) - }) + expect(ajv.errors).toEqual(null) + expect(valid).toEqual(true) }) diff --git a/src/functions.ts b/src/functions.ts index 78219f6..b950d87 100644 --- a/src/functions.ts +++ b/src/functions.ts @@ -167,7 +167,7 @@ export const functions: FunctionBuildersMap = { limit: (count: number) => (data: T[]) => - data.slice(0, count), + data.slice(0, Math.max(count, 0)), size: () => @@ -208,8 +208,8 @@ export const functions: FunctionBuildersMap = { return (data: unknown) => regex.test(getter(data) as string) }, - and: buildFunction((a, b) => a && b), - or: buildFunction((a, b) => a || b), + and: buildFunction((a, b) => !!(a && b)), + or: buildFunction((a, b) => !!(a || b)), not: buildFunction((a: unknown) => !a), exists: (path: JSONQueryFunction) => { const parentPath = path.slice(1) diff --git a/src/parse.test.ts b/src/parse.test.ts index f2042c2..a89c340 100644 --- a/src/parse.test.ts +++ b/src/parse.test.ts @@ -1,347 +1,69 @@ +import Ajv from 'ajv' import { describe, expect, test } from 'vitest' +import type { ParseTestException, ParseTestSuite } from '../test-suite/parse.test' +import suite from '../test-suite/parse.test.json' +import schema from '../test-suite/parse.test.schema.json' +import { compile } from './compile' import { parse } from './parse' import type { JSONQueryParseOptions } from './types' -describe('parse', () => { - describe('property', () => { - test('should parse a property without quotes', () => { - expect(parse('.name')).toEqual(['get', 'name']) - expect(parse('.AaZz_$')).toEqual(['get', 'AaZz_$']) - expect(parse('.AaZz09_$')).toEqual(['get', 'AaZz09_$']) - expect(parse('.9')).toEqual(['get', 9]) - expect(parse('.123')).toEqual(['get', 123]) - expect(parse('.0')).toEqual(['get', 0]) - expect(parse(' .name ')).toEqual(['get', 'name']) - expect(() => parse('.')).toThrow('Property expected (pos: 1)') - }) - - test('should throw an error in case of an invalid unquoted property', () => { - expect(() => parse('.01')).toThrow("Unexpected part '1'") - expect(() => parse('.1abc')).toThrow("Unexpected part 'abc'") - expect(() => parse('.[')).toThrow('Property expected (pos: 1)') - }) - - test('should parse a property with quotes', () => { - expect(parse('."name"')).toEqual(['get', 'name']) - expect(parse(' ."name" ')).toEqual(['get', 'name']) - expect(parse('."escape \\n \\"chars"')).toEqual(['get', 'escape \n "chars']) - }) - - test('should throw an error when a property misses an end quote', () => { - expect(() => parse('."name')).toThrow('Property expected (pos: 1)') - }) - - test('should throw an error when there is whitespace between the dot and the property name', () => { - expect(() => parse('. "name"')).toThrow('Property expected (pos: 1)') - expect(() => parse('."address" ."city"')).toThrow('Unexpected part \'."city"\' (pos: 11)') - expect(() => parse('.address .city')).toThrow("Unexpected part '.city' (pos: 9)") - }) - - test('should parse a nested property', () => { - expect(parse('.address.city')).toEqual(['get', 'address', 'city']) - expect(parse('."address"."city"')).toEqual(['get', 'address', 'city']) - expect(parse('."address"."city"')).toEqual(['get', 'address', 'city']) - expect(parse('.array.2')).toEqual(['get', 'array', 2]) - }) - - test('should throw an error in case of an invalid property', () => { - expect(() => parse('.foo#')).toThrow("Unexpected part '#'") - expect(() => parse('.foo#bar')).toThrow("Unexpected part '#bar'") - }) - }) - - describe('function', () => { - test('should parse a function without arguments', () => { - expect(parse('sort()')).toEqual(['sort']) - expect(parse('sort( )')).toEqual(['sort']) - expect(parse('sort ( )')).toEqual(['sort']) - expect(parse(' sort ( ) ')).toEqual(['sort']) - }) - - test('should parse a function with one argument', () => { - expect(parse('sort(.age)')).toEqual(['sort', ['get', 'age']]) - expect(parse('sort(get())')).toEqual(['sort', ['get']]) - expect(parse('sort ( .age )')).toEqual(['sort', ['get', 'age']]) - }) - - test('should parse a function with multiple arguments', () => { - expect(parse('sort(.age, "desc")')).toEqual(['sort', ['get', 'age'], 'desc']) - expect(parse('sort(get(), "desc")')).toEqual(['sort', ['get'], 'desc']) - }) - - test('should parse a custom function', () => { - const options: JSONQueryParseOptions = { - functions: { customFn: true } - } - - expect(parse('customFn(.age, "desc")', options)).toEqual(['customFn', ['get', 'age'], 'desc']) - }) - - test('should throw an error in case of an unknown function name', () => { - expect(() => parse('foo(42)')).toThrow("Unknown function 'foo' (pos: 4)") - }) - - test('should throw an error when the end bracket is missing', () => { - expect(() => parse('sort(.age, "desc"')).toThrow("Character ')' expected (pos: 17)") - }) - - test('should throw an error when a comma is missing', () => { - expect(() => parse('sort(.age "desc")')).toThrow("Character ',' expected (pos: 10)") - }) - }) - - describe('operator', () => { - test('should parse an operator', () => { - expect(parse('.score==8')).toEqual(['eq', ['get', 'score'], 8]) - expect(parse('.score == 8')).toEqual(['eq', ['get', 'score'], 8]) - expect(parse('.score < 8')).toEqual(['lt', ['get', 'score'], 8]) - expect(parse('.score <= 8')).toEqual(['lte', ['get', 'score'], 8]) - expect(parse('.score > 8')).toEqual(['gt', ['get', 'score'], 8]) - expect(parse('.score >= 8')).toEqual(['gte', ['get', 'score'], 8]) - expect(parse('.score != 8')).toEqual(['ne', ['get', 'score'], 8]) - expect(parse('.score + 8')).toEqual(['add', ['get', 'score'], 8]) - expect(parse('.score - 8')).toEqual(['subtract', ['get', 'score'], 8]) - expect(parse('.score * 8')).toEqual(['multiply', ['get', 'score'], 8]) - expect(parse('.score / 8')).toEqual(['divide', ['get', 'score'], 8]) - expect(parse('.score ^ 8')).toEqual(['pow', ['get', 'score'], 8]) - expect(parse('.score % 8')).toEqual(['mod', ['get', 'score'], 8]) - expect(parse('.name in ["Joe", "Sarah"]')).toEqual([ - 'in', - ['get', 'name'], - ['array', 'Joe', 'Sarah'] - ]) - expect(parse('.name not in ["Joe", "Sarah"]')).toEqual([ - 'not in', - ['get', 'name'], - ['array', 'Joe', 'Sarah'] - ]) - }) - - test('should parse an operator having the same name as a function', () => { - expect(parse('0 and 1')).toEqual(['and', 0, 1]) - expect(parse('.a and .b')).toEqual(['and', ['get', 'a'], ['get', 'b']]) - }) - - test('should parse nested operators', () => { - expect(parse('(.a == "A") and (.b == "B")')).toEqual([ - 'and', - ['eq', ['get', 'a'], 'A'], - ['eq', ['get', 'b'], 'B'] - ]) - - expect(parse('(.a == "A") or (.b == "B")')).toEqual([ - 'or', - ['eq', ['get', 'a'], 'A'], - ['eq', ['get', 'b'], 'B'] - ]) - - expect(parse('(.a == "A") or ((.b == "B") and (.c == "C"))')).toEqual([ - 'or', - ['eq', ['get', 'a'], 'A'], - ['and', ['eq', ['get', 'b'], 'B'], ['eq', ['get', 'c'], 'C']] - ]) - - expect(parse('(.a * 2) + 3')).toEqual(['add', ['multiply', ['get', 'a'], 2], 3]) - expect(parse('3 + (.a * 2)')).toEqual(['add', 3, ['multiply', ['get', 'a'], 2]]) - }) - - test('should throw an error when using multiple operators without brackets', () => { - expect(() => parse('.a == "A" and .b == "B"')).toThrow('Unexpected part \'and .b == "B"\'') - expect(() => parse('(.a == "A") and (.b == "B") and (.C == "C")')).toThrow( - 'Unexpected part \'and (.C == "C")\' (pos: 28)' - ) - expect(() => parse('.a + 2 * 3')).toThrow("Unexpected part '* 3' (pos: 7)") - }) - - test('should throw an error in case of an unknown operator', () => { - expect(() => parse('.a === "A"')).toThrow('Value expected (pos: 5)') - expect(() => parse('.a <> "A"')).toThrow('Value expected (pos: 4)') - }) - - test('should throw an error in case a missing right hand side', () => { - expect(() => parse('.a ==')).toThrow('Value expected (pos: 5)') - }) - - test('should throw an error in case a missing left and right hand side', () => { - expect(() => parse('+')).toThrow('Value expected (pos: 0)') - expect(() => parse(' +')).toThrow('Value expected (pos: 1)') - }) - - test('should parse a custom operator', () => { - const options: JSONQueryParseOptions = { - operators: { aboutEq: '~=' } - } - - expect(parse('.score ~= 8', options)).toEqual(['aboutEq', ['get', 'score'], 8]) - }) - }) - - describe('pipe', () => { - test('should parse a pipe', () => { - expect(parse('.friends | sort(.age)')).toEqual([ - 'pipe', - ['get', 'friends'], - ['sort', ['get', 'age']] - ]) - - expect(parse('.friends | sort(.age) | filter(.age >= 18)')).toEqual([ - 'pipe', - ['get', 'friends'], - ['sort', ['get', 'age']], - ['filter', ['gte', ['get', 'age'], 18]] - ]) - }) - - test('should throw an error when a value is missing after a pipe', () => { - expect(() => parse('.friends |')).toThrow('Value expected (pos: 10)') - }) - - test('should throw an error when a value is missing before a pipe', () => { - expect(() => parse('| .friends ')).toThrow('Value expected (pos: 0)') - }) - }) - - describe('parentheses', () => { - test('should parse parentheses', () => { - expect(parse('(.friends)')).toEqual(['get', 'friends']) - expect(parse('( .friends)')).toEqual(['get', 'friends']) - expect(parse('(.friends )')).toEqual(['get', 'friends']) - expect(parse('(.age == 18)')).toEqual(['eq', ['get', 'age'], 18]) - expect(parse('(42)')).toEqual(42) - expect(parse(' ( 42 ) ')).toEqual(42) - expect(parse('((42))')).toEqual(42) - }) - - test('should throw an error when missing closing parenthesis', () => { - expect(() => parse('(.friends')).toThrow("Character ')' expected (pos: 9)") - }) - }) - - describe('object', () => { - test('should parse a basic object', () => { - expect(parse('{}')).toEqual(['object', {}]) - expect(parse('{ }')).toEqual(['object', {}]) - expect(parse('{a:1}')).toEqual(['object', { a: 1 }]) - expect(parse('{a1:1}')).toEqual(['object', { a1: 1 }]) - expect(parse('{AaZz_$019:1}')).toEqual(['object', { AaZz_$019: 1 }]) - expect(parse('{ a : 1 }')).toEqual(['object', { a: 1 }]) - expect(parse('{a:1,b:2}')).toEqual(['object', { a: 1, b: 2 }]) - expect(parse('{ a : 1 , b : 2 }')).toEqual(['object', { a: 1, b: 2 }]) - expect(parse('{ "a" : 1 , "b" : 2 }')).toEqual(['object', { a: 1, b: 2 }]) - expect(parse('{2:"two"}')).toEqual(['object', { 2: 'two' }]) - expect(parse('{null:null}')).toEqual(['object', { null: null }]) - expect(parse('{"":"empty"}')).toEqual(['object', { '': 'empty' }]) - }) - - test('should parse a larger object', () => { - expect( - parse(`{ - name: .name, - city: .address.city, - averageAge: map(.age) | average() - }`) - ).toEqual([ - 'object', - { - name: ['get', 'name'], - city: ['get', 'address', 'city'], - averageAge: ['pipe', ['map', ['get', 'age']], ['average']] +function isTestException(test: unknown): test is ParseTestException { + return !!test && typeof (test as Record).throws === 'string' +} + +const groupByCategory = compile(['groupBy', ['get', 'category']]) +const testsByCategory = groupByCategory(suite.groups) as Record + +for (const [category, testGroups] of Object.entries(testsByCategory)) { + describe(category, () => { + for (const group of testGroups) { + describe(group.description, () => { + for (const currentTest of group.tests) { + const description = `input = '${currentTest.input}'` + + if (isTestException(currentTest)) { + test(description, () => { + const { input, throws } = currentTest + + expect(() => parse(input)).toThrow(throws) + }) + } else { + test(description, () => { + const { input, output } = currentTest + + expect(parse(input)).toEqual(output) + }) + } } - ]) - }) - - test('should throw an error when missing closing parenthesis', () => { - expect(() => parse('{a:1')).toThrow("Character '}' expected (pos: 4)") - }) - - test('should throw an error when missing a comma', () => { - expect(() => parse('{a:1 b:2}')).toThrow("Character ',' expected (pos: 5)") - }) - - test('should throw an error when missing a colon', () => { - expect(() => parse('{a')).toThrow("Character ':' expected (pos: 2)") - }) - - test('should throw an error when missing a key', () => { - expect(() => parse('{{')).toThrow('Key expected (pos: 1)') - expect(() => parse('{a:2,{')).toThrow('Key expected (pos: 5)') - }) - - test('should throw an error when missing a value', () => { - expect(() => parse('{a:')).toThrow('Value expected (pos: 3)') - expect(() => parse('{a:2,b:}')).toThrow('Value expected (pos: 7)') - }) - - test('should throw an error in case of a trailing comma', () => { - expect(() => parse('{a:2,}')).toThrow('Key expected (pos: 5)') - }) - }) - - describe('array', () => { - test('should parse an array', () => { - expect(parse('[]')).toEqual(['array']) - expect(parse(' [ ] ')).toEqual(['array']) - expect(parse('[1, 2, 3]')).toEqual(['array', 1, 2, 3]) - expect(parse(' [ 1 , 2 , 3 ] ')).toEqual(['array', 1, 2, 3]) - expect(parse('[(1 + 3), 2, 4]')).toEqual(['array', ['add', 1, 3], 2, 4]) - expect(parse('[2, (1 + 2), 4]')).toEqual(['array', 2, ['add', 1, 2], 4]) - }) - - test('should throw an error when missing closing bracket', () => { - expect(() => parse('[1,2')).toThrow("Character ']' expected (pos: 4)") - }) - - test('should throw an error when missing a comma', () => { - expect(() => parse('[1 2]')).toThrow("Character ',' expected (pos: 3)") - }) - - test('should throw an error when missing a value', () => { - expect(() => parse('[1,')).toThrow('Value expected (pos: 3)') - }) - - test('should throw an error in case of a trailing comma', () => { - expect(() => parse('[1,2,]')).toThrow('Value expected (pos: 5)') - }) + }) + } }) +} - test('should parse a string', () => { - expect(parse('"hello"')).toEqual('hello') - expect(parse(' "hello"')).toEqual('hello') - expect(parse('"hello" ')).toEqual('hello') - expect(() => parse('"hello')).toThrow('Value expected (pos: 0)') - }) +describe('customization', () => { + test('should parse a custom function', () => { + const options: JSONQueryParseOptions = { + functions: { customFn: true } + } - test('should parse a number', () => { - expect(parse('42')).toEqual(42) - expect(parse('-42')).toEqual(-42) - expect(parse('2.3')).toEqual(2.3) - expect(parse('-2.3')).toEqual(-2.3) - expect(parse('2.3e2')).toEqual(230) - expect(parse('2.3e+2')).toEqual(230) - expect(parse('2.3e-2')).toEqual(0.023) - expect(parse('2.3E+2')).toEqual(230) - expect(parse('2.3E-2')).toEqual(0.023) + expect(parse('customFn(.age, "desc")', options)).toEqual(['customFn', ['get', 'age'], 'desc']) }) - test('should parse a boolean', () => { - expect(parse('true')).toEqual(true) - expect(parse('false')).toEqual(false) - }) + test('should parse a custom operator', () => { + const options: JSONQueryParseOptions = { + operators: { aboutEq: '~=' } + } - test('should parse null', () => { - expect(parse('null')).toEqual(null) - }) - - test('should throw an error in case of garbage at the end', () => { - expect(() => parse('null 2')).toThrow("Unexpected part '2' (pos: 5)") - expect(() => parse('sort() 2')).toThrow("Unexpected part '2' (pos: 7)") + expect(parse('.score ~= 8', options)).toEqual(['aboutEq', ['get', 'score'], 8]) }) +}) - test('should skip whitespace characters', () => { - expect(parse(' \n\r\t"hello" \n\r\t')).toEqual('hello') - }) +describe('test-suite', () => { + test('should validate the parse test-suite against its JSON schema', () => { + const ajv = new Ajv({ allErrors: false }) + const valid = ajv.validate(schema, suite) - test('should throw when the query is empty', () => { - expect(() => parse('')).toThrow('Value expected (pos: 0)') + expect(ajv.errors).toEqual(null) + expect(valid).toEqual(true) }) }) diff --git a/src/stringify.test.ts b/src/stringify.test.ts index 2a98028..e166fcb 100644 --- a/src/stringify.test.ts +++ b/src/stringify.test.ts @@ -1,193 +1,41 @@ +import Ajv from 'ajv' import { describe, expect, test } from 'vitest' +import type { StringifyTestSuite } from '../test-suite/stringify.test' +import suite from '../test-suite/stringify.test.json' +import schema from '../test-suite/stringify.test.schema.json' +import { compile } from './compile' import { stringify } from './stringify' -import type { JSONQueryStringifyOptions } from './types' -describe('stringify', () => { - test('should stringify a function', () => { - expect(stringify(['sort', ['get', 'age'], 'desc'])).toEqual('sort(.age, "desc")') - expect(stringify(['filter', ['gt', ['get', 'age'], 18]])).toEqual('filter(.age > 18)') - }) +const groupByCategory = compile(['groupBy', ['get', 'category']]) +const testsByCategory = groupByCategory(suite.groups) as Record< + string, + StringifyTestSuite['groups'] +> - test('should stringify a function with indentation', () => { - expect(stringify(['sort', ['get', 'age'], 'desc'], { maxLineLength: 4 })).toEqual( - 'sort(\n .age,\n "desc"\n)' - ) - }) +for (const [category, testGroups] of Object.entries(testsByCategory)) { + describe(category, () => { + for (const group of testGroups) { + describe(group.description, () => { + for (const currentTest of group.tests) { + const description = `input = ${JSON.stringify(currentTest.input)}` - test('should stringify a nested function with indentation', () => { - expect( - stringify(['object', { sorted: ['sort', ['get', 'age'], 'desc'] }], { maxLineLength: 4 }) - ).toEqual('{\n sorted: sort(\n .age,\n "desc"\n )\n}') - }) + test(description, () => { + const { input, output } = currentTest - test('should stringify a nested function having one argument with indentation', () => { - expect( - stringify(['map', ['object', { name: ['get', 'name'], city: ['get', 'address', 'city'] }]], { - maxLineLength: 4 + expect(stringify(input, group.options)).toEqual(output) + }) + } }) - ).toEqual('map({\n name: .name,\n city: .address.city\n})') - }) - - test('should stringify a property', () => { - expect(stringify(['get'])).toEqual('get()') - expect(stringify(['get', 'age'])).toEqual('.age') - expect(stringify(['get', 'address', 'city'])).toEqual('.address.city') - expect(stringify(['get', 'with space'])).toEqual('."with space"') - expect(stringify(['get', 'with special !'])).toEqual('."with special !"') - }) - - test('should stringify an operator', () => { - expect(stringify(['add', 2, 3])).toEqual('(2 + 3)') - }) - - test('should stringify an custom operator', () => { - const options: JSONQueryStringifyOptions = { - operators: { aboutEq: '~=' } } - - expect(stringify(['aboutEq', 2, 3], options)).toEqual('(2 ~= 3)') - expect(stringify(['filter', ['aboutEq', 2, 3]], options)).toEqual('filter(2 ~= 3)') - expect(stringify(['object', { result: ['aboutEq', 2, 3] }], options)).toEqual( - '{ result: (2 ~= 3) }' - ) - expect(stringify(['eq', 2, 3], options)).toEqual('(2 == 3)') - }) - - test('should stringify a pipe', () => { - expect(stringify(['pipe', ['get', 'age'], ['average']])).toEqual('.age | average()') - }) - - test('should stringify a pipe with indentation', () => { - expect(stringify(['pipe', ['get', 'age'], ['average']], { maxLineLength: 10 })).toEqual( - '.age\n | average()' - ) - }) - - test('should stringify a nested pipe with indentation', () => { - const query = ['object', { nested: ['pipe', ['get', 'age'], ['average']] }] - expect(stringify(query, { maxLineLength: 10 })).toEqual('{\n nested: .age\n | average()\n}') - }) - - test('should stringify an object', () => { - expect( - stringify(['object', { name: ['get', 'name'], city: ['get', 'address', 'city'] }]) - ).toEqual('{ name: .name, city: .address.city }') - }) - - test('should stringify an object with indentation', () => { - const query = ['object', { name: ['get', 'name'], city: ['get', 'address', 'city'] }] - - expect(stringify(query, { maxLineLength: 20 })).toEqual( - '{\n name: .name,\n city: .address.city\n}' - ) }) +} - test('should stringify a nested object with indentation', () => { - const query = [ - 'object', - { - name: ['get', 'name'], - address: [ - 'object', - { - city: ['get', 'city'], - street: ['get', 'street'] - } - ] - } - ] +describe('test-suite', () => { + test('should validate the stringify test-suite against its JSON schema', () => { + const ajv = new Ajv({ allErrors: false }) + const valid = ajv.validate(schema, suite) - expect(stringify(query, { maxLineLength: 4 })).toEqual( - '{\n name: .name,\n address: {\n city: .city,\n street: .street\n }\n}' - ) - }) - - test('should stringify an object with custom indentation', () => { - const query = ['object', { name: ['get', 'name'], city: ['get', 'address', 'city'] }] - - expect(stringify(query, { maxLineLength: 20, indentation: ' ' })).toEqual( - '{\n name: .name,\n city: .address.city\n}' - ) - - expect(stringify(query, { maxLineLength: 20, indentation: '\t' })).toEqual( - '{\n\tname: .name,\n\tcity: .address.city\n}' - ) - }) - - test('should stringify an array', () => { - expect(stringify(['array', 1, 2, 3])).toEqual('[1, 2, 3]') - expect(stringify(['array', ['add', 1, 2], 4, 5])).toEqual('[(1 + 2), 4, 5]') - expect(stringify(['filter', ['in', ['get', 'age'], ['array', 19, 23]]])).toEqual( - 'filter(.age in [19, 23])' - ) - }) - - test('should stringify an array with indentation', () => { - expect(stringify(['array', 1, 2, 3], { maxLineLength: 4 })).toEqual('[\n 1,\n 2,\n 3\n]') - }) - - test('should stringify a nested array with indentation', () => { - expect(stringify(['object', { array: ['array', 1, 2, 3] }], { maxLineLength: 4 })).toEqual( - '{\n array: [\n 1,\n 2,\n 3\n ]\n}' - ) - }) - - test('should stringify a composed query (1)', () => { - expect( - stringify(['pipe', ['map', ['multiply', ['get', 'price'], ['get', 'quantity']]], ['sum']]) - ).toEqual('map(.price * .quantity) | sum()') - }) - - test('should stringify a composed query (2)', () => { - expect( - stringify([ - 'pipe', - ['get', 'friends'], - ['filter', ['eq', ['get', 'city'], 'New York']], - ['sort', ['get', 'age']], - ['pick', ['get', 'name'], ['get', 'age']] - ]) - ).toEqual(`.friends - | filter(.city == "New York") - | sort(.age) - | pick(.name, .age)`) - }) - - test('should stringify a composed query (3)', () => { - expect( - stringify(['filter', ['and', ['gte', ['get', 'age'], 23], ['lte', ['get', 'age'], 27]]]) - ).toEqual('filter((.age >= 23) and (.age <= 27))') - }) - - test('should stringify a composed query (4)', () => { - expect( - stringify([ - 'pipe', - ['get', 'friends'], - [ - 'object', - { - names: ['map', ['get', 'name']], - count: ['size'], - averageAge: ['pipe', ['map', ['get', 'age']], ['average']] - } - ] - ]) - ).toEqual( - '.friends\n | {\n names: map(.name),\n count: size(),\n averageAge: map(.age) | average()\n }' - ) - }) - - test('should stringify a composed query (5)', () => { - expect( - stringify([ - 'object', - { - name: ['get', 'name'], - city: ['get', 'address', 'city'], - averageAge: ['pipe', ['map', ['get', 'age']], ['average']] - } - ]) - ).toEqual('{\n name: .name,\n city: .address.city,\n averageAge: map(.age) | average()\n}') + expect(ajv.errors).toEqual(null) + expect(valid).toEqual(true) }) }) diff --git a/test-suite/README.md b/test-suite/README.md new file mode 100644 index 0000000..4862660 --- /dev/null +++ b/test-suite/README.md @@ -0,0 +1,34 @@ +# JSON Query Test Suite + +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. + +The test-suite contains three sections: +- [`compile.test.json`](./compile.test.json) tests verifying the behavior of the compiler, the query engine, i.e.: + + ```js + import { compile } from '@jsonquerylang/jsonquery' + + const queryIt = compile(["sort"]) + const result = queryIt([3, 1, 5]) + // result should be [1, 3, 5] + ``` + +- [`parse.test.json`](./parse.test.json) tests verifying the parser that parses the text format into the JSON format, i.e.: + + ```js + import { parse } from '@jsonquerylang/jsonquery' + + const query = parse('filter(.age > 65)') + // query should be ["filter", ["gt", ["get", "age"], 65]] + ``` + +- [`stringify.test.json`](./stringify.test.json) tests converting the JSON format into the test format (including indentation), i.e.: + + ```js + import { stringify } from '@jsonquerylang/jsonquery' + + const text = stringify(["sort", ["get", "age"], "desc"]) + // text should be 'sort(.age, "desc")' + ``` + +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. diff --git a/test-suite/compile.test.d.ts b/test-suite/compile.test.d.ts new file mode 100644 index 0000000..dbf38d8 --- /dev/null +++ b/test-suite/compile.test.d.ts @@ -0,0 +1,14 @@ +import type { JSONQuery } from '../src/types' + +export interface CompileTest { + category: string + description: string + input: unknown + query: JSONQuery + output: unknown +} + +export interface CompileTestSuite { + updated: string + tests: CompileTest[] +} diff --git a/test-suite/compile.test.json b/test-suite/compile.test.json new file mode 100644 index 0000000..4089572 --- /dev/null +++ b/test-suite/compile.test.json @@ -0,0 +1,1191 @@ +{ + "source": "https://github.com/jsonquerylang/jsonquery/tree/main/test-suite/compile.test.json", + "updated": "2024-11-11T09:00:00Z", + "tests": [ + { + "category": "pipe", + "description": "should execute a pipe (1)", + "input": [{ "user": { "name": "Joe" } }], + "query": ["pipe", ["get", "0"], ["get", "user"], ["get", "name"]], + "output": "Joe" + }, + { + "category": "pipe", + "description": "should execute a pipe (2)", + "input": [1, -2, 3], + "query": ["pipe", ["filter", ["gte", ["get"], 0]], ["sum"]], + "output": 4 + }, + { + "category": "pipe", + "description": "should execute an empty pipe", + "input": [1, 2, 3], + "query": ["pipe"], + "output": [1, 2, 3] + }, + + { + "category": "object", + "description": "should create a static object", + "input": null, + "query": ["object", { "a": 2, "b": 3 }], + "output": { "a": 2, "b": 3 } + }, + { + "category": "object", + "description": "should create a dynamic object with getters", + "input": { "name": "Joe", "age": 23, "city": "New York" }, + "query": ["object", { "firstName": ["get", "name"], "age": ["get", "age"] }], + "output": { "firstName": "Joe", "age": 23 } + }, + { + "category": "object", + "description": "should create an object containing pipelines", + "input": [1, -2, 3], + "query": [ + "object", + { + "total": ["pipe", ["filter", ["gte", ["get"], 0]], ["sum"]] + } + ], + "output": { + "total": 4 + } + }, + + { + "category": "array", + "description": "should create a static array", + "input": null, + "query": ["array", 1, 2, 3], + "output": [1, 2, 3] + }, + { + "category": "array", + "description": "should create a dynamic array (1)", + "input": { "name": "Joe", "age": 23, "city": "New York" }, + "query": ["array", ["get", "name"], ["get", "age"]], + "output": ["Joe", 23] + }, + { + "category": "array", + "description": "should create a dynamic array (2)", + "input": null, + "query": ["array", ["add", 10, 9], 23], + "output": [19, 23] + }, + + { + "category": "get", + "description": "should get a path with a single property as string", + "input": { "name": "Joe" }, + "query": ["get", "name"], + "output": "Joe" + }, + { + "category": "get", + "description": "should get the full object itself", + "input": { "name": "Joe" }, + "query": ["get"], + "output": { "name": "Joe" } + }, + { + "category": "get", + "description": "should return null in case of a non existing path", + "input": {}, + "query": ["get", "foo", "bar"], + "output": null + }, + { + "category": "get", + "description": "should get a path using function get", + "input": { "name": "Joe" }, + "query": ["get", "name"], + "output": "Joe" + }, + { + "category": "get", + "description": "should get a value 0", + "input": { "value": 0 }, + "query": ["get", "value"], + "output": 0 + }, + { + "category": "get", + "description": "should get a nested value 0", + "input": { "nested": { "value": 0 } }, + "query": ["get", "nested", "value"], + "output": 0 + }, + { + "category": "get", + "description": "should get a value false", + "input": { "value": false }, + "query": ["get", "value"], + "output": false + }, + { + "category": "get", + "description": "should get a path that has the same name as a function", + "input": { "sort": "Joe" }, + "query": ["get", "sort"], + "output": "Joe" + }, + { + "category": "get", + "description": "should get a nested value that has the same name as a function", + "input": { "sort": { "name": "Joe" } }, + "query": ["get", "sort", "name"], + "output": "Joe" + }, + { + "category": "get", + "description": "should get in item from an array (1)", + "input": ["A", "B", "C"], + "query": ["get", 1], + "output": "B" + }, + { + "category": "get", + "description": "should get in item from an array (2)", + "input": { "arr": ["A", "B", "C"] }, + "query": ["get", "arr", 1], + "output": "B" + }, + { + "category": "get", + "description": "should get in item from an array (3)", + "input": [{ "text": "A" }, { "text": "B" }, { "text": "C" }], + "query": ["get", 1, "text"], + "output": "B" + }, + + { + "category": "filter", + "description": "should filter an array with booleans and null", + "input": [ + { "id": 1, "admin": true }, + { "id": 2 }, + { "id": 3, "admin": true }, + { "id": 4, "admin": false } + ], + "query": ["filter", ["get", "admin"]], + "output": [{ "id": 1, "admin": true }, { "id": 3, "admin": true }] + }, + { + "category": "filter", + "description": "should filter an array with numbers", + "input": [-1, 0, 1, 2, 3], + "query": ["filter", ["get"]], + "output": [-1, 1, 2, 3] + }, + + { + "category": "sort", + "description": "should sort an array with numbers", + "input": [5, 2, 3], + "query": ["sort"], + "output": [2, 3, 5] + }, + { + "category": "sort", + "description": "should sort an array with numbers (asc)", + "input": [5, 2, 3], + "query": ["sort", ["get"], "asc"], + "output": [2, 3, 5] + }, + { + "category": "sort", + "description": "should sort an array with numbers (desc)", + "input": [5, 2, 3], + "query": ["sort", ["get"], "desc"], + "output": [5, 3, 2] + }, + { + "category": "sort", + "description": "should sort an array with strings", + "input": ["C", "c", "b", "a", "B", "A"], + "query": ["sort"], + "output": ["A", "B", "C", "a", "b", "c"] + }, + { + "category": "sort", + "description": "should sort an array objects", + "input": [{ "score": -2 }, { "score": 5 }, { "score": 3 }], + "query": ["sort", ["get", "score"]], + "output": [{ "score": -2 }, { "score": 3 }, { "score": 5 }] + }, + { + "category": "sort", + "description": "should sort an array objects (desc)", + "input": [{ "score": -2 }, { "score": 5 }, { "score": 3 }], + "query": ["sort", ["get", "score"], "desc"], + "output": [{ "score": 5 }, { "score": 3 }, { "score": -2 }] + }, + { + "category": "sort", + "description": "should do nothing when sorting objects without a getter", + "input": [{ "a": 1 }, { "c": 3 }, { "b": 2 }], + "query": ["sort"], + "output": [{ "a": 1 }, { "c": 3 }, { "b": 2 }] + }, + + { + "category": "pick", + "description": "should pick one property from an object", + "input": { "name": "Joe", "age": 23, "city": "New York" }, + "query": ["pick", ["get", "name"]], + "output": { "name": "Joe" } + }, + { + "category": "pick", + "description": "should pick multiple properties from an object", + "input": { "name": "Joe", "age": 23, "city": "New York" }, + "query": ["pick", ["get", "name"], ["get", "city"]], + "output": { "name": "Joe", "city": "New York" } + }, + { + "category": "pick", + "description": "should pick nested properties from an object", + "input": { "name": "Joe", "age": 23, "address": { "city": "New York" } }, + "query": ["pick", ["get", "name"], ["get", "address", "city"]], + "output": { "name": "Joe", "city": "New York" } + }, + { + "category": "pick", + "description": "should pick one property from an array", + "input": [ + { "name": "Joe", "age": 23, "city": "New York" }, + { "name": "Sarah", "age": 21, "city": "Amsterdam" } + ], + "query": ["pick", ["get", "name"]], + "output": [{ "name": "Joe" }, { "name": "Sarah" }] + }, + { + "category": "pick", + "description": "should pick multiple properties from an array", + "input": [ + { "name": "Joe", "age": 23, "city": "New York" }, + { "name": "Sarah", "age": 21, "city": "Amsterdam" } + ], + "query": ["pick", ["get", "name"], ["get", "city"]], + "output": [{ "name": "Joe", "city": "New York" }, { "name": "Sarah", "city": "Amsterdam" }] + }, + { + "category": "pick", + "description": "should pick nested properties from an array", + "input": [ + { "name": "Joe", "age": 23, "address": { "city": "New York" } }, + { "name": "Sarah", "age": 21, "address": { "city": "Amsterdam" } } + ], + "query": ["pick", ["get", "name"], ["get", "address", "city"]], + "output": [{ "name": "Joe", "city": "New York" }, { "name": "Sarah", "city": "Amsterdam" }] + }, + + { + "category": "map", + "description": "should map an array with objects", + "input": [ + { "name": "Joe", "age": 23 }, + { "name": "Oliver", "age": 27 }, + { "name": "Sarah", "age": 21 } + ], + "query": ["map", ["get", "name"]], + "output": ["Joe", "Oliver", "Sarah"] + }, + { + "category": "map", + "description": "should map an array with numbers", + "input": [3, -4, 1, -7], + "query": ["map", ["abs", ["get"]]], + "output": [3, 4, 1, 7] + }, + + { + "category": "groupBy", + "description": "should group items by a key", + "input": [ + { "name": "Joe", "city": "New York" }, + { "name": "Oliver", "city": "Amsterdam" }, + { "name": "Sarah", "city": "Amsterdam" } + ], + "query": ["groupBy", ["get", "city"]], + "output": { + "New York": [{ "name": "Joe", "city": "New York" }], + "Amsterdam": [ + { "name": "Oliver", "city": "Amsterdam" }, + { "name": "Sarah", "city": "Amsterdam" } + ] + } + }, + + { + "category": "keyBy", + "description": "should turn an array in an object by key", + "input": [ + { "id": 1, "name": "Joe" }, + { "id": 2, "name": "Oliver" }, + { "id": 3, "name": "Sarah" } + ], + "query": ["keyBy", ["get", "id"]], + "output": { + "1": { "id": 1, "name": "Joe" }, + "2": { "id": 2, "name": "Oliver" }, + "3": { "id": 3, "name": "Sarah" } + } + }, + { + "category": "keyBy", + "description": "should handle duplicate keys in keyBy, keeping the first", + "input": [ + { "id": 1, "name": "Joe" }, + { "id": 2, "name": "Oliver" }, + { "id": 1, "name": "Sarah" } + ], + "query": ["keyBy", ["get", "id"]], + "output": { + "1": { "id": 1, "name": "Joe" }, + "2": { "id": 2, "name": "Oliver" } + } + }, + + { + "category": "keys", + "description": "should extract the keys of an object", + "input": { "a": 2, "b": 3 }, + "query": ["keys"], + "output": ["a", "b"] + }, + + { + "category": "values", + "description": "should extract the values of an object", + "input": { "a": 2, "b": 3 }, + "query": ["values"], + "output": [2, 3] + }, + + { + "category": "flatten", + "description": "should flatten an array", + "input": [[1, 2], [3, 4, 5]], + "query": ["flatten"], + "output": [1, 2, 3, 4, 5] + }, + { + "category": "flatten", + "description": "should not flatten arrays inside arrays", + "input": [[1, [2, 3]]], + "query": ["flatten"], + "output": [1, [2, 3]] + }, + + { + "category": "uniq", + "description": "should get unique values from a list with numbers", + "input": [2, 3, 2, 7, 1, 1], + "query": ["uniq"], + "output": [2, 3, 7, 1] + }, + { + "category": "uniq", + "description": "should get unique values from a list with strings", + "input": ["hi", "hello", "hi", "HI", "bye", "bye"], + "query": ["uniq"], + "output": ["hi", "hello", "HI", "bye"] + }, + + { + "category": "uniqBy", + "description": "should get unique objects by key (keeping the first)", + "input": [ + { "name": "Joe", "city": "New York" }, + { "name": "Oliver", "city": "Amsterdam" }, + { "name": "Sarah", "city": "Amsterdam" } + ], + "query": ["uniqBy", ["get", "city"]], + "output": [{ "name": "Joe", "city": "New York" }, { "name": "Oliver", "city": "Amsterdam" }] + }, + + { + "category": "limit", + "description": "should limit an array with numbers", + "input": [1, 2, 3, 4, 5], + "query": ["limit", 3], + "output": [1, 2, 3] + }, + { + "category": "limit", + "description": "should limit an array with an index larger than the length of the array", + "input": [1, 2, 3], + "query": ["limit", 10], + "output": [1, 2, 3] + }, + { + "category": "limit", + "description": "should limit an array with objects", + "input": [ + { "id": 1, "name": "Joe" }, + { "id": 2, "name": "Oliver" }, + { "id": 3, "name": "Sarah" } + ], + "query": ["limit", 2], + "output": [{ "id": 1, "name": "Joe" }, { "id": 2, "name": "Oliver" }] + }, + { + "category": "limit", + "description": "should return an empty array when limit has a negative value", + "input": [1, 2, 3], + "query": ["limit", -2], + "output": [] + }, + + { + "category": "size", + "description": "should return the size of an array (1)", + "input": [], + "query": ["size"], + "output": 0 + }, + { + "category": "size", + "description": "should return the size of an array (2)", + "input": [1, 2, 3], + "query": ["size"], + "output": 3 + }, + { + "category": "size", + "description": "should return the size of an array (3)", + "input": [{}, {}, {}, {}], + "query": ["size"], + "output": 4 + }, + + { + "category": "sum", + "description": "should calculate the sum of an array with integers", + "input": [1, 2, 3], + "query": ["sum"], + "output": 6 + }, + { + "category": "sum", + "description": "should calculate the sum of an array with floats", + "input": [2.4, 5.7], + "query": ["sum"], + "output": 8.1 + }, + + { + "category": "min", + "description": "should calculate the minimum value", + "input": [3, -4, 1, -7], + "query": ["min"], + "output": -7 + }, + + { + "category": "max", + "description": "should calculate the maximum value", + "input": [3, -4, 1, -7], + "query": ["max"], + "output": 3 + }, + + { + "category": "prod", + "description": "should calculate the product", + "input": [2, 3, 5], + "query": ["prod"], + "output": 30 + }, + + { + "category": "average", + "description": "should calculate the average (1)", + "input": [2, 4], + "query": ["average"], + "output": 3 + }, + { + "category": "average", + "description": "should calculate the average (2)", + "input": [2, 3, 2, 7, 1], + "query": ["average"], + "output": 3 + }, + + { + "category": "eq", + "description": "should calculate equal (1)", + "input": { "a": 1, "b": 2 }, + "query": ["eq", ["get", "a"], ["get", "b"]], + "output": false + }, + { + "category": "eq", + "description": "should calculate equal (2)", + "input": { "a": 2, "b": 2 }, + "query": ["eq", ["get", "a"], ["get", "b"]], + "output": true + }, + { + "category": "eq", + "description": "should calculate equal (3)", + "input": { "a": 3, "b": 2 }, + "query": ["eq", ["get", "a"], ["get", "b"]], + "output": false + }, + { + "category": "eq", + "description": "should calculate equal (4)", + "input": null, + "query": ["eq", 2, 2], + "output": true + }, + + { + "category": "gt", + "description": "should calculate greater than (1)", + "input": { "a": 1, "b": 2 }, + "query": ["gt", ["get", "a"], ["get", "b"]], + "output": false + }, + { + "category": "gt", + "description": "should calculate greater than (2)", + "input": { "a": 2, "b": 2 }, + "query": ["gt", ["get", "a"], ["get", "b"]], + "output": false + }, + { + "category": "gt", + "description": "should calculate greater than (3)", + "input": { "a": 3, "b": 2 }, + "query": ["gt", ["get", "a"], ["get", "b"]], + "output": true + }, + { + "category": "gt", + "description": "should calculate greater than (4)", + "input": null, + "query": ["gt", 3, 2], + "output": true + }, + + { + "category": "gte", + "description": "should calculate greater than or equal to (1)", + "input": { "a": 1, "b": 2 }, + "query": ["gte", ["get", "a"], ["get", "b"]], + "output": false + }, + { + "category": "gte", + "description": "should calculate greater than or equal to (2)", + "input": { "a": 2, "b": 2 }, + "query": ["gte", ["get", "a"], ["get", "b"]], + "output": true + }, + { + "category": "gte", + "description": "should calculate greater than or equal to (3)", + "input": { "a": 3, "b": 2 }, + "query": ["gte", ["get", "a"], ["get", "b"]], + "output": true + }, + { + "category": "gte", + "description": "should calculate greater than or equal to (4)", + "input": null, + "query": ["gte", 3, 2], + "output": true + }, + + { + "category": "lt", + "description": "should calculate less than (1)", + "input": { "a": 1, "b": 2 }, + "query": ["lt", ["get", "a"], ["get", "b"]], + "output": true + }, + { + "category": "lt", + "description": "should calculate less than (2)", + "input": { "a": 2, "b": 2 }, + "query": ["lt", ["get", "a"], ["get", "b"]], + "output": false + }, + { + "category": "lt", + "description": "should calculate less than (3)", + "input": { "a": 3, "b": 2 }, + "query": ["lt", ["get", "a"], ["get", "b"]], + "output": false + }, + { + "category": "lt", + "description": "should calculate less than (4)", + "input": null, + "query": ["lt", 1, 2], + "output": true + }, + + { + "category": "lte", + "description": "should calculate less than or equal to (1)", + "input": { "a": 1, "b": 2 }, + "query": ["lte", ["get", "a"], ["get", "b"]], + "output": true + }, + { + "category": "lte", + "description": "should calculate less than or equal to (2)", + "input": { "a": 2, "b": 2 }, + "query": ["lte", ["get", "a"], ["get", "b"]], + "output": true + }, + { + "category": "lte", + "description": "should calculate less than or equal to (3)", + "input": { "a": 3, "b": 2 }, + "query": ["lte", ["get", "a"], ["get", "b"]], + "output": false + }, + { + "category": "lte", + "description": "should calculate less than or equal to (4)", + "input": null, + "query": ["lte", 2, 2], + "output": true + }, + + { + "category": "ne", + "description": "should calculate not equal (1)", + "input": { "a": 1, "b": 2 }, + "query": ["ne", ["get", "a"], ["get", "b"]], + "output": true + }, + { + "category": "ne", + "description": "should calculate not equal (2)", + "input": { "a": 2, "b": 2 }, + "query": ["ne", ["get", "a"], ["get", "b"]], + "output": false + }, + { + "category": "ne", + "description": "should calculate not equal (3)", + "input": { "a": 3, "b": 2 }, + "query": ["ne", ["get", "a"], ["get", "b"]], + "output": true + }, + { + "category": "ne", + "description": "should calculate not equal (4)", + "input": null, + "query": ["ne", 3, 2], + "output": true + }, + + { + "category": "and", + "description": "should calculate and (1)", + "input": { "a": false, "b": false }, + "query": ["and", ["get", "a"], ["get", "b"]], + "output": false + }, + { + "category": "and", + "description": "should calculate and (2)", + "input": { "a": false, "b": true }, + "query": ["and", ["get", "a"], ["get", "b"]], + "output": false + }, + { + "category": "and", + "description": "should calculate and (3)", + "input": { "a": true, "b": false }, + "query": ["and", ["get", "a"], ["get", "b"]], + "output": false + }, + { + "category": "and", + "description": "should calculate and (4)", + "input": { "a": true, "b": true }, + "query": ["and", ["get", "a"], ["get", "b"]], + "output": true + }, + { + "category": "and", + "description": "should calculate and (5)", + "input": { "a": 0, "b": 1 }, + "query": ["and", ["get", "a"], ["get", "b"]], + "output": false + }, + { + "category": "and", + "description": "should calculate and (6)", + "input": { "a": 1, "b": 1 }, + "query": ["and", ["get", "a"], ["get", "b"]], + "output": true + }, + { + "category": "and", + "description": "should calculate and (7)", + "input": null, + "query": ["and", true, true], + "output": true + }, + + { + "category": "or", + "description": "should calculate or (1)", + "input": { "a": false, "b": false }, + "query": ["or", ["get", "a"], ["get", "b"]], + "output": false + }, + { + "category": "or", + "description": "should calculate or (2)", + "input": { "a": false, "b": true }, + "query": ["or", ["get", "a"], ["get", "b"]], + "output": true + }, + { + "category": "or", + "description": "should calculate or (3)", + "input": { "a": true, "b": false }, + "query": ["or", ["get", "a"], ["get", "b"]], + "output": true + }, + { + "category": "or", + "description": "should calculate or (4)", + "input": { "a": true, "b": true }, + "query": ["or", ["get", "a"], ["get", "b"]], + "output": true + }, + { + "category": "or", + "description": "should calculate or (5)", + "input": { "a": 0, "b": 1 }, + "query": ["or", ["get", "a"], ["get", "b"]], + "output": true + }, + { + "category": "or", + "description": "should calculate or (6)", + "input": { "a": 1, "b": 1 }, + "query": ["or", ["get", "a"], ["get", "b"]], + "output": true + }, + { + "category": "or", + "description": "should calculate or (7)", + "input": null, + "query": ["or", false, true], + "output": true + }, + + { + "category": "not", + "description": "should calculate not (1)", + "input": { "a": false }, + "query": ["not", ["get", "a"]], + "output": true + }, + { + "category": "not", + "description": "should calculate not (2)", + "input": { "a": true }, + "query": ["not", ["get", "a"]], + "output": false + }, + { + "category": "not", + "description": "should calculate not (3)", + "input": null, + "query": ["not", true], + "output": false + }, + { + "category": "not", + "description": "should calculate not (4)", + "input": { "a": 1 }, + "query": ["not", ["get", "a"]], + "output": false + }, + { + "category": "not", + "description": "should calculate not (5)", + "input": { "a": 0 }, + "query": ["not", ["get", "a"]], + "output": true + }, + + { + "category": "exists", + "description": "should calculate exists (1)", + "input": { "a": false }, + "query": ["exists", ["get", "a"]], + "output": true + }, + { + "category": "exists", + "description": "should calculate exists (1)", + "input": { "a": null }, + "query": ["exists", ["get", "a"]], + "output": true + }, + { + "category": "exists", + "description": "should calculate exists (1)", + "input": { "a": 2 }, + "query": ["exists", ["get", "a"]], + "output": true + }, + { + "category": "exists", + "description": "should calculate exists (1)", + "input": { "a": 0 }, + "query": ["exists", ["get", "a"]], + "output": true + }, + { + "category": "exists", + "description": "should calculate exists (1)", + "input": { "a": "" }, + "query": ["exists", ["get", "a"]], + "output": true + }, + { + "category": "exists", + "description": "should calculate exists (1)", + "input": { "nested": { "a": 2 } }, + "query": ["exists", ["get", "nested", "a"]], + "output": true + }, + { + "category": "exists", + "description": "should calculate exists (1)", + "input": {}, + "query": ["exists", ["get", "a"]], + "output": false + }, + { + "category": "exists", + "description": "should calculate exists (1)", + "input": {}, + "query": ["exists", ["get", "nested", "a"]], + "output": false + }, + { + "category": "exists", + "description": "should calculate exists (1)", + "input": {}, + "query": ["exists", ["get", "sort"]], + "output": false + }, + + { + "category": "in", + "description": "should calculate in (1)", + "input": { "score": 5 }, + "query": ["in", ["get", "score"], ["array", 1, 2, 5, 8]], + "output": true + }, + { + "category": "in", + "description": "should calculate in (2)", + "input": { "score": 7 }, + "query": ["in", ["get", "score"], ["array", 1, 2, 5, 8]], + "output": false + }, + { + "category": "in", + "description": "should calculate in (3)", + "input": null, + "query": ["in", 5, ["array", 1, 2, 5, 8]], + "output": true + }, + + { + "category": "not in", + "description": "should calculate not in (1)", + "input": { "score": 5 }, + "query": ["not in", ["get", "score"], ["array", 1, 2, 5, 8]], + "output": false + }, + { + "category": "not in", + "description": "should calculate not in (2)", + "input": { "score": 7 }, + "query": ["not in", ["get", "score"], ["array", 1, 2, 5, 8]], + "output": true + }, + { + "category": "not in", + "description": "should calculate not in (3)", + "input": null, + "query": ["not in", 7, ["array", 1, 2, 5, 8]], + "output": true + }, + + { + "category": "regex", + "description": "should calculate a regex without flags", + "input": [{ "name": "Joe" }, { "name": "Oliver42" }, { "name": "Sarah" }], + "query": ["filter", ["regex", ["get", "name"], "^[A-z]{2,4}$"]], + "output": [{ "name": "Joe" }] + }, + { + "category": "regex", + "description": "should calculate a regex with flags", + "input": [{ "name": "Joe" }, { "name": "Oliver42" }, { "name": "Sarah" }], + "query": ["filter", ["regex", ["get", "name"], "^[a-z]+$", "i"]], + "output": [{ "name": "Joe" }, { "name": "Sarah" }] + }, + { + "category": "regex", + "description": "should calculate a regex with a static text", + "input": null, + "query": ["regex", "Joe", "^[A-z]+$"], + "output": true + }, + + { + "category": "add", + "description": "should add two properties", + "input": { "a": 6, "b": 2 }, + "query": ["add", ["get", "a"], ["get", "b"]], + "output": 8 + }, + { + "category": "add", + "description": "should add two numbers", + "input": null, + "query": ["add", 6, 2], + "output": 8 + }, + + { + "category": "subtract", + "description": "should subtract two properties", + "input": { "a": 6, "b": 2 }, + "query": ["subtract", ["get", "a"], ["get", "b"]], + "output": 4 + }, + { + "category": "add", + "description": "should subtract two numbers", + "input": null, + "query": ["subtract", 6, 2], + "output": 4 + }, + + { + "category": "multiply", + "description": "should multiply two properties", + "input": { "a": 6, "b": 2 }, + "query": ["multiply", ["get", "a"], ["get", "b"]], + "output": 12 + }, + { + "category": "multiply", + "description": "should multiply two numbers", + "input": null, + "query": ["multiply", 6, 2], + "output": 12 + }, + + { + "category": "divide", + "description": "should divide two properties", + "input": { "a": 6, "b": 2 }, + "query": ["divide", ["get", "a"], ["get", "b"]], + "output": 3 + }, + { + "category": "divide", + "description": "should divide two numbers", + "input": null, + "query": ["divide", 6, 2], + "output": 3 + }, + + { + "category": "pow", + "description": "should calculate the exponent of two properties", + "input": { "a": 6, "b": 2 }, + "query": ["pow", ["get", "a"], ["get", "b"]], + "output": 36 + }, + { + "category": "pow", + "description": "should calculate the exponent of two numbers", + "input": null, + "query": ["pow", 6, 2], + "output": 36 + }, + { + "category": "pow", + "description": "should calculate the square root using pow", + "input": 25, + "query": ["pow", ["get"], 0.5], + "output": 5 + }, + + { + "category": "mod", + "description": "should calculate the remainder (the modulus) of two properties", + "input": { "a": 8, "b": 3 }, + "query": ["mod", ["get", "a"], ["get", "b"]], + "output": 2 + }, + { + "category": "mod", + "description": "should calculate the remainder (the modulus) of two numbers", + "input": null, + "query": ["mod", 8, 3], + "output": 2 + }, + + { + "category": "abs", + "description": "should calculate the absolute value (1)", + "input": { "a": -3 }, + "query": ["abs", ["get", "a"]], + "output": 3 + }, + { + "category": "abs", + "description": "should calculate the absolute value (2)", + "input": { "a": 3 }, + "query": ["abs", ["get", "a"]], + "output": 3 + }, + { + "category": "abs", + "description": "should calculate the absolute value (3)", + "input": null, + "query": ["abs", -5], + "output": 5 + }, + + { + "category": "round", + "description": "should round a property (1)", + "input": { "a": 23.1345 }, + "query": ["round", ["get", "a"]], + "output": 23 + }, + { + "category": "round", + "description": "should round a property (2)", + "input": { "a": 23.761 }, + "query": ["round", ["get", "a"]], + "output": 24 + }, + { + "category": "round", + "description": "should round a property to two digits", + "input": { "a": 23.1345 }, + "query": ["round", ["get", "a"], 2], + "output": 23.13 + }, + { + "category": "round", + "description": "should round a property to three digits", + "input": { "a": 23.1345 }, + "query": ["round", ["get", "a"], 3], + "output": 23.135 + }, + { + "category": "round", + "description": "should round a number to three digits", + "input": null, + "query": ["round", 23.1345, 2], + "output": 23.13 + }, + + { + "category": "composed query", + "description": "should filter using and, gte, and lte", + "input": [ + { "age": 23 }, + { "age": 19 }, + { "age": 32 }, + { "age": 19 }, + { "age": 27 }, + { "age": 45 }, + { "age": 31 }, + { "age": 25 } + ], + "query": ["filter", ["and", ["gte", ["get", "age"], 23], ["lte", ["get", "age"], 27]]], + "output": [{ "age": 23 }, { "age": 27 }, { "age": 25 }] + }, + { + "category": "composed query", + "description": "should create an object containing pipelines and various functions", + "input": [ + { "name": "Chris", "age": 23, "city": "New York" }, + { "name": "Emily", "age": 19, "city": "Atlanta" }, + { "name": "Joe", "age": 32, "city": "New York" }, + { "name": "Kevin", "age": 19, "city": "Atlanta" }, + { "name": "Michelle", "age": 27, "city": "Los Angeles" }, + { "name": "Robert", "age": 45, "city": "Manhattan" }, + { "name": "Sarah", "age": 31, "city": "New York" } + ], + "query": [ + "object", + { + "names": ["map", ["get", "name"]], + "count": ["size"], + "averageAge": ["pipe", ["map", ["get", "age"]], ["average"]] + } + ], + "output": { + "names": ["Chris", "Emily", "Joe", "Kevin", "Michelle", "Robert", "Sarah"], + "count": 7, + "averageAge": 28 + } + }, + { + "category": "composed query", + "description": "should process multiple operations", + "input": { + "friends": [ + { "name": "Chris", "age": 23, "city": "New York" }, + { "name": "Emily", "age": 19, "city": "Atlanta" }, + { "name": "Joe", "age": 32, "city": "New York" }, + { "name": "Kevin", "age": 19, "city": "Atlanta" }, + { "name": "Michelle", "age": 27, "city": "Los Angeles" }, + { "name": "Robert", "age": 45, "city": "Manhattan" }, + { "name": "Sarah", "age": 31, "city": "New York" } + ] + }, + "query": [ + "pipe", + ["get", "friends"], + ["filter", ["eq", ["get", "city"], "New York"]], + ["sort", ["get", "age"]], + ["map", ["get", "name"]], + ["limit", 2] + ], + "output": ["Chris", "Sarah"] + }, + { + "category": "composed query", + "description": "should use functions to calculate a shopping cart", + "input": [ + { "name": "bread", "price": 2.5, "quantity": 2 }, + { "name": "milk", "price": 1.2, "quantity": 3 } + ], + "query": ["pipe", ["map", ["multiply", ["get", "price"], ["get", "quantity"]]], ["sum"]], + "output": 8.6 + } + ] +} diff --git a/test-suite/compile.test.schema.json b/test-suite/compile.test.schema.json new file mode 100644 index 0000000..377388f --- /dev/null +++ b/test-suite/compile.test.schema.json @@ -0,0 +1,27 @@ +{ + "type": "object", + "properties": { + "source": { "type": "string", "pattern": "^https://" }, + "updated": { + "type": "string", + "pattern": "^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}Z$" + }, + "tests": { + "type": "array", + "items": { + "type": "object", + "properties": { + "category": { "type": "string" }, + "description": { "type": "string" }, + "input": {}, + "query": {}, + "output": {} + }, + "required": ["category", "description", "input", "query", "output"], + "additionalProperties": false + } + } + }, + "required": ["source", "updated", "tests"], + "additionalProperties": false +} diff --git a/test-suite/parse.test.d.ts b/test-suite/parse.test.d.ts new file mode 100644 index 0000000..0b32b92 --- /dev/null +++ b/test-suite/parse.test.d.ts @@ -0,0 +1,22 @@ +import type { JSONQuery } from '../src/types' + +export interface ParseTest { + input: string + output: JSONQuery +} + +export interface ParseTestException { + input: string + throws: string +} + +export interface ParseTestGroup { + category: string + description: string + tests: Array +} + +export interface ParseTestSuite { + updated: string + groups: ParseTestGroup[] +} diff --git a/test-suite/parse.test.json b/test-suite/parse.test.json new file mode 100644 index 0000000..8cb7ce9 --- /dev/null +++ b/test-suite/parse.test.json @@ -0,0 +1,418 @@ +{ + "source": "https://github.com/jsonquerylang/jsonquery/tree/main/test-suite/parse.test.json", + "updated": "2024-11-11T09:00:00Z", + "groups": [ + { + "category": "property", + "description": "should parse a property without quotes", + "tests": [ + { "input": ".name", "output": ["get", "name"] }, + { "input": ".AaZz_$", "output": ["get", "AaZz_$"] }, + { "input": ".AaZz09_$", "output": ["get", "AaZz09_$"] }, + { "input": ".9", "output": ["get", 9] }, + { "input": ".123", "output": ["get", 123] }, + { "input": ".0", "output": ["get", 0] }, + { "input": " .name ", "output": ["get", "name"] } + ] + }, + { + "category": "property", + "description": "should throw an error in case of an invalid unquoted property", + "tests": [ + { "input": ".", "throws": "Property expected (pos: 1)" }, + { "input": ".01", "throws": "Unexpected part '1'" }, + { "input": ".1abc", "throws": "Unexpected part 'abc'" }, + { "input": ".[", "throws": "Property expected (pos: 1)" }, + { "input": ".foo#", "throws": "Unexpected part '#'" }, + { "input": ".foo#bar", "throws": "Unexpected part '#bar'" } + ] + }, + { + "category": "property", + "description": "should parse a property with quotes", + "tests": [ + { "input": ".\"name\"", "output": ["get", "name"] }, + { "input": " .\"name\" ", "output": ["get", "name"] }, + { "input": ".\"escape \\n \\\"chars\"", "output": ["get", "escape \n \"chars"] } + ] + }, + { + "category": "property", + "description": "should parse a nested property", + "tests": [ + { "input": ".address.city", "output": ["get", "address", "city"] }, + { "input": ".\"address\".\"city\"", "output": ["get", "address", "city"] }, + { "input": ".\"address\".\"city\"", "output": ["get", "address", "city"] }, + { "input": ".array.2", "output": ["get", "array", 2] } + ] + }, + { + "category": "property", + "description": "should throw an error when a property misses an end quote", + "tests": [{ "input": ".\"name", "throws": "Property expected (pos: 1)" }] + }, + { + "category": "property", + "description": "should throw an error when there is whitespace between the dot and the property name", + "tests": [ + { "input": ". \"name\"", "throws": "Property expected (pos: 1)" }, + { "input": ".\"address\" .\"city\"", "throws": "Unexpected part '.\"city\"' (pos: 11)" }, + { "input": ".address .city", "throws": "Unexpected part '.city' (pos: 9)" } + ] + }, + { + "category": "function", + "description": "should parse a function without arguments", + "tests": [ + { "input": "sort()", "output": ["sort"] }, + { "input": "sort( )", "output": ["sort"] }, + { "input": "sort ( )", "output": ["sort"] }, + { "input": " sort ( ) ", "output": ["sort"] } + ] + }, + { + "category": "function", + "description": "should parse a function with one argument", + "tests": [ + { "input": "sort(.age)", "output": ["sort", ["get", "age"]] }, + { "input": "sort(get())", "output": ["sort", ["get"]] }, + { "input": "sort ( .age ) ", "output": ["sort", ["get", "age"]] } + ] + }, + { + "category": "function", + "description": "should parse a function with multiple arguments", + "tests": [ + { "input": "sort(.age, \"desc\")", "output": ["sort", ["get", "age"], "desc"] }, + { "input": "sort(get(), \"desc\")", "output": ["sort", ["get"], "desc"] } + ] + }, + { + "category": "function", + "description": "should throw an error in case of an unknown function name", + "tests": [{ "input": "foo(42)", "throws": "Unknown function 'foo' (pos: 4)" }] + }, + { + "category": "function", + "description": "should throw an error when the end bracket is missing", + "tests": [{ "input": "sort(.age, \"desc\"", "throws": "Character ')' expected (pos: 17)" }] + }, + { + "category": "function", + "description": "should throw an error when a comma is missing", + "tests": [{ "input": "sort(.age \"desc\")", "throws": "Character ',' expected (pos: 10)" }] + }, + { + "category": "operator", + "description": "should parse all operators", + "tests": [ + { "input": ".score==8", "output": ["eq", ["get", "score"], 8] }, + { "input": ".score == 8", "output": ["eq", ["get", "score"], 8] }, + { "input": ".score < 8", "output": ["lt", ["get", "score"], 8] }, + { "input": ".score <= 8", "output": ["lte", ["get", "score"], 8] }, + { "input": ".score > 8", "output": ["gt", ["get", "score"], 8] }, + { "input": ".score >= 8", "output": ["gte", ["get", "score"], 8] }, + { "input": ".score != 8", "output": ["ne", ["get", "score"], 8] }, + { "input": ".score + 8", "output": ["add", ["get", "score"], 8] }, + { "input": ".score - 8", "output": ["subtract", ["get", "score"], 8] }, + { "input": ".score * 8", "output": ["multiply", ["get", "score"], 8] }, + { "input": ".score / 8", "output": ["divide", ["get", "score"], 8] }, + { "input": ".score ^ 8", "output": ["pow", ["get", "score"], 8] }, + { "input": ".score % 8", "output": ["mod", ["get", "score"], 8] }, + { "input": ".a and .b", "output": ["and", ["get", "a"], ["get", "b"]] }, + { "input": ".a or .b", "output": ["or", ["get", "a"], ["get", "b"]] }, + { + "input": ".name in [\"Joe\", \"Sarah\"]", + "output": ["in", ["get", "name"], ["array", "Joe", "Sarah"]] + }, + { + "input": ".name not in [\"Joe\", \"Sarah\"]", + "output": ["not in", ["get", "name"], ["array", "Joe", "Sarah"]] + } + ] + }, + { + "category": "operator", + "description": "should parse an operator having the same name as a function", + "tests": [ + { "input": "0 and 1", "output": ["and", 0, 1] }, + { "input": ".a and .b", "output": ["and", ["get", "a"], ["get", "b"]] } + ] + }, + { + "category": "operator", + "description": "should parse nested operators", + "tests": [ + { + "input": "(.a == \"A\") and (.b == \"B\")", + "output": ["and", ["eq", ["get", "a"], "A"], ["eq", ["get", "b"], "B"]] + }, + { + "input": "(.a == \"A\") or (.b == \"B\")", + "output": ["or", ["eq", ["get", "a"], "A"], ["eq", ["get", "b"], "B"]] + }, + { + "input": "(.a * 2) + 3", + "output": ["add", ["multiply", ["get", "a"], 2], 3] + }, + { + "input": "3 + (.a * 2)", + "output": ["add", 3, ["multiply", ["get", "a"], 2]] + } + ] + }, + { + "category": "operator", + "description": "should throw an error when using multiple operators without parenthesis", + "tests": [ + { "input": ".a == \"A\" and .b == \"B\"", "throws": "Unexpected part 'and .b == \"B\"'" }, + { + "input": "2 and 3 and 4", + "throws": "Unexpected part 'and 4' (pos: 8)" + }, + { "input": ".a + 2 * 3", "throws": "Unexpected part '* 3' (pos: 7)" } + ] + }, + { + "category": "operator", + "description": "should throw an error in case of an unknown operator", + "tests": [ + { "input": ".a === \"A\"", "throws": "Value expected (pos: 5)" }, + { "input": ".a <> \"A\"", "throws": "Value expected (pos: 4)" } + ] + }, + { + "category": "operator", + "description": "should throw an error in case a missing right hand side", + "tests": [{ "input": ".a ==", "throws": "Value expected (pos: 5)" }] + }, + { + "category": "operator", + "description": "should throw an error in case a missing left and right hand side", + "tests": [ + { "input": "+", "throws": "Value expected (pos: 0)" }, + { "input": " +", "throws": "Value expected (pos: 1)" } + ] + }, + { + "category": "pipe", + "description": "should parse a pipe", + "tests": [ + { + "input": ".friends | sort(.age)", + "output": ["pipe", ["get", "friends"], ["sort", ["get", "age"]]] + }, + { + "input": ".friends | sort(.age) | filter(.age >= 18)", + "output": [ + "pipe", + ["get", "friends"], + ["sort", ["get", "age"]], + ["filter", ["gte", ["get", "age"], 18]] + ] + } + ] + }, + { + "category": "pipe", + "description": "should throw an error when a value is missing after a pipe", + "tests": [{ "input": ".friends |", "throws": "Value expected (pos: 10)" }] + }, + { + "category": "pipe", + "description": "should throw an error when a value is missing before a pipe", + "tests": [{ "input": "| .friends ", "throws": "Value expected (pos: 0)" }] + }, + { + "category": "parenthesis", + "description": "should parse parenthesis", + "tests": [ + { "input": "(.friends)", "output": ["get", "friends"] }, + { "input": "( .friends)", "output": ["get", "friends"] }, + { "input": "(.friends )", "output": ["get", "friends"] }, + { "input": "(.age == 18)", "output": ["eq", ["get", "age"], 18] }, + { "input": "(42)", "output": 42 }, + { "input": " ( 42 ) ", "output": 42 }, + { "input": "((42))", "output": 42 } + ] + }, + { + "category": "parenthesis", + "description": "should throw an error when missing closing parenthesis", + "tests": [{ "input": "(.friends", "throws": "Character ')' expected (pos: 9)" }] + }, + { + "category": "object", + "description": "should parse an object", + "tests": [ + { "input": "{}", "output": ["object", {}] }, + { "input": "{ }", "output": ["object", {}] }, + { "input": "{a:1}", "output": ["object", { "a": 1 }] }, + { "input": "{a1:1}", "output": ["object", { "a1": 1 }] }, + { "input": "{AaZz_$019:1}", "output": ["object", { "AaZz_$019": 1 }] }, + { "input": " { a : 1 } ", "output": ["object", { "a": 1 }] }, + { "input": "{a:1,b:2}", "output": ["object", { "a": 1, "b": 2 }] }, + { "input": "{ a : 1 , b : 2 }", "output": ["object", { "a": 1, "b": 2 }] }, + { "input": "{ \"a\" : 1 , \"b\" : 2 }", "output": ["object", { "a": 1, "b": 2 }] }, + { "input": "{ 2: \"two\" }", "output": ["object", { "2": "two" }] }, + { "input": "{null:null}", "output": ["object", { "null": null }] }, + { + "input": "{\n name: .name,\n city: .address.city,\n averageAge: map(.age) | average()\n }", + "output": [ + "object", + { + "name": ["get", "name"], + "city": ["get", "address", "city"], + "averageAge": ["pipe", ["map", ["get", "age"]], ["average"]] + } + ] + } + ] + }, + { + "category": "object", + "description": "should throw an error when missing closing parenthesis", + "tests": [{ "input": "{a:1", "throws": "Character '}' expected (pos: 4)" }] + }, + { + "category": "object", + "description": "should throw an error when missing a comma", + "tests": [{ "input": "{a:1 b:2}", "throws": "Character ',' expected (pos: 5)" }] + }, + { + "category": "object", + "description": "should throw an error when missing a comma", + "tests": [{ "input": "{a", "throws": "Character ':' expected (pos: 2)" }] + }, + { + "category": "object", + "description": "should throw an error when missing a key", + "tests": [ + { "input": "{{", "throws": "Key expected (pos: 1)" }, + { "input": "{a:2,{", "throws": "Key expected (pos: 5)" } + ] + }, + { + "category": "object", + "description": "should throw an error when missing a value", + "tests": [ + { "input": "{a:", "throws": "Value expected (pos: 3)" }, + { "input": "{a:2,b:}", "throws": "Value expected (pos: 7)" } + ] + }, + { + "category": "object", + "description": "should throw an error in case of a trailing comma", + "tests": [{ "input": "{a:2,}", "throws": "Key expected (pos: 5)" }] + }, + { + "category": "array", + "description": "should parse an array", + "tests": [ + { "input": "[]", "output": ["array"] }, + { "input": " [ ] ", "output": ["array"] }, + { "input": "[1, 2, 3]", "output": ["array", 1, 2, 3] }, + { "input": " [ 1 , 2 , 3 ] ", "output": ["array", 1, 2, 3] }, + { "input": "[(1 + 3), 2, 4]", "output": ["array", ["add", 1, 3], 2, 4] }, + { "input": "[2, (1 + 2), 4]", "output": ["array", 2, ["add", 1, 2], 4] } + ] + }, + { + "category": "array", + "description": "should throw an error when missing closing bracket", + "tests": [{ "input": "[1,2", "throws": "Character ']' expected (pos: 4)" }] + }, + { + "category": "array", + "description": "should throw an error when missing a comma", + "tests": [{ "input": "[1 2]", "throws": "Character ',' expected (pos: 3)" }] + }, + { + "category": "array", + "description": "should throw an error when missing a value", + "tests": [{ "input": "[1,", "throws": "Value expected (pos: 3)" }] + }, + { + "category": "array", + "description": "should throw an error in case of a trailing comma", + "tests": [{ "input": "[1,2,]", "throws": "Value expected (pos: 5)" }] + }, + { + "category": "string", + "description": "should parse a string", + "tests": [ + { "input": "\"hello\"", "output": "hello" }, + { "input": " \"hello\"", "output": "hello" }, + { "input": "\"hello\" ", "output": "hello" }, + { "input": "\"hello \\\"world\\\"\"", "output": "hello \"world\"" } + ] + }, + { + "category": "string", + "description": "should throw an error when missing closing quote", + "tests": [{ "input": "\"hello", "throws": "Value expected (pos: 0)" }] + }, + { + "category": "number", + "description": "should parse a number", + "tests": [ + { "input": "42", "output": 42 }, + { "input": "-42", "output": -42 }, + { "input": "2.3", "output": 2.3 }, + { "input": "-2.3", "output": -2.3 }, + { "input": "2.3e2", "output": 230 }, + { "input": "2.3e+2", "output": 230 }, + { "input": "2.3e-2", "output": 0.023 }, + { "input": "2.3E+2", "output": 230 }, + { "input": "2.3E-2", "output": 0.023 } + ] + }, + { + "category": "number", + "description": "should throw an error in case of an invalid number", + "tests": [ + { "input": "-", "throws": "Value expected (pos: 0)" }, + { "input": "2.", "throws": "Unexpected part '.' (pos: 1)" }, + { "input": "2.3e", "throws": "Unexpected part 'e' (pos: 3)" }, + { "input": "2.3e+", "throws": "Unexpected part 'e+' (pos: 3)" }, + { "input": "2.3e-", "throws": "Unexpected part 'e-' (pos: 3)" }, + { "input": "2.", "throws": "Unexpected part '.' (pos: 1)" } + ] + }, + { + "category": "boolean", + "description": "should parse a boolean", + "tests": [ + { "input": "true", "output": true }, + { "input": " true ", "output": true }, + { "input": "false", "output": false } + ] + }, + { + "category": "null", + "description": "should parse null", + "tests": [{ "input": "null", "output": null }, { "input": " null ", "output": null }] + }, + { + "category": "garbage", + "description": "should throw an error in case of garbage at the end", + "tests": [ + { "input": "null 2", "throws": "Unexpected part '2' (pos: 5)" }, + { "input": "sort() 2", "throws": "Unexpected part '2' (pos: 7)" } + ] + }, + { + "category": "whitespace", + "description": "should skip whitespace characters", + "tests": [{ "input": " \n\r\t\"hello\" \n\r\t", "output": "hello" }] + }, + { + "category": "empty", + "description": "should throw when the query is empty", + "tests": [ + { "input": "", "throws": "Value expected (pos: 0)" }, + { "input": " ", "throws": "Value expected (pos: 1)" } + ] + } + ] +} diff --git a/test-suite/parse.test.schema.json b/test-suite/parse.test.schema.json new file mode 100644 index 0000000..762c340 --- /dev/null +++ b/test-suite/parse.test.schema.json @@ -0,0 +1,49 @@ +{ + "type": "object", + "properties": { + "source": { "type": "string", "pattern": "^https://" }, + "updated": { + "type": "string", + "pattern": "^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}Z$" + }, + "groups": { + "type": "array", + "items": { + "type": "object", + "properties": { + "category": { "type": "string" }, + "description": { "type": "string" }, + "tests": { + "type": "array", + "items": { + "oneOf": [ + { + "type": "object", + "properties": { + "input": { "type": "string" }, + "output": {} + }, + "required": ["input", "output"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "input": { "type": "string" }, + "throws": { "type": "string" } + }, + "required": ["input", "throws"], + "additionalProperties": false + } + ] + } + } + }, + "required": ["category", "description", "tests"], + "additionalProperties": false + } + } + }, + "required": ["source", "updated", "groups"], + "additionalProperties": false +} diff --git a/test-suite/stringify.test.d.ts b/test-suite/stringify.test.d.ts new file mode 100644 index 0000000..c004c97 --- /dev/null +++ b/test-suite/stringify.test.d.ts @@ -0,0 +1,18 @@ +import type { JSONQuery, JSONQueryStringifyOptions } from '../src/types' + +export interface StringifyTest { + input: JSONQuery + output: string +} + +export interface StringifyTestGroup { + category: string + description: string + options?: JSONQueryStringifyOptions + tests: StringifyTest[] +} + +export interface StringifyTestSuite { + updated: string + groups: StringifyTestGroup[] +} diff --git a/test-suite/stringify.test.json b/test-suite/stringify.test.json new file mode 100644 index 0000000..2e2d5a0 --- /dev/null +++ b/test-suite/stringify.test.json @@ -0,0 +1,266 @@ +{ + "source": "https://github.com/jsonquerylang/jsonquery/tree/main/test-suite/stringify.test.json", + "updated": "2024-11-11T09:00:00Z", + "groups": [ + { + "category": "property", + "description": "should stringify a property", + "tests": [ + { "input": ["get"], "output": "get()" }, + { "input": ["get", "age"], "output": ".age" }, + { "input": ["get", "address", "city"], "output": ".address.city" }, + { "input": ["get", "with space"], "output": ".\"with space\"" }, + { "input": ["get", "with special !"], "output": ".\"with special !\"" } + ] + }, + { + "category": "operator", + "description": "should stringify all operators", + "tests": [ + { "input": ["eq", ["get", "score"], 8], "output": "(.score == 8)" }, + { "input": ["lt", ["get", "score"], 8], "output": "(.score < 8)" }, + { "input": ["lte", ["get", "score"], 8], "output": "(.score <= 8)" }, + { "input": ["gt", ["get", "score"], 8], "output": "(.score > 8)" }, + { "input": ["gte", ["get", "score"], 8], "output": "(.score >= 8)" }, + { "input": ["ne", ["get", "score"], 8], "output": "(.score != 8)" }, + { "input": ["add", ["get", "score"], 8], "output": "(.score + 8)" }, + { "input": ["subtract", ["get", "score"], 8], "output": "(.score - 8)" }, + { "input": ["multiply", ["get", "score"], 8], "output": "(.score * 8)" }, + { "input": ["divide", ["get", "score"], 8], "output": "(.score / 8)" }, + { "input": ["pow", ["get", "score"], 8], "output": "(.score ^ 8)" }, + { "input": ["mod", ["get", "score"], 8], "output": "(.score % 8)" }, + { "input": ["and", ["get", "score"], 8], "output": "(.score and 8)" }, + { "input": ["or", ["get", "score"], 8], "output": "(.score or 8)" }, + { + "input": ["in", ["get", "score"], ["array", 8, 9, 10]], + "output": "(.score in [8, 9, 10])" + }, + { + "input": ["not in", ["get", "score"], ["array", 8, 9, 10]], + "output": "(.score not in [8, 9, 10])" + } + ] + }, + { + "category": "operator", + "description": "should stringify a custom operator", + "options": { + "operators": { "aboutEq": "~=" } + }, + "tests": [ + { "input": ["aboutEq", 2, 3], "output": "(2 ~= 3)" }, + { "input": ["filter", ["aboutEq", 2, 3]], "output": "filter(2 ~= 3)" }, + { "input": ["object", { "result": ["aboutEq", 2, 3] }], "output": "{ result: (2 ~= 3) }" }, + { "input": ["eq", 2, 3], "output": "(2 == 3)" } + ] + }, + { + "category": "function", + "description": "should stringify a function", + "tests": [ + { "input": ["sort", ["get", "age"], "desc"], "output": "sort(.age, \"desc\")" }, + { "input": ["filter", ["gt", ["get", "age"], 18]], "output": "filter(.age > 18)" } + ] + }, + { + "category": "function", + "description": "should stringify a function with indentation", + "options": { + "indentation": " ", + "maxLineLength": 4 + }, + "tests": [ + { + "input": ["sort", ["get", "age"], "desc"], + "output": "sort(\n .age,\n \"desc\"\n)" + } + ] + }, + { + "category": "function", + "description": "should stringify a function inside an object with indentation", + "options": { + "indentation": " ", + "maxLineLength": 4 + }, + "tests": [ + { + "input": ["object", { "sorted": ["sort", ["get", "age"], "desc"] }], + "output": "{\n sorted: sort(\n .age,\n \"desc\"\n )\n}" + } + ] + }, + { + "category": "function", + "description": "should stringify a nested function having one argument with indentation", + "options": { + "indentation": " ", + "maxLineLength": 4 + }, + "tests": [ + { + "input": [ + "map", + ["object", { "name": ["get", "name"], "city": ["get", "address", "city"] }] + ], + "output": "map({\n name: .name,\n city: .address.city\n})" + } + ] + }, + { + "category": "pipe", + "description": "should stringify a pipe", + "tests": [{ "input": ["pipe", ["get", "age"], ["average"]], "output": ".age | average()" }] + }, + { + "category": "pipe", + "description": "should stringify a pipe with indentation", + "options": { "maxLineLength": 10 }, + "tests": [{ "input": ["pipe", ["get", "age"], ["average"]], "output": ".age\n | average()" }] + }, + { + "category": "pipe", + "description": "should stringify a nested pipe with indentation", + "options": { "maxLineLength": 10 }, + "tests": [ + { + "input": ["object", { "nested": ["pipe", ["get", "age"], ["average"]] }], + "output": "{\n nested: .age\n | average()\n}" + } + ] + }, + { + "category": "object", + "description": "should stringify an object", + "tests": [ + { + "input": ["object", { "name": ["get", "name"], "city": ["get", "address", "city"] }], + "output": "{ name: .name, city: .address.city }" + } + ] + }, + { + "category": "object", + "description": "should stringify an object with indentation", + "options": { "maxLineLength": 20 }, + "tests": [ + { + "input": ["object", { "name": ["get", "name"], "city": ["get", "address", "city"] }], + "output": "{\n name: .name,\n city: .address.city\n}" + } + ] + }, + { + "category": "object", + "description": "should stringify a nested object with indentation", + "options": { "maxLineLength": 4 }, + "tests": [ + { + "input": [ + "object", + { + "name": ["get", "name"], + "address": ["object", { "city": ["get", "city"], "street": ["get", "street"] }] + } + ], + "output": "{\n name: .name,\n address: {\n city: .city,\n street: .street\n }\n}" + } + ] + }, + { + "category": "object", + "description": "should stringify a nested object with custom indentation (1)", + "options": { + "maxLineLength": 20, + "indentation": " " + }, + "tests": [ + { + "input": ["object", { "name": ["get", "name"], "city": ["get", "address", "city"] }], + "output": "{\n name: .name,\n city: .address.city\n}" + } + ] + }, + { + "category": "object", + "description": "should stringify a nested object with custom indentation (2)", + "options": { + "maxLineLength": 20, + "indentation": "\t" + }, + "tests": [ + { + "input": ["object", { "name": ["get", "name"], "city": ["get", "address", "city"] }], + "output": "{\n\tname: .name,\n\tcity: .address.city\n}" + } + ] + }, + { + "category": "array", + "description": "should stringify an array with indentation", + "options": { "maxLineLength": 4 }, + "tests": [{ "input": ["array", 1, 2, 3], "output": "[\n 1,\n 2,\n 3\n]" }] + }, + { + "category": "array", + "description": "should stringify a nested array with indentation", + "options": { "maxLineLength": 4 }, + "tests": [ + { + "input": ["object", { "array": ["array", 1, 2, 3] }], + "output": "{\n array: [\n 1,\n 2,\n 3\n ]\n}" + } + ] + }, + { + "category": "composed query", + "description": "should stringify a composed query", + "tests": [ + { + "input": ["pipe", ["map", ["multiply", ["get", "price"], ["get", "quantity"]]], ["sum"]], + "output": "map(.price * .quantity) | sum()" + }, + { + "input": [ + "pipe", + ["get", "friends"], + ["filter", ["eq", ["get", "city"], "New York"]], + ["sort", ["get", "age"]], + ["pick", ["get", "name"], ["get", "age"]] + ], + "output": ".friends\n | filter(.city == \"New York\")\n | sort(.age)\n | pick(.name, .age)" + }, + { + "input": ["filter", ["and", ["gte", ["get", "age"], 23], ["lte", ["get", "age"], 27]]], + "output": "filter((.age >= 23) and (.age <= 27))" + }, + { + "input": [ + "pipe", + ["get", "friends"], + [ + "object", + { + "names": ["map", ["get", "name"]], + "count": ["size"], + "averageAge": ["pipe", ["map", ["get", "age"]], ["average"]] + } + ] + ], + "output": ".friends\n | {\n names: map(.name),\n count: size(),\n averageAge: map(.age) | average()\n }" + }, + { + "input": [ + "object", + { + "name": ["get", "name"], + "city": ["get", "address", "city"], + "averageAge": ["pipe", ["map", ["get", "age"]], ["average"]] + } + ], + "output": "{\n name: .name,\n city: .address.city,\n averageAge: map(.age) | average()\n}" + } + ] + } + ] +} diff --git a/test-suite/stringify.test.schema.json b/test-suite/stringify.test.schema.json new file mode 100644 index 0000000..9b018c9 --- /dev/null +++ b/test-suite/stringify.test.schema.json @@ -0,0 +1,44 @@ +{ + "type": "object", + "properties": { + "source": { "type": "string", "pattern": "^https://" }, + "updated": { + "type": "string", + "pattern": "^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}Z$" + }, + "groups": { + "type": "array", + "items": { + "type": "object", + "properties": { + "category": { "type": "string" }, + "description": { "type": "string" }, + "options": { + "type": "object", + "properties": { + "indentation": { "type": "string" }, + "maxLineLength": { "type": "number" }, + "operators": { "type": "object" } + } + }, + "tests": { + "type": "array", + "items": { + "type": "object", + "properties": { + "input": {}, + "output": { "type": "string" } + }, + "required": ["input", "output"], + "additionalProperties": false + } + } + }, + "required": ["category", "description", "tests"], + "additionalProperties": false + } + } + }, + "required": ["source", "updated", "groups"], + "additionalProperties": false +} diff --git a/tsconfig.json b/tsconfig.json index 320ed91..72fd580 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,9 +1,9 @@ { "compilerOptions": { "moduleResolution": "bundler", - "module": "es2020", - "lib": ["es2020"], - "target": "es2020", + "module": "ESNext", + "lib": ["ESNext"], + "target": "ESNext", "sourceMap": true, "esModuleInterop": true, "allowJs": false,