diff --git a/README.md b/README.md index 7959d51..26f27ee 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@ Try it out on the online playground: ## Features -- Small: just `3.7 kB` when minified and gzipped! The JSON query engine without parse/stringify is only `1.7 kB`. +- Small: just `3.9 kB` when minified and gzipped! The JSON query engine without parse/stringify is only `1.9 kB`. - Feature rich (50+ powerful functions and operators) - Easy to interoperate with thanks to the intermediate JSON format. - Expressive diff --git a/src/compile.test.ts b/src/compile.test.ts index f69f794..347524d 100644 --- a/src/compile.test.ts +++ b/src/compile.test.ts @@ -109,16 +109,6 @@ describe('error handling', () => { ]) }) - test('should do nothing when sorting objects without a getter', () => { - const data = [{ a: 1 }, { c: 3 }, { b: 2 }] - expect(go(data, ['sort'])).toEqual(data) - }) - - 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 throw an error when calculating the sum of an empty array', () => { expect(() => go([], ['sum'])).toThrow('Reduce of empty array with no initial value') }) diff --git a/src/functions.ts b/src/functions.ts index 3149902..6a50883 100644 --- a/src/functions.ts +++ b/src/functions.ts @@ -1,5 +1,5 @@ import { compile } from './compile' -import { isArray } from './is' +import { isArray, isEqual } from './is' import type { Entry, FunctionBuilder, @@ -27,6 +27,20 @@ export function buildFunction(fn: (...args: unknown[]) => unknown): FunctionBuil } } +const gt = (a: unknown, b: unknown) => { + if ( + (typeof a === 'number' && typeof b === 'number') || + (typeof a === 'string' && typeof b === 'string') + ) { + return a > b + } + + throw new TypeError('Two numbers or two strings expected') +} +const gte = (a: unknown, b: unknown) => isEqual(a, b) || gt(a, b) +const lt = (a: unknown, b: unknown) => gt(b, a) +const lte = (a: unknown, b: unknown) => gte(b, a) + export const functions: FunctionBuildersMap = { pipe: (...entries: JSONQuery[]) => { const _entries = entries.map((entry) => compile(entry)) @@ -130,7 +144,7 @@ export const functions: FunctionBuildersMap = { function compare(itemA: unknown, itemB: unknown) { const a = getter(itemA) const b = getter(itemB) - return a > b ? sign : a < b ? -sign : 0 + return gt(a, b) ? sign : lt(a, b) ? -sign : 0 } return (data: T[]) => data.slice().sort(compare) @@ -216,7 +230,17 @@ export const functions: FunctionBuildersMap = { uniq: () => - (data: T[]) => [...new Set(data)], + (data: T[]) => { + const res: T[] = [] + + for (const item of data) { + if (!res.find((resItem) => isEqual(resItem, item))) { + res.push(item) + } + } + + return res + }, uniqBy: (path: JSONQueryProperty) => @@ -268,11 +292,16 @@ export const functions: FunctionBuildersMap = { return (data: unknown) => (truthy(_condition(data)) ? _valueIfTrue(data) : _valueIfFalse(data)) }, - in: (path: string, values: JSONQuery) => { - const getter = compile(path) - const _values = compile(values) + in: (value: JSONQuery, values: JSONQuery) => { + const getValue = compile(value) + const getValues = compile(values) - return (data: unknown) => (_values(data) as string[]).includes(getter(data) as string) + return (data: unknown) => { + const _value = getValue(data) + const _values = getValues(data) as unknown[] + + return !!_values.find((item) => isEqual(item, _value)) + } }, 'not in': (path: string, values: JSONQuery) => { const _in = functions.in(path, values) @@ -286,12 +315,12 @@ export const functions: FunctionBuildersMap = { return (data: unknown) => regex.test(getter(data) as string) }, - eq: buildFunction((a, b) => a === b), - gt: buildFunction((a, b) => a > b), - gte: buildFunction((a, b) => a >= b), - lt: buildFunction((a, b) => a < b), - lte: buildFunction((a, b) => a <= b), - ne: buildFunction((a, b) => a !== b), + eq: buildFunction(isEqual), + gt: buildFunction(gt), + gte: buildFunction(gte), + lt: buildFunction(lt), + lte: buildFunction(lte), + ne: buildFunction((a, b) => !isEqual(a, b)), add: buildFunction((a: number, b: number) => a + b), subtract: buildFunction((a: number, b: number) => a - b), diff --git a/src/is.ts b/src/is.ts index 54091cc..fd88b50 100644 --- a/src/is.ts +++ b/src/is.ts @@ -4,3 +4,18 @@ export const isObject = (value: unknown): value is object => value && typeof value === 'object' && !isArray(value) export const isString = (value: unknown): value is string => typeof value === 'string' + +// source: https://stackoverflow.com/a/77278013/1262753 +export const isEqual = (a: T, b: T): boolean => { + if (a === b) { + return true + } + + const bothObject = a && b && typeof a === 'object' && typeof b === 'object' + + return ( + bothObject && + Object.keys(a).length === Object.keys(b).length && + Object.entries(a).every(([k, v]) => isEqual(v, b[k as keyof T])) + ) +} diff --git a/test-suite/compile.test.json b/test-suite/compile.test.json index c6e4874..bb622f1 100644 --- a/test-suite/compile.test.json +++ b/test-suite/compile.test.json @@ -278,6 +278,20 @@ "query": ["sort", ["get", "score"], "desc"], "output": [{ "score": 5 }, { "score": 3 }, { "score": -2 }] }, + { + "category": "sort", + "description": "should throw when sorting nested arrays", + "input": [[1], [2], [3]], + "query": ["sort"], + "throws": "Two numbers or two strings expected" + }, + { + "category": "sort", + "description": "should throw when sorting nested objects", + "input": [{ "a": 1 }, { "c": 3 }, { "b": 2 }], + "query": ["sort"], + "throws": "Two numbers or two strings expected" + }, { "category": "reverse", @@ -596,6 +610,13 @@ "query": ["uniq"], "output": ["hi", "hello", "HI", "bye"] }, + { + "category": "uniq", + "description": "should get unique values from a list objects (deep comparison)", + "input": [{ "a": 1, "b": 2 }, { "b": 2 }, { "b": 2, "a": 1 }, [1], [1]], + "query": ["uniq"], + "output": [{ "a": 1, "b": 2 }, { "b": 2 }, [1]] + }, { "category": "uniqBy", @@ -748,11 +769,82 @@ }, { "category": "eq", - "description": "should calculate equal (4)", + "description": "should calculate equal comparing array and number (no type coercion)", + "input": null, + "query": ["eq", ["array", 2], 2], + "output": false + }, + { + "category": "eq", + "description": "should calculate equal comparing number and string (no type coercion)", + "input": null, + "query": ["eq", "2", 2], + "output": false + }, + { + "category": "eq", + "description": "should calculate (deep) equal on objects (1)", "input": null, - "query": ["eq", 2, 2], + "query": ["eq", ["object", { "a": 2, "b": 3 }], ["object", { "b": 3, "a": 2 }]], "output": true }, + { + "category": "eq", + "description": "should calculate (deep) equal on objects (2)", + "input": null, + "query": ["eq", ["object", { "a": 2, "b": 3 }], ["object", { "b": 3, "a": 2, "c": 4 }]], + "output": false + }, + { + "category": "eq", + "description": "should calculate (deep) equal on objects (3)", + "input": null, + "query": ["eq", ["object", { "a": 2, "b": 3, "c": 4 }], ["object", { "b": 3, "a": 2 }]], + "output": false + }, + { + "category": "eq", + "description": "should calculate (deep) equal on arrays (1)", + "input": null, + "query": ["eq", ["array", 1, 2, 3], ["array", 1, 2, 3]], + "output": true + }, + { + "category": "eq", + "description": "should calculate (deep) equal on arrays (2)", + "input": null, + "query": ["eq", ["array", 1, 2], ["array", 1, 2, 3]], + "output": false + }, + { + "category": "eq", + "description": "should calculate (deep) equal on arrays (3)", + "input": null, + "query": ["eq", ["array", 1, 2, 3], ["array", 1, 2]], + "output": false + }, + { + "category": "eq", + "description": "should calculate (deep) equal on nested objects and arrays (1)", + "input": null, + "query": [ + "eq", + ["object", { "arr": ["array", 1, 2, 3] }], + ["object", { "arr": ["array", 1, 2, 3] }] + ], + "output": true + }, + { + "category": "eq", + "description": "should calculate (deep) equal on nested objects and arrays (2)", + "input": null, + "query": [ + "eq", + ["object", { "arr": ["array", 1, 2] }], + ["object", { "arr": ["array", 1, 2, 3] }] + ], + "output": false + }, { "category": "gt", @@ -782,6 +874,69 @@ "query": ["gt", 3, 2], "output": true }, + { + "category": "gt", + "description": "should calculate greater than for strings (1)", + "input": null, + "query": ["gt", "abd", "abc"], + "output": true + }, + { + "category": "gt", + "description": "should calculate greater than for strings (2)", + "input": null, + "query": ["gt", "abc", "abc"], + "output": false + }, + { + "category": "gt", + "description": "should calculate greater than for strings (3)", + "input": null, + "query": ["gt", "abcd", "abc"], + "output": true + }, + { + "category": "gt", + "description": "should calculate greater than for strings (4)", + "input": null, + "query": ["gt", "A", "a"], + "output": false + }, + { + "category": "gt", + "description": "should calculate greater than for strings (5)", + "input": null, + "query": ["gt", "20", "3"], + "output": false + }, + { + "category": "gt", + "description": "should throw when calculating greater than with mixed data types", + "input": null, + "query": ["gt", "3", 2], + "throws": "Two numbers or two strings expected" + }, + { + "category": "gt", + "description": "should throw when calculating greater than with an unsupported data type (1)", + "input": null, + "query": ["gt", 2, ["array", 1, 2, 3]], + "throws": "Two numbers or two strings expected" + }, + { + "category": "gt", + "description": "should throw when calculating greater than with an unsupported data type (1)", + "input": null, + "query": ["gt", ["array", 1, 2, 4], ["array", 1, 2, 3]], + "throws": "Two numbers or two strings expected" + }, + { + "category": "gt", + "description": "should throw when calculating greater than with an unsupported data type (2)", + "input": null, + "query": ["gt", 2, ["object", { "a": 1 }]], + "throws": "Two numbers or two strings expected" + }, { "category": "gte", @@ -811,6 +966,69 @@ "query": ["gte", 3, 2], "output": true }, + { + "category": "gte", + "description": "should calculate greater than or equal to for strings (1)", + "input": null, + "query": ["gte", "abd", "abc"], + "output": true + }, + { + "category": "gte", + "description": "should calculate greater than or equal to for strings (2)", + "input": null, + "query": ["gte", "abc", "abc"], + "output": true + }, + { + "category": "gte", + "description": "should calculate greater than or equal to for strings (3)", + "input": null, + "query": ["gte", "abcd", "abc"], + "output": true + }, + { + "category": "gte", + "description": "should calculate greater than or equal to for strings (4)", + "input": null, + "query": ["gte", "A", "a"], + "output": false + }, + { + "category": "gte", + "description": "should calculate greater than or equal to for strings (5)", + "input": null, + "query": ["gte", "20", "3"], + "output": false + }, + { + "category": "gte", + "description": "should throw when calculating greater than or equal to with mixed data types", + "input": null, + "query": ["gte", "3", 2], + "throws": "Two numbers or two strings expected" + }, + { + "category": "gte", + "description": "should throw when calculating greater than with an unsupported data type (1)", + "input": null, + "query": ["gte", 2, ["array", 1, 2, 3]], + "throws": "Two numbers or two strings expected" + }, + { + "category": "gte", + "description": "should throw when calculating greater than or equal to with an unsupported data type (1)", + "input": null, + "query": ["gte", ["array", 1, 2, 4], ["array", 1, 2, 3]], + "throws": "Two numbers or two strings expected" + }, + { + "category": "gte", + "description": "should throw when calculating greater than or equal to with an unsupported data type (2)", + "input": null, + "query": ["gte", 2, ["object", { "a": 1 }]], + "throws": "Two numbers or two strings expected" + }, { "category": "lt", @@ -840,6 +1058,69 @@ "query": ["lt", 1, 2], "output": true }, + { + "category": "lt", + "description": "should calculate less than for strings (1)", + "input": null, + "query": ["lt", "abc", "abd"], + "output": true + }, + { + "category": "lt", + "description": "should calculate less than for strings (2)", + "input": null, + "query": ["lt", "abc", "abc"], + "output": false + }, + { + "category": "lt", + "description": "should calculate less than for strings (3)", + "input": null, + "query": ["lt", "abc", "abcd"], + "output": true + }, + { + "category": "lt", + "description": "should calculate less than for strings (4)", + "input": null, + "query": ["lt", "a", "A"], + "output": false + }, + { + "category": "lt", + "description": "should calculate less than for strings (5)", + "input": null, + "query": ["lt", "3", "20"], + "output": false + }, + { + "category": "lt", + "description": "should throw when calculating less than with mixed data types", + "input": null, + "query": ["lt", 2, "3"], + "throws": "Two numbers or two strings expected" + }, + { + "category": "lt", + "description": "should throw when calculating less than with an unsupported data type (1)", + "input": null, + "query": ["lt", 2, ["array", 1, 2, 3]], + "throws": "Two numbers or two strings expected" + }, + { + "category": "lt", + "description": "should throw when calculating less than with an unsupported data type (1)", + "input": null, + "query": ["lt", ["array", 1, 2, 4], ["array", 1, 2, 3]], + "throws": "Two numbers or two strings expected" + }, + { + "category": "lt", + "description": "should throw when calculating less than with an unsupported data type (2)", + "input": null, + "query": ["lt", 2, ["object", { "a": 1 }]], + "throws": "Two numbers or two strings expected" + }, { "category": "lte", @@ -869,6 +1150,69 @@ "query": ["lte", 2, 2], "output": true }, + { + "category": "lte", + "description": "should calculate less than or equal to for strings (1)", + "input": null, + "query": ["lte", "abc", "abd"], + "output": true + }, + { + "category": "lte", + "description": "should calculate less than or equal to for strings (2)", + "input": null, + "query": ["lte", "abc", "abc"], + "output": true + }, + { + "category": "lte", + "description": "should calculate less than or equal to for strings (3)", + "input": null, + "query": ["lte", "abc", "abcd"], + "output": true + }, + { + "category": "lte", + "description": "should calculate less than or equal to for strings (4)", + "input": null, + "query": ["lte", "a", "A"], + "output": false + }, + { + "category": "lte", + "description": "should calculate less than or equal to for strings (5)", + "input": null, + "query": ["lte", "3", "20"], + "output": false + }, + { + "category": "lte", + "description": "should throw when calculating less than or equal to with mixed data types", + "input": null, + "query": ["lte", "3", 2], + "throws": "Two numbers or two strings expected" + }, + { + "category": "lte", + "description": "should throw when calculating greater than with an unsupported data type (1)", + "input": null, + "query": ["lte", 2, ["array", 1, 2, 3]], + "throws": "Two numbers or two strings expected" + }, + { + "category": "lte", + "description": "should throw when calculating less than or equal to with an unsupported data type (1)", + "input": null, + "query": ["lte", ["array", 1, 2, 4], ["array", 1, 2, 3]], + "throws": "Two numbers or two strings expected" + }, + { + "category": "lte", + "description": "should throw when calculating less than or equal to with an unsupported data type (2)", + "input": null, + "query": ["lte", 2, ["object", { "a": 1 }]], + "throws": "Two numbers or two strings expected" + }, { "category": "ne", @@ -898,6 +1242,84 @@ "query": ["ne", 3, 2], "output": true }, + { + "category": "ne", + "description": "should calculate not equal comparing array and number (no type coercion)", + "input": null, + "query": ["ne", ["array", 2], 2], + "output": true + }, + { + "category": "ne", + "description": "should calculate not equal comparing number and string (no type coercion)", + "input": null, + "query": ["ne", "2", 2], + "output": true + }, + { + "category": "ne", + "description": "should calculate (deep) not equal on objects (1)", + "input": null, + "query": ["ne", ["object", { "a": 2, "b": 3 }], ["object", { "b": 3, "a": 2 }]], + "output": false + }, + { + "category": "ne", + "description": "should calculate (deep) not equal on objects (2)", + "input": null, + "query": ["ne", ["object", { "a": 2, "b": 3 }], ["object", { "b": 3, "a": 2, "c": 4 }]], + "output": true + }, + { + "category": "ne", + "description": "should calculate (deep) not equal on objects (3)", + "input": null, + "query": ["ne", ["object", { "a": 2, "b": 3, "c": 4 }], ["object", { "b": 3, "a": 2 }]], + "output": true + }, + { + "category": "ne", + "description": "should calculate (deep) not equal on arrays (1)", + "input": null, + "query": ["ne", ["array", 1, 2, 3], ["array", 1, 2, 3]], + "output": false + }, + { + "category": "ne", + "description": "should calculate (deep) not equal on arrays (2)", + "input": null, + "query": ["ne", ["array", 1, 2], ["array", 1, 2, 3]], + "output": true + }, + { + "category": "ne", + "description": "should calculate (deep) not equal on arrays (3)", + "input": null, + "query": ["ne", ["array", 1, 2, 3], ["array", 1, 2]], + "output": true + }, + { + "category": "ne", + "description": "should calculate (deep) not equal on nested objects and arrays (1)", + "input": null, + "query": [ + "ne", + ["object", { "arr": ["array", 1, 2, 3] }], + ["object", { "arr": ["array", 1, 2, 3] }] + ], + "output": false + }, + { + "category": "ne", + "description": "should calculate (deep) not equal on nested objects and arrays (2)", + "input": null, + "query": [ + "ne", + ["object", { "arr": ["array", 1, 2] }], + ["object", { "arr": ["array", 1, 2, 3] }] + ], + "output": true + }, { "category": "and", @@ -1195,6 +1617,49 @@ "query": ["in", 5, ["array", 1, 2, 5, 8]], "output": true }, + { + "category": "in", + "description": "should calculate in finding a string (1)", + "input": null, + "query": ["in", "b", ["array", "a", "b", "c"]], + "output": true + }, + { + "category": "in", + "description": "should calculate in finding a string (2)", + "input": null, + "query": ["in", "d", ["array", "a", "b", "c"]], + "output": false + }, + { + "category": "in", + "description": "should calculate in finding a string (3)", + "input": null, + "query": ["in", "A", ["array", "a", "b", "c"]], + "output": false + }, + { + "category": "in", + "description": "should calculate in finding an object", + "input": null, + "query": [ + "in", + ["object", { "a": 1, "b": 2 }], + ["array", ["object", { "b": 2 }], ["object", { "a": 1 }], ["object", { "a": 1, "b": 2 }]] + ], + "output": true + }, + { + "category": "in", + "description": "should calculate in finding an object", + "input": null, + "query": [ + "in", + ["object", { "a": 1, "b": 3 }], + ["array", ["object", { "b": 2 }], ["object", { "a": 1 }], ["object", { "a": 1, "b": 2 }]] + ], + "output": false + }, { "category": "not in", @@ -1217,6 +1682,49 @@ "query": ["not in", 7, ["array", 1, 2, 5, 8]], "output": true }, + { + "category": "not in", + "description": "should calculate not in finding a string (1)", + "input": null, + "query": ["not in", "b", ["array", "a", "b", "c"]], + "output": false + }, + { + "category": "not in", + "description": "should calculate not in finding a string (2)", + "input": null, + "query": ["not in", "d", ["array", "a", "b", "c"]], + "output": true + }, + { + "category": "not in", + "description": "should calculate not in finding a string (3)", + "input": null, + "query": ["not in", "A", ["array", "a", "b", "c"]], + "output": true + }, + { + "category": "not in", + "description": "should calculate not in finding an object", + "input": null, + "query": [ + "not in", + ["object", { "a": 1, "b": 2 }], + ["array", ["object", { "b": 2 }], ["object", { "a": 1 }], ["object", { "a": 1, "b": 2 }]] + ], + "output": false + }, + { + "category": "not in", + "description": "should calculate not in finding an object", + "input": null, + "query": [ + "not in", + ["object", { "a": 1, "b": 3 }], + ["array", ["object", { "b": 2 }], ["object", { "a": 1 }], ["object", { "a": 1, "b": 2 }]] + ], + "output": true + }, { "category": "regex",