diff --git a/asLogic.js b/asLogic.js index 03854f4..c25709d 100644 --- a/asLogic.js +++ b/asLogic.js @@ -21,7 +21,7 @@ function pick (keep, obj) { export function asLogicSync (functions, keep = ['var'], engine = new LogicEngine()) { engine.methods = pick(keep, engine.methods) engine.addMethod('list', i => [].concat(i)) - Object.keys(functions).forEach(i => engine.addMethod(i, data => Array.isArray(data) ? functions[i](...data) : functions[i](data === null ? undefined : data))) + Object.keys(functions).forEach(i => engine.addMethod(i, data => functions[i](...data))) return engine.build.bind(engine) } diff --git a/asLogic.test.js b/asLogic.test.js index e30fea1..cb14d1a 100644 --- a/asLogic.test.js +++ b/asLogic.test.js @@ -1,7 +1,7 @@ import { asLogicSync, asLogicAsync } from './asLogic.js' const module = { - hello: (name = 'World', last = '') => `Hello, ${name}${last.length ? ' ' : ''}${last}!` + hello: (name = 'World', last = '') => `Hello, ${name || 'World'}${last.length ? ' ' : ''}${last}!` } describe('asLogicSync', () => { diff --git a/async.test.js b/async.test.js index 814334a..4aac58e 100644 --- a/async.test.js +++ b/async.test.js @@ -6,7 +6,7 @@ const modes = [ ] for (const engine of modes) { - engine.addMethod('as1', async (n) => n + 1, { async: true, deterministic: true }) + engine.addMethod('as1', async ([n]) => n + 1, { async: true, deterministic: true }) } modes.forEach((logic) => { @@ -676,7 +676,7 @@ modes.forEach((logic) => { length: ['hello'] }) - expect(answer).toStrictEqual(1) + expect(answer).toStrictEqual(5) }) test('length object (2 keys)', async () => { @@ -781,7 +781,7 @@ modes.forEach((logic) => { describe('addMethod', () => { test('adding a method works', async () => { - logic.addMethod('+1', (item) => item + 1, { sync: true }) + logic.addMethod('+1', ([item]) => item + 1, { sync: true }) expect( await logic.run({ '+1': 7 diff --git a/asyncLogic.js b/asyncLogic.js index 3eff035..96d6208 100644 --- a/asyncLogic.js +++ b/asyncLogic.js @@ -9,6 +9,7 @@ import { buildAsync } from './compiler.js' import omitUndefined from './utilities/omitUndefined.js' import { optimize } from './async_optimizer.js' import { applyPatches } from './compatibility.js' +import { coerceArray } from './utilities/coerceArray.js' /** * An engine capable of running asynchronous JSON Logic. @@ -75,16 +76,15 @@ class AsyncLogicEngine { if (!this.methods[func]) throw new Error(`Method '${func}' was not found in the Logic Engine.`) if (typeof this.methods[func] === 'function') { - const input = await this.run(data, context, { above }) - const result = await this.methods[func](input, context, above, this) + const input = (!data || typeof data !== 'object') ? [data] : await this.run(data, context, { above }) + const result = await this.methods[func](coerceArray(input), context, above, this) return Array.isArray(result) ? Promise.all(result) : result } if (typeof this.methods[func] === 'object') { const { asyncMethod, method, traverse } = this.methods[func] const shouldTraverse = typeof traverse === 'undefined' ? true : traverse - const parsedData = shouldTraverse ? await this.run(data, context, { above }) : data - + const parsedData = shouldTraverse ? ((!data || typeof data !== 'object') ? [data] : coerceArray(await this.run(data, context, { above }))) : data const result = await (asyncMethod || method)(parsedData, context, above, this) return Array.isArray(result) ? Promise.all(result) : result } @@ -96,12 +96,12 @@ class AsyncLogicEngine { * * @param {String} name The name of the method being added. * @param {((args: any, context: any, above: any[], engine: AsyncLogicEngine) => any) | { traverse?: Boolean, method?: (args: any, context: any, above: any[], engine: AsyncLogicEngine) => any, asyncMethod?: (args: any, context: any, above: any[], engine: AsyncLogicEngine) => Promise, deterministic?: Function | Boolean }} method - * @param {{ deterministic?: Boolean, async?: Boolean, sync?: Boolean }} annotations This is used by the compiler to help determine if it can optimize the function being generated. + * @param {{ deterministic?: Boolean, async?: Boolean, sync?: Boolean, optimizeUnary?: boolean }} annotations This is used by the compiler to help determine if it can optimize the function being generated. */ addMethod ( name, method, - { deterministic, async, sync } = {} + { deterministic, async, sync, optimizeUnary } = {} ) { if (typeof async === 'undefined' && typeof sync === 'undefined') sync = false if (typeof sync !== 'undefined') async = !sync @@ -112,7 +112,7 @@ class AsyncLogicEngine { else method = { method, traverse: true } } else method = { ...method } - Object.assign(method, omitUndefined({ deterministic })) + Object.assign(method, omitUndefined({ deterministic, optimizeUnary })) // @ts-ignore this.fallback.addMethod(name, method, { deterministic }) this.methods[name] = declareSync(method, sync) @@ -188,6 +188,8 @@ class AsyncLogicEngine { async build (logic, options = {}) { const { above = [], top = true } = options this.fallback.truthy = this.truthy + // @ts-ignore + this.fallback.allowFunctions = this.allowFunctions if (top) { const constructedFunction = await buildAsync(logic, { engine: this, above, async: true, state: {} }) diff --git a/async_optimizer.js b/async_optimizer.js index 931815b..284f7ce 100644 --- a/async_optimizer.js +++ b/async_optimizer.js @@ -3,6 +3,7 @@ import { isDeterministic } from './compiler.js' import { map } from './async_iterators.js' import { isSync, Sync } from './constants.js' import declareSync from './utilities/declareSync.js' +import { coerceArray } from './utilities/coerceArray.js' /** * Turns an expression like { '+': [1, 2] } into a function that can be called with data. @@ -26,7 +27,8 @@ function getMethod (logic, engine, methodName, above) { return (data, abv) => called(args, data, abv || above, engine) } - const args = logic[methodName] + let args = logic[methodName] + if (!args || typeof args !== 'object') args = [args] if (Array.isArray(args)) { const optimizedArgs = args.map(l => optimize(l, engine, above)) @@ -48,11 +50,11 @@ function getMethod (logic, engine, methodName, above) { if (isSync(optimizedArgs) && (method.method || method[Sync])) { const called = method.method ? method.method : method - return declareSync((data, abv) => called(typeof optimizedArgs === 'function' ? optimizedArgs(data, abv) : optimizedArgs, data, abv || above, engine), true) + return declareSync((data, abv) => called(coerceArray(typeof optimizedArgs === 'function' ? optimizedArgs(data, abv) : optimizedArgs, method.optimizeUnary), data, abv || above, engine), true) } return async (data, abv) => { - return called(typeof optimizedArgs === 'function' ? await optimizedArgs(data, abv) : optimizedArgs, data, abv || above, engine) + return called(coerceArray(typeof optimizedArgs === 'function' ? await optimizedArgs(data, abv) : optimizedArgs, method.optimizeUnary), data, abv || above, engine) } } } @@ -65,6 +67,7 @@ function getMethod (logic, engine, methodName, above) { * @returns A function that optimizes the logic for the engine in advance. */ export function optimize (logic, engine, above = []) { + engine.fallback.allowFunctions = engine.allowFunctions if (Array.isArray(logic)) { const arr = logic.map(l => optimize(l, engine, above)) if (isSync(arr)) return declareSync((data, abv) => arr.map(l => typeof l === 'function' ? l(data, abv) : l), true) diff --git a/bench/incompatible.json b/bench/incompatible.json deleted file mode 100644 index 0637a08..0000000 --- a/bench/incompatible.json +++ /dev/null @@ -1 +0,0 @@ -[] \ No newline at end of file diff --git a/build.test.js b/build.test.js index e929866..e119040 100644 --- a/build.test.js +++ b/build.test.js @@ -231,11 +231,11 @@ function timeout (n, x) { expect(await f({ x: 1, y: 2 })).toStrictEqual({ a: 1, b: 2 }) }) - test('Invalid eachKey', async () => { - expect(async () => await logic.build({ eachKey: 5 })).rejects.toThrow( - InvalidControlInput - ) - }) + // test('Invalid eachKey', async () => { + // expect(async () => await logic.build({ eachKey: 5 })).rejects.toThrow( + // InvalidControlInput + // ) + // }) test('Simple deterministic eachKey', async () => { const f = await logic.build({ eachKey: { a: 1, b: { '+': [1, 1] } } }) @@ -246,7 +246,7 @@ function timeout (n, x) { }) const logic = new AsyncLogicEngine() -logic.addMethod('as1', async (n) => timeout(100, n + 1), { async: true }) +logic.addMethod('as1', async ([n]) => timeout(100, n + 1), { async: true }) describe('Testing async build with full async', () => { test('Async +1', async () => { diff --git a/compatible.test.js b/compatible.test.js index 5a624ab..e4b0966 100644 --- a/compatible.test.js +++ b/compatible.test.js @@ -1,6 +1,12 @@ import fs from 'fs' import { LogicEngine, AsyncLogicEngine } from './index.js' -const tests = JSON.parse(fs.readFileSync('./bench/compatible.json').toString()) +const tests = [] + +// get all json files from "suites" directory +const files = fs.readdirSync('./suites') +for (const file of files) { + if (file.endsWith('.json')) tests.push(...JSON.parse(fs.readFileSync(`./suites/${file}`).toString()).filter(i => typeof i !== 'string')) +} // eslint-disable-next-line no-labels inline: { diff --git a/compiler.js b/compiler.js index fe71d9b..06e27d6 100644 --- a/compiler.js +++ b/compiler.js @@ -10,6 +10,7 @@ import declareSync from './utilities/declareSync.js' // asyncIterators is required for the compiler to operate as intended. import asyncIterators from './async_iterators.js' +import { coerceArray } from './utilities/coerceArray.js' /** * Provides a simple way to compile logic into a function that can be run. @@ -191,8 +192,11 @@ function buildString (method, buildState = {}) { } } + let lower = method[func] + if (!lower || typeof lower !== 'object') lower = [lower] + if (engine.methods[func] && engine.methods[func].compile) { - let str = engine.methods[func].compile(method[func], buildState) + let str = engine.methods[func].compile(lower, buildState) if (str[Compiled]) str = str[Compiled] if ((str || '').startsWith('await')) buildState.asyncDetected = true @@ -200,16 +204,20 @@ function buildString (method, buildState = {}) { if (str !== false) return str } + let coerce = engine.methods[func].optimizeUnary ? '' : 'coerceArray' + if (!coerce && Array.isArray(lower) && lower.length === 1) lower = lower[0] + else if (coerce && Array.isArray(lower)) coerce = '' + if (typeof engine.methods[func] === 'function') { asyncDetected = !isSync(engine.methods[func]) - return makeAsync(`engine.methods["${func}"](` + buildString(method[func], buildState) + ', context, above, engine)') + return makeAsync(`engine.methods["${func}"](${coerce}(` + buildString(lower, buildState) + '), context, above, engine)') } else { if (engine.methods[func] && (typeof engine.methods[func].traverse === 'undefined' ? true : engine.methods[func].traverse)) { asyncDetected = Boolean(async && engine.methods[func] && engine.methods[func].asyncMethod) - return makeAsync(`engine.methods["${func}"]${asyncDetected ? '.asyncMethod' : '.method'}(` + buildString(method[func], buildState) + ', context, above, engine)') + return makeAsync(`engine.methods["${func}"]${asyncDetected ? '.asyncMethod' : '.method'}(${coerce}(` + buildString(lower, buildState) + '), context, above, engine)') } else { asyncDetected = Boolean(async && engine.methods[func] && engine.methods[func].asyncMethod) - notTraversed.push(method[func]) + notTraversed.push(lower) return makeAsync(`engine.methods["${func}"]${asyncDetected ? '.asyncMethod' : '.method'}(` + `notTraversed[${notTraversed.length - 1}]` + ', context, above, engine)') } } @@ -294,12 +302,12 @@ function processBuiltString (method, str, buildState) { str = str.replace(`__%%%${x}%%%__`, item) }) - const final = `(values, methods, notTraversed, asyncIterators, engine, above) => ${buildState.asyncDetected ? 'async' : ''} (context ${buildState.extraArguments ? ',' + buildState.extraArguments : ''}) => { const result = ${str}; return result }` + const final = `(values, methods, notTraversed, asyncIterators, engine, above, coerceArray) => ${buildState.asyncDetected ? 'async' : ''} (context ${buildState.extraArguments ? ',' + buildState.extraArguments : ''}) => { const result = ${str}; return result }` // console.log(str) // console.log(final) // eslint-disable-next-line no-eval - return declareSync((typeof globalThis !== 'undefined' ? globalThis : global).eval(final)(values, methods, notTraversed, asyncIterators, engine, above), !buildState.asyncDetected) + return declareSync((typeof globalThis !== 'undefined' ? globalThis : global).eval(final)(values, methods, notTraversed, asyncIterators, engine, above, coerceArray), !buildState.asyncDetected) } export { build } diff --git a/defaultMethods.js b/defaultMethods.js index 39e0990..ba40e4a 100644 --- a/defaultMethods.js +++ b/defaultMethods.js @@ -178,7 +178,7 @@ const defaultMethods = { } return string.substr(from, end) }, - length: (i) => { + length: ([i]) => { if (typeof i === 'string' || Array.isArray(i)) return i.length if (i && typeof i === 'object') return Object.keys(i).length return 0 @@ -398,7 +398,7 @@ const defaultMethods = { for (let i = 0; i < arr.length; i++) res += arr[i] return res }, - keys: (obj) => typeof obj === 'object' ? Object.keys(obj) : [], + keys: ([obj]) => typeof obj === 'object' ? Object.keys(obj) : [], pipe: { traverse: false, [Sync]: (data, buildState) => isSyncDeep(data, buildState.engine, buildState), @@ -799,13 +799,13 @@ defaultMethods.var.compile = function (data, buildState) { typeof data === 'number' || (Array.isArray(data) && data.length <= 2) ) { - if (data === '../index' && buildState.iteratorCompile) return 'index' - if (Array.isArray(data)) { key = data[0] defaultValue = typeof data[1] === 'undefined' ? null : data[1] } + if (key === '../index' && buildState.iteratorCompile) return 'index' + // this counts the number of var accesses to determine if they're all just using this override. // this allows for a small optimization :) if (typeof key === 'undefined' || key === null || key === '') return 'context' @@ -835,6 +835,9 @@ defaultMethods.var.compile = function (data, buildState) { return false } +// @ts-ignore Allowing a optimizeUnary attribute that can be used for performance optimizations +defaultMethods['+'].optimizeUnary = defaultMethods['-'].optimizeUnary = defaultMethods.var.optimizeUnary = defaultMethods['!'].optimizeUnary = defaultMethods['!!'].optimizeUnary = defaultMethods.cat.optimizeUnary = true + export default { ...defaultMethods } diff --git a/general.test.js b/general.test.js index 376bd80..7ecbb93 100644 --- a/general.test.js +++ b/general.test.js @@ -138,8 +138,8 @@ describe('Various Test Cases', () => { try { for (const engine of [...normalEngines, ...permissiveEngines]) { engine.allowFunctions = true - engine.addMethod('typeof', (value) => typeof value) - await testEngine(engine, { typeof: { var: 'toString' } }, 'hello', 'function') + engine.addMethod('typeof', ([value]) => typeof value) + await testEngine(engine, { typeof: { var: 'toString' } }, {}, 'function') } } finally { for (const engine of [...normalEngines, ...permissiveEngines]) { @@ -149,6 +149,10 @@ describe('Various Test Cases', () => { } }) + it('is able to handle max with 1 element', async () => { + for (const engine of [...normalEngines, ...permissiveEngines]) await testEngine(engine, { max: 5 }, {}, 5) + }) + it('is able to handle path escaping in a var call', async () => { for (const engine of [...normalEngines, ...permissiveEngines]) await testEngine(engine, { var: 'hello\\.world' }, { 'hello.world': 2 }, 2) }) @@ -179,7 +183,7 @@ describe('Various Test Cases', () => { for (const engine of [...normalEngines, ...permissiveEngines]) await testEngine(engine, { pipe: [{ var: 'name' }, { cat: ['Hello, ', { var: '' }, '!'] }] }, { name: 'Austin' }, 'Hello, Austin!') for (const engine of [normalEngines[1], normalEngines[3], permissiveEngines[1], permissiveEngines[3]]) { - engine.addMethod('as1', async (n) => n + 1, { async: true, deterministic: true }) + engine.addMethod('as1', async ([n]) => n + 1, { async: true, deterministic: true }) await testEngine(engine, { pipe: [ 'Austin', diff --git a/logic.js b/logic.js index 89ed9ec..45e8a0e 100644 --- a/logic.js +++ b/logic.js @@ -8,6 +8,7 @@ import declareSync from './utilities/declareSync.js' import omitUndefined from './utilities/omitUndefined.js' import { optimize } from './optimizer.js' import { applyPatches } from './compatibility.js' +import { coerceArray } from './utilities/coerceArray.js' /** * An engine capable of running synchronous JSON Logic. @@ -71,14 +72,14 @@ class LogicEngine { if (!this.methods[func]) throw new Error(`Method '${func}' was not found in the Logic Engine.`) if (typeof this.methods[func] === 'function') { - const input = this.run(data, context, { above }) + const input = (!data || typeof data !== 'object') ? [data] : coerceArray(this.run(data, context, { above })) return this.methods[func](input, context, above, this) } if (typeof this.methods[func] === 'object') { const { method, traverse } = this.methods[func] const shouldTraverse = typeof traverse === 'undefined' ? true : traverse - const parsedData = shouldTraverse ? this.run(data, context, { above }) : data + const parsedData = shouldTraverse ? ((!data || typeof data !== 'object') ? [data] : coerceArray(this.run(data, context, { above }))) : data return method(parsedData, context, above, this) } @@ -89,12 +90,12 @@ class LogicEngine { * * @param {String} name The name of the method being added. * @param {((args: any, context: any, above: any[], engine: LogicEngine) => any) |{ traverse?: Boolean, method: (args: any, context: any, above: any[], engine: LogicEngine) => any, deterministic?: Function | Boolean }} method - * @param {{ deterministic?: Boolean }} annotations This is used by the compiler to help determine if it can optimize the function being generated. + * @param {{ deterministic?: Boolean, optimizeUnary?: Boolean }} annotations This is used by the compiler to help determine if it can optimize the function being generated. */ - addMethod (name, method, { deterministic } = {}) { + addMethod (name, method, { deterministic, optimizeUnary } = {}) { if (typeof method === 'function') method = { method, traverse: true } else method = { ...method } - Object.assign(method, omitUndefined({ deterministic })) + Object.assign(method, omitUndefined({ deterministic, optimizeUnary })) this.methods[name] = declareSync(method) } diff --git a/optimizer.js b/optimizer.js index b85b344..dc90e2f 100644 --- a/optimizer.js +++ b/optimizer.js @@ -1,5 +1,6 @@ // This is the synchronous version of the optimizer; which the Async one should be based on. import { isDeterministic } from './compiler.js' +import { coerceArray } from './utilities/coerceArray.js' /** * Turns an expression like { '+': [1, 2] } into a function that can be called with data. @@ -18,7 +19,8 @@ function getMethod (logic, engine, methodName, above) { return (data, abv) => called(args, data, abv || above, engine) } - const args = logic[methodName] + let args = logic[methodName] + if (!args || typeof args !== 'object') args = [args] if (Array.isArray(args)) { const optimizedArgs = args.map(l => optimize(l, engine, above)) @@ -29,7 +31,7 @@ function getMethod (logic, engine, methodName, above) { } else { const optimizedArgs = optimize(args, engine, above) return (data, abv) => { - return called(typeof optimizedArgs === 'function' ? optimizedArgs(data, abv) : optimizedArgs, data, abv || above, engine) + return called(coerceArray(typeof optimizedArgs === 'function' ? optimizedArgs(data, abv) : optimizedArgs, method.optimizeUnary), data, abv || above, engine) } } } diff --git a/package.json b/package.json index 70c4747..94a5e64 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "json-logic-engine", - "version": "3.0.5", + "version": "4.0.0", "description": "Construct complex rules with JSON & process them.", "main": "./dist/cjs/index.js", "module": "./dist/esm/index.js", diff --git a/suites/chained.json b/suites/chained.json new file mode 100644 index 0000000..26fa93d --- /dev/null +++ b/suites/chained.json @@ -0,0 +1,64 @@ +[ + "These are tests from https://github.com/orgs/json-logic/discussions/2", + [ + { "max": [1, 2, 3] }, + {}, + 3, + "Standard Max" + ], + [ + { "max": 1 }, + {}, + 1, + "Standard Max, Single Argument Sugared" + ], + [ + { "max": { "var": "data" } }, + { "data": [1, 2, 3] }, + 3, + "Max with Logic Chaining" + ], + [ + { "cat": { "merge": [["Hello "], ["World", "!"]] } }, + {}, + "Hello World!", + "Cat with Logic Chaining" + ], + [ + { "cat": { "var": "text" } }, + { "text": ["Hello ", "World", "!"] }, + "Hello World!", + "Cat with Logic Chaining (Simple)" + ], + [ + { + "max": { + "map": [ + { + "filter": [ + { "var": "people" }, + { "===": [{ "var": "department" }, "Engineering"] } + ] + }, + { "var": "salary" } + ] + } + }, + { + "people": [ + { "name": "Jay Ortiz", "salary": 100414, "department": "Engineering"}, + { "name": "Louisa Hall", "salary": 133601, "department": "Sales"}, + { "name": "Kyle Carlson", "salary": 139803, "department": "Sales"}, + { "name": "Grace Ortiz", "salary": 147068, "department": "Engineering"}, + { "name": "Isabelle Harrington", "salary": 112704, "department": "Marketing"}, + { "name": "Harold Moore", "salary": 125221, "department": "Sales"}, + { "name": "Clarence Schultz", "salary": 127985, "department": "Sales"}, + { "name": "Jesse Keller", "salary": 149212, "department": "Engineering"}, + { "name": "Phillip Holland", "salary": 105888, "department": "Marketing"}, + { "name": "Mason Sullivan", "salary": 147161, "department": "Engineering" } + ] + }, + 149212, + "Max with Logic Chaining (Complex)" + ] + ] \ No newline at end of file diff --git a/bench/compatible.json b/suites/compatible.json similarity index 100% rename from bench/compatible.json rename to suites/compatible.json diff --git a/test.js b/test.js index e818479..752ebaf 100644 --- a/test.js +++ b/test.js @@ -704,7 +704,7 @@ modes.forEach((logic) => { length: ['hello'] }) - expect(answer).toStrictEqual(1) + expect(answer).toStrictEqual(5) }) test('length object (2 keys)', () => { @@ -813,7 +813,7 @@ modes.forEach((logic) => { describe('addMethod', () => { test('adding a method works', () => { - logic.addMethod('+1', (item) => item + 1) + logic.addMethod('+1', ([item]) => item + 1) expect( logic.run({ '+1': 7 diff --git a/utilities/coerceArray.js b/utilities/coerceArray.js new file mode 100644 index 0000000..0c80880 --- /dev/null +++ b/utilities/coerceArray.js @@ -0,0 +1,9 @@ + +/** + * Coerces a value into an array. + * This is used for unary value operations. + */ +export function coerceArray (value, skip = false) { + if (skip) return value + return Array.isArray(value) ? value : [value] +}