diff --git a/asyncLogic.js b/asyncLogic.js index fb1d95e..5f90f56 100644 --- a/asyncLogic.js +++ b/asyncLogic.js @@ -89,9 +89,8 @@ class AsyncLogicEngine { } if (typeof this.methods[func] === 'object') { - const { asyncMethod, method, traverse } = this.methods[func] - const shouldTraverse = typeof traverse === 'undefined' ? true : traverse - const parsedData = shouldTraverse ? ((!data || typeof data !== 'object') ? [data] : coerceArray(await this.run(data, context, { above }))) : data + const { asyncMethod, method, lazy } = this.methods[func] + const parsedData = !lazy ? ((!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 } @@ -102,7 +101,7 @@ 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 {((args: any, context: any, above: any[], engine: AsyncLogicEngine) => any) | { lazy?: Boolean, 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, optimizeUnary?: boolean }} annotations This is used by the compiler to help determine if it can optimize the function being generated. */ addMethod ( @@ -115,9 +114,9 @@ class AsyncLogicEngine { if (typeof async !== 'undefined') sync = !async if (typeof method === 'function') { - if (async) method = { asyncMethod: method, traverse: true } - else method = { method, traverse: true } - } else method = { ...method } + if (async) method = { asyncMethod: method, lazy: false } + else method = { method, lazy: false } + } else method = { ...method, lazy: typeof method.traverse !== 'undefined' ? !method.traverse : method.lazy } Object.assign(method, omitUndefined({ deterministic, optimizeUnary })) // @ts-ignore @@ -174,10 +173,10 @@ class AsyncLogicEngine { // END OPTIMIZER BLOCK // if (Array.isArray(logic)) { - const res = [] + const res = new Array(logic.length) // Note: In the past, it used .map and Promise.all; this can be changed in the future // if we want it to run concurrently. - for (let i = 0; i < logic.length; i++) res.push(await this.run(logic[i], data, { above })) + for (let i = 0; i < logic.length; i++) res[i] = await this.run(logic[i], data, { above }) return res } diff --git a/async_optimizer.js b/async_optimizer.js index 7c27584..0d2d61b 100644 --- a/async_optimizer.js +++ b/async_optimizer.js @@ -17,7 +17,7 @@ function getMethod (logic, engine, methodName, above) { const method = engine.methods[methodName] const called = method.asyncMethod ? method.asyncMethod : method.method ? method.method : method - if (method.traverse === false) { + if (method.lazy) { if (typeof method[Sync] === 'function' && method[Sync](logic, { engine })) { const called = method.method ? method.method : method return declareSync((data, abv) => called(logic[methodName], data, abv || above, engine.fallback), true) diff --git a/compatibility.js b/compatibility.js index 6bc75de..e57ea2e 100644 --- a/compatibility.js +++ b/compatibility.js @@ -19,11 +19,12 @@ const all = { return oldAll.asyncMethod(args, context, above, engine) }, deterministic: oldAll.deterministic, - traverse: oldAll.traverse + lazy: oldAll.lazy } function truthy (value) { if (Array.isArray(value) && value.length === 0) return false + if (Number.isNaN(value)) return true return value } diff --git a/compatible.test.js b/compatible.test.js index 141a9f3..107b320 100644 --- a/compatible.test.js +++ b/compatible.test.js @@ -16,6 +16,7 @@ for (const file of files) { function correction (x) { // eslint-disable-next-line no-compare-neg-zero if (x === -0) return 0 + if (Number.isNaN(x)) return { error: 'NaN' } return x } diff --git a/compiler.js b/compiler.js index 9fad6ec..e72eade 100644 --- a/compiler.js +++ b/compiler.js @@ -11,6 +11,7 @@ import { import asyncIterators from './async_iterators.js' import { coerceArray } from './utilities/coerceArray.js' import { countArguments } from './utilities/countArguments.js' +import { downgrade, precoerceNumber } from './utilities/downgrade.js' /** * Provides a simple way to compile logic into a function that can be run. @@ -87,7 +88,7 @@ export function isDeterministic (method, engine, buildState) { if (lower === undefined) return true if (!engine.methods[func]) throw new Error(`Method '${func}' was not found in the Logic Engine.`) - if (engine.methods[func].traverse === false) { + if (engine.methods[func].lazy) { return typeof engine.methods[func].deterministic === 'function' ? engine.methods[func].deterministic(lower, buildState) : engine.methods[func].deterministic @@ -118,7 +119,7 @@ function isDeepSync (method, engine) { const lower = method[func] if (!isSync(engine.methods[func])) return false - if (engine.methods[func].traverse === false) { + if (engine.methods[func].lazy) { if (typeof engine.methods[func][Sync] === 'function' && engine.methods[func][Sync](method, { engine })) return true return false } @@ -193,7 +194,7 @@ function buildString (method, buildState = {}) { } let lower = method[func] - if (!lower || typeof lower !== 'object') lower = [lower] + if ((!lower || typeof lower !== 'object') && (!engine.methods[func].lazy)) lower = [lower] if (engine.methods[func] && engine.methods[func].compile) { let str = engine.methods[func].compile(lower, buildState) @@ -219,7 +220,7 @@ function buildString (method, buildState = {}) { const argCount = countArguments(asyncDetected ? engine.methods[func].asyncMethod : engine.methods[func].method) const argumentsNeeded = argumentsDict[argCount - 1] || argumentsDict[2] - if (engine.methods[func] && (typeof engine.methods[func].traverse === 'undefined' ? true : engine.methods[func].traverse)) { + if (engine.methods[func] && !engine.methods[func].lazy) { return makeAsync(`engine.methods["${func}"]${asyncDetected ? '.asyncMethod' : '.method'}(${coerce}(` + buildString(lower, buildState) + ')' + argumentsNeeded + ')') } else { notTraversed.push(lower) @@ -307,12 +308,12 @@ function processBuiltString (method, str, buildState) { str = str.replace(`__%%%${x}%%%__`, item) }) - const final = `(values, methods, notTraversed, asyncIterators, engine, above, coerceArray) => ${buildState.asyncDetected ? 'async' : ''} (context ${buildState.extraArguments ? ',' + buildState.extraArguments : ''}) => { let prev; const result = ${str}; return result }` + const final = `(values, methods, notTraversed, asyncIterators, engine, above, coerceArray, downgrade, precoerceNumber) => ${buildState.asyncDetected ? 'async' : ''} (context ${buildState.extraArguments ? ',' + buildState.extraArguments : ''}) => { ${str.includes('prev') ? 'let prev;' : ''} const result = ${str}; return result }` // console.log(str) // console.log(final) // eslint-disable-next-line no-eval return Object.assign( - (typeof globalThis !== 'undefined' ? globalThis : global).eval(final)(values, methods, notTraversed, asyncIterators, engine, above, coerceArray), { + (typeof globalThis !== 'undefined' ? globalThis : global).eval(final)(values, methods, notTraversed, asyncIterators, engine, above, coerceArray, downgrade, precoerceNumber), { [Sync]: !buildState.asyncDetected, aboveDetected: typeof str === 'string' && str.includes(', above') }) diff --git a/defaultMethods.js b/defaultMethods.js index a3800ad..dbfcd9c 100644 --- a/defaultMethods.js +++ b/defaultMethods.js @@ -8,6 +8,7 @@ import { build, buildString } from './compiler.js' import chainingSupported from './utilities/chainingSupported.js' import InvalidControlInput from './errors/InvalidControlInput.js' import legacyMethods from './legacy.js' +import { downgrade } from './utilities/downgrade.js' function isDeterministic (method, engine, buildState) { if (Array.isArray(method)) { @@ -20,11 +21,12 @@ function isDeterministic (method, engine, buildState) { if (engine.isData(method, func)) return true if (!engine.methods[func]) throw new Error(`Method '${func}' was not found in the Logic Engine.`) - if (engine.methods[func].traverse === false) { + if (engine.methods[func].lazy) { return typeof engine.methods[func].deterministic === 'function' ? engine.methods[func].deterministic(lower, buildState) : engine.methods[func].deterministic } + return typeof engine.methods[func].deterministic === 'function' ? engine.methods[func].deterministic(lower, buildState) : engine.methods[func].deterministic && @@ -43,7 +45,7 @@ function isSyncDeep (method, engine, buildState) { const lower = method[func] if (engine.isData(method, func)) return true if (!engine.methods[func]) throw new Error(`Method '${func}' was not found in the Logic Engine.`) - if (engine.methods[func].traverse === false) return typeof engine.methods[func][Sync] === 'function' ? engine.methods[func][Sync](lower, buildState) : engine.methods[func][Sync] + if (engine.methods[func].lazy) return typeof engine.methods[func][Sync] === 'function' ? engine.methods[func][Sync](lower, buildState) : engine.methods[func][Sync] return typeof engine.methods[func][Sync] === 'function' ? engine.methods[func][Sync](lower, buildState) : engine.methods[func][Sync] && isSyncDeep(lower, engine, buildState) } @@ -56,18 +58,29 @@ const defaultMethods = { if (typeof data === 'string') return +data if (typeof data === 'number') return +data if (typeof data === 'boolean') return +data + if (typeof data === 'object' && !Array.isArray(data)) return Number.NaN let res = 0 - for (let i = 0; i < data.length; i++) res += +data[i] + for (let i = 0; i < data.length; i++) { + if (data[i] && typeof data[i] === 'object') return Number.NaN + res += +data[i] + } return res }, '*': (data) => { let res = 1 - for (let i = 0; i < data.length; i++) res *= +data[i] + for (let i = 0; i < data.length; i++) { + if (data[i] && typeof data[i] === 'object') return Number.NaN + res *= +data[i] + } return res }, '/': (data) => { + if (data[0] && typeof data[0] === 'object') return Number.NaN let res = +data[0] - for (let i = 1; i < data.length; i++) res /= +data[i] + for (let i = 1; i < data.length; i++) { + if (data[i] && typeof data[i] === 'object') return Number.NaN + res /= +data[i] + } return res }, '-': (data) => { @@ -75,21 +88,41 @@ const defaultMethods = { if (typeof data === 'string') return -data if (typeof data === 'number') return -data if (typeof data === 'boolean') return -data + if (typeof data === 'object' && !Array.isArray(data)) return Number.NaN + if (data[0] && typeof data[0] === 'object') return Number.NaN if (data.length === 1) return -data[0] let res = data[0] - for (let i = 1; i < data.length; i++) res -= +data[i] + for (let i = 1; i < data.length; i++) { + if (data[i] && typeof data[i] === 'object') return Number.NaN + res -= +data[i] + } return res }, '%': (data) => { + if (data[0] && typeof data[0] === 'object') return Number.NaN let res = +data[0] - for (let i = 1; i < data.length; i++) res %= +data[i] + for (let i = 1; i < data.length; i++) { + if (data[i] && typeof data[i] === 'object') return Number.NaN + res %= +data[i] + } return res }, + error: (type) => { + if (Array.isArray(type)) type = type[0] + if (type === 'NaN') return Number.NaN + return { error: type } + }, + panic: (item) => { + if (Array.isArray(item)) item = item[0] + if (Number.isNaN(item)) throw new Error('NaN was returned from expression') + if (item && item.error) throw item.error + return item + }, max: (data) => Math.max(...data), min: (data) => Math.min(...data), in: ([item, array]) => (array || []).includes(item), preserve: { - traverse: false, + lazy: true, method: declareSync((i) => i, true), [Sync]: () => true }, @@ -150,7 +183,7 @@ const defaultMethods = { return engine.run(onFalse, context, { above }) }, - traverse: false + lazy: true }, '<': (args) => { if (args.length === 2) return args[0] < args[1] @@ -215,44 +248,6 @@ const defaultMethods = { xor: ([a, b]) => a ^ b, // Why "executeInLoop"? Because if it needs to execute to get an array, I do not want to execute the arguments, // Both for performance and safety reasons. - '??': { - [Sync]: (data, buildState) => isSyncDeep(data, buildState.engine, buildState), - method: (arr, _1, _2, engine) => { - // See "executeInLoop" above - const executeInLoop = Array.isArray(arr) - if (!executeInLoop) arr = engine.run(arr, _1, { above: _2 }) - - let item - for (let i = 0; i < arr.length; i++) { - item = executeInLoop ? engine.run(arr[i], _1, { above: _2 }) : arr[i] - if (item !== null && item !== undefined) return item - } - - if (item === undefined) return null - return item - }, - asyncMethod: async (arr, _1, _2, engine) => { - // See "executeInLoop" above - const executeInLoop = Array.isArray(arr) - if (!executeInLoop) arr = await engine.run(arr, _1, { above: _2 }) - - let item - for (let i = 0; i < arr.length; i++) { - item = executeInLoop ? await engine.run(arr[i], _1, { above: _2 }) : arr[i] - if (item !== null && item !== undefined) return item - } - - if (item === undefined) return null - return item - }, - deterministic: (data, buildState) => isDeterministic(data, buildState.engine, buildState), - compile: (data, buildState) => { - if (!chainingSupported) return false - if (Array.isArray(data) && data.length) return `(${data.map((i) => buildString(i, buildState)).join(' ?? ')})` - return `(${buildString(data, buildState)}).reduce((a,b) => a ?? b, null)` - }, - traverse: false - }, or: { [Sync]: (data, buildState) => isSyncDeep(data, buildState.engine, buildState), method: (arr, _1, _2, engine) => { @@ -283,12 +278,22 @@ const defaultMethods = { }, deterministic: (data, buildState) => isDeterministic(data, buildState.engine, buildState), compile: (data, buildState) => { - if (!buildState.engine.truthy[OriginalImpl]) return false + if (!buildState.engine.truthy[OriginalImpl]) { + let res = buildState.compile`` + if (Array.isArray(data) && data.length) { + for (let i = 0; i < data.length; i++) res = buildState.compile`${res} engine.truthy(prev = ${data[i]}) ? prev : ` + res = buildState.compile`${res} prev` + return res + } + return false + } if (Array.isArray(data) && data.length) return `(${data.map((i) => buildString(i, buildState)).join(' || ')})` return `(${buildString(data, buildState)}).reduce((a,b) => a||b, false)` }, - traverse: false + lazy: true }, + '??': defineCoalesce(), + try: defineCoalesce(downgrade), and: { [Sync]: (data, buildState) => isSyncDeep(data, buildState.engine, buildState), method: (arr, _1, _2, engine) => { @@ -315,10 +320,18 @@ const defaultMethods = { } return item }, - traverse: false, + lazy: true, deterministic: (data, buildState) => isDeterministic(data, buildState.engine, buildState), compile: (data, buildState) => { - if (!buildState.engine.truthy[OriginalImpl]) return false + if (!buildState.engine.truthy[OriginalImpl]) { + let res = buildState.compile`` + if (Array.isArray(data) && data.length) { + for (let i = 0; i < data.length; i++) res = buildState.compile`${res} !engine.truthy(prev = ${data[i]}) ? prev : ` + res = buildState.compile`${res} prev` + return res + } + return false + } if (Array.isArray(data) && data.length) return `(${data.map((i) => buildString(i, buildState)).join(' && ')})` return `(${buildString(data, buildState)}).reduce((a,b) => a&&b, true)` } @@ -340,7 +353,6 @@ const defaultMethods = { const result = defaultMethods.val.method(key, context, above, engine, Unfound) return result !== Unfound }, - traverse: true, deterministic: false }, val: { @@ -392,13 +404,13 @@ const defaultMethods = { }, compile: (data, buildState) => { function wrapNull (data) { - if (!chainingSupported) return buildState.compile`(methods.preventFunctions(((a) => a === null || a === undefined ? null : a)(${data})))` - return buildState.compile`(methods.preventFunctions(${data} ?? null))` + let res + if (!chainingSupported) res = buildState.compile`(((a) => a === null || a === undefined ? null : a)(${data}))` + else res = buildState.compile`(${data} ?? null)` + if (!buildState.engine.allowFunctions) res = buildState.compile`(typeof (prev = ${res}) === 'function' ? null : prev)` + return res } - if (!buildState.engine.allowFunctions) buildState.methods.preventFunctions = a => typeof a === 'function' ? null : a - else buildState.methods.preventFunctions = a => a - if (typeof data === 'object' && !Array.isArray(data)) { // If the input for this function can be inlined, we will do so right here. if (isSyncDeep(data, buildState.engine, buildState) && isDeterministic(data, buildState.engine, buildState) && !buildState.engine.disableInline) data = (buildState.engine.fallback || buildState.engine).run(data, buildState.context, { above: buildState.above }) @@ -432,7 +444,7 @@ const defaultMethods = { all: createArrayIterativeMethod('every', true), none: { [Sync]: (data, buildState) => isSyncDeep(data, buildState.engine, buildState), - traverse: false, + lazy: true, // todo: add async build & build method: (val, context, above, engine) => { return !defaultMethods.some.method(val, context, above, engine) @@ -568,7 +580,7 @@ const defaultMethods = { defaultValue ) }, - traverse: false + lazy: true }, '!': (value, _1, _2, engine) => Array.isArray(value) ? !engine.truthy(value[0]) : !engine.truthy(value), '!!': (value, _1, _2, engine) => Boolean(Array.isArray(value) ? engine.truthy(value[0]) : engine.truthy(value)), @@ -586,7 +598,6 @@ const defaultMethods = { return res }, deterministic: true, - traverse: true, optimizeUnary: true, compile: (data, buildState) => { if (typeof data === 'string') return JSON.stringify(data) @@ -599,7 +610,7 @@ const defaultMethods = { }, keys: ([obj]) => typeof obj === 'object' ? Object.keys(obj) : [], pipe: { - traverse: false, + lazy: true, [Sync]: (data, buildState) => isSyncDeep(data, buildState.engine, buildState), method: (args, context, above, engine) => { if (!Array.isArray(args)) throw new Error('Data for pipe must be an array') @@ -626,7 +637,7 @@ const defaultMethods = { } }, eachKey: { - traverse: false, + lazy: true, [Sync]: (data, buildState) => isSyncDeep(Object.values(data[Object.keys(data)[0]]), buildState.engine, buildState), method: (object, context, above, engine) => { const result = Object.keys(object).reduce((accumulator, key) => { @@ -682,6 +693,60 @@ const defaultMethods = { } } +/** + * Defines separate coalesce methods + */ +function defineCoalesce (func) { + let downgrade + if (func) downgrade = func + else downgrade = (a) => a + + return { + [Sync]: (data, buildState) => isSyncDeep(data, buildState.engine, buildState), + method: (arr, _1, _2, engine) => { + // See "executeInLoop" above + const executeInLoop = Array.isArray(arr) + if (!executeInLoop) arr = engine.run(arr, _1, { above: _2 }) + + let item + for (let i = 0; i < arr.length; i++) { + item = executeInLoop ? engine.run(arr[i], _1, { above: _2 }) : arr[i] + if (downgrade(item) !== null && item !== undefined) return item + } + + if (item === undefined) return null + return item + }, + asyncMethod: async (arr, _1, _2, engine) => { + // See "executeInLoop" above + const executeInLoop = Array.isArray(arr) + if (!executeInLoop) arr = await engine.run(arr, _1, { above: _2 }) + + let item + for (let i = 0; i < arr.length; i++) { + item = executeInLoop ? await engine.run(arr[i], _1, { above: _2 }) : arr[i] + if (downgrade(item) !== null && item !== undefined) return item + } + + if (item === undefined) return null + return item + }, + deterministic: (data, buildState) => isDeterministic(data, buildState.engine, buildState), + compile: (data, buildState) => { + if (!chainingSupported) return false + const funcCall = func ? 'downgrade' : '' + if (Array.isArray(data) && data.length) { + return `(${data.map((i, x) => { + if (Array.isArray(i) || !i || typeof i !== 'object' || x === data.length - 1) return buildString(i, buildState) + return `${funcCall}(` + buildString(i, buildState) + ')' + }).join(' ?? ')})` + } + return `(${buildString(data, buildState)}).reduce((a,b) => ${funcCall}(a) ?? b, null)` + }, + lazy: true + } +} + function createArrayIterativeMethod (name, useTruthy = false) { return { deterministic: (data, buildState) => { @@ -748,7 +813,7 @@ function createArrayIterativeMethod (name, useTruthy = false) { return buildState.compile`(${selector} || [])[${name}]((i, x, z) => ${useTruthyMethod}(${method}(i, x, ${aboveArray})))` }, - traverse: false + lazy: true } } defaultMethods['?:'] = defaultMethods.if @@ -851,31 +916,27 @@ defaultMethods['==='].compile = function (data, buildState) { for (let i = 2; i < data.length; i++) res = buildState.compile`(${res} && ${data[i - 1]} === ${data[i]})` return res } + +/** + * Transforms the operands of the arithmetic operation to numbers. + */ +function numberCoercion (i, buildState) { + if (Array.isArray(i)) return 'NaN' + if (typeof i === 'string' || typeof i === 'number' || typeof i === 'boolean') return `(+${buildString(i, buildState)})` + return `(+precoerceNumber(${buildString(i, buildState)}))` +} + // @ts-ignore Allow custom attribute defaultMethods['+'].compile = function (data, buildState) { - if (Array.isArray(data)) { - return `(${data - .map((i) => `(+${buildString(i, buildState)})`) - .join(' + ')})` - } else if (typeof data === 'string' || typeof data === 'number') { - return `(+${buildString(data, buildState)})` - } else { - return `([].concat(${buildString( - data, - buildState - )})).reduce((a,b) => (+a)+(+b), 0)` - } + if (Array.isArray(data)) return `(${data.map(i => numberCoercion(i, buildState)).join(' + ')})` + if (typeof data === 'string' || typeof data === 'number' || typeof data === 'boolean') return `(+${buildString(data, buildState)})` + return buildState.compile`(Array.isArray(prev = ${data}) ? prev.reduce((a,b) => (+a)+(+precoerceNumber(b)), 0) : +precoerceNumber(prev))` } // @ts-ignore Allow custom attribute defaultMethods['%'].compile = function (data, buildState) { - if (Array.isArray(data)) { - return `(${data - .map((i) => `(+${buildString(i, buildState)})`) - .join(' % ')})` - } else { - return `(${buildString(data, buildState)}).reduce((a,b) => (+a)%(+b))` - } + if (Array.isArray(data)) return `(${data.map(i => numberCoercion(i, buildState)).join(' % ')})` + return `(${buildString(data, buildState)}).reduce((a,b) => (+precoerceNumber(a))%(+precoerceNumber(b)))` } // @ts-ignore Allow custom attribute @@ -886,47 +947,19 @@ defaultMethods.in.compile = function (data, buildState) { // @ts-ignore Allow custom attribute defaultMethods['-'].compile = function (data, buildState) { - if (Array.isArray(data)) { - return `${data.length === 1 ? '-' : ''}(${data - .map((i) => `(+${buildString(i, buildState)})`) - .join(' - ')})` - } - if (typeof data === 'string' || typeof data === 'number') { - return `(-${buildString(data, buildState)})` - } else { - return `((a=>(a.length===1?a[0]=-a[0]:a)&0||a)([].concat(${buildString( - data, - buildState - )}))).reduce((a,b) => (+a)-(+b))` - } + if (Array.isArray(data)) return `${data.length === 1 ? '-' : ''}(${data.map(i => numberCoercion(i, buildState)).join(' - ')})` + if (typeof data === 'string' || typeof data === 'number') return `(-${buildString(data, buildState)})` + return buildState.compile`(Array.isArray(prev = ${data}) ? prev.length === 1 ? -precoerceNumber(prev[0]) : prev.reduce((a,b) => (+precoerceNumber(a))-(+precoerceNumber(b))) : -precoerceNumber(prev))` } // @ts-ignore Allow custom attribute defaultMethods['/'].compile = function (data, buildState) { - if (Array.isArray(data)) { - return `(${data - .map((i) => `(+${buildString(i, buildState)})`) - .join(' / ')})` - } else { - return `(${buildString(data, buildState)}).reduce((a,b) => (+a)/(+b))` - } + if (Array.isArray(data)) return `(${data.map(i => numberCoercion(i, buildState)).join(' / ')})` + return `(${buildString(data, buildState)}).reduce((a,b) => (+precoerceNumber(a))/(+precoerceNumber(b)))` } // @ts-ignore Allow custom attribute defaultMethods['*'].compile = function (data, buildState) { - if (Array.isArray(data)) { - return `(${data - .map((i) => `(+${buildString(i, buildState)})`) - .join(' * ')})` - } else { - return `(${buildString(data, buildState)}).reduce((a,b) => (+a)*(+b))` - } -} -// @ts-ignore Allow custom attribute -defaultMethods.cat.compile = function (data, buildState) { - if (typeof data === 'string') return JSON.stringify(data) - if (!Array.isArray(data)) return false - let res = buildState.compile`''` - for (let i = 0; i < data.length; i++) res = buildState.compile`${res} + ${data[i]}` - return buildState.compile`(${res})` + if (Array.isArray(data)) return `(${data.map(i => numberCoercion(i, buildState)).join(' * ')})` + return `(${buildString(data, buildState)}).reduce((a,b) => (+precoerceNumber(a))*(+precoerceNumber(b)))` } // @ts-ignore Allow custom attribute @@ -943,12 +976,12 @@ defaultMethods.not = defaultMethods['!'] // @ts-ignore Allow custom attribute defaultMethods['!!'].compile = function (data, buildState) { if (Array.isArray(data)) return buildState.compile`(!!engine.truthy(${data[0]}))` - return `(!!engine.truthy(${data}))` + return buildState.compile`(!!engine.truthy(${data}))` } defaultMethods.none.deterministic = defaultMethods.some.deterministic // @ts-ignore Allowing a optimizeUnary attribute that can be used for performance optimizations -defaultMethods['+'].optimizeUnary = defaultMethods['-'].optimizeUnary = defaultMethods['!'].optimizeUnary = defaultMethods['!!'].optimizeUnary = defaultMethods.cat.optimizeUnary = true +defaultMethods['+'].optimizeUnary = defaultMethods['-'].optimizeUnary = defaultMethods['!'].optimizeUnary = defaultMethods['!!'].optimizeUnary = defaultMethods.cat.optimizeUnary = defaultMethods.error.optimizeUnary = defaultMethods.panic.optimizeUnary = true export default { ...defaultMethods, diff --git a/legacy.js b/legacy.js index 98ea589..85b3cd3 100644 --- a/legacy.js +++ b/legacy.js @@ -4,8 +4,7 @@ import { splitPathMemoized } from './utilities/splitPath.js' import chainingSupported from './utilities/chainingSupported.js' import { Sync, OriginalImpl } from './constants.js' - -/** @type {Record<'get' | 'missing' | 'missing_some' | 'var', { method: (...args) => any }>} **/ +/** @type {Record<'get' | 'missing' | 'missing_some' | 'var', { method: (...args) => any }>} **/ const legacyMethods = { get: { [Sync]: true, @@ -117,19 +116,20 @@ const legacyMethods = { const pieces = splitPathMemoized(key) - if (!buildState.engine.allowFunctions) buildState.methods.preventFunctions = a => typeof a === 'function' ? null : a - else buildState.methods.preventFunctions = a => a - // support older versions of node if (!chainingSupported) { - return `(methods.preventFunctions(((a,b) => (typeof a === 'undefined' || a === null) ? b : a)(${pieces.reduce( + const res = `((((a,b) => (typeof a === 'undefined' || a === null) ? b : a)(${pieces.reduce( (text, i) => `(${text}||0)[${JSON.stringify(i)}]`, '(context||0)' )}, ${buildString(defaultValue, buildState)})))` + if (buildState.engine.allowFunctions) return res + return `(typeof (prev = ${res}) === 'function' ? null : prev)` } - return `(methods.preventFunctions(context${pieces + const res = `(context${pieces .map((i) => `?.[${JSON.stringify(i)}]`) - .join('')} ?? ${buildString(defaultValue, buildState)}))` + .join('')} ?? ${buildString(defaultValue, buildState)})` + if (buildState.engine.allowFunctions) return res + return `(typeof (prev = ${res}) === 'function' ? null : prev)` } return false } @@ -159,5 +159,4 @@ const legacyMethods = { } } - -export default {...legacyMethods} +export default { ...legacyMethods } diff --git a/logic.js b/logic.js index 3348d22..44d8379 100644 --- a/logic.js +++ b/logic.js @@ -64,8 +64,7 @@ class LogicEngine { * @param {*} above The context above (can be used for handlebars-style data traversal.) * @returns {{ result: *, func: string }} */ - _parse (logic, context, above) { - const [func] = Object.keys(logic) + _parse (logic, context, above, func) { const data = logic[func] if (this.isData(logic, func)) return logic @@ -85,9 +84,8 @@ class LogicEngine { } if (typeof this.methods[func] === 'object') { - const { method, traverse } = this.methods[func] - const shouldTraverse = typeof traverse === 'undefined' ? true : traverse - const parsedData = shouldTraverse ? ((!data || typeof data !== 'object') ? [data] : coerceArray(this.run(data, context, { above }))) : data + const { method, lazy } = this.methods[func] + const parsedData = !lazy ? ((!data || typeof data !== 'object') ? [data] : coerceArray(this.run(data, context, { above }))) : data return method(parsedData, context, above, this) } @@ -97,12 +95,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 {((args: any, context: any, above: any[], engine: LogicEngine) => any) |{ lazy?: Boolean, traverse?: Boolean, method: (args: any, context: any, above: any[], engine: LogicEngine) => any, deterministic?: Function | Boolean }} method * @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, optimizeUnary } = {}) { - if (typeof method === 'function') method = { method, traverse: true } - else method = { ...method } + if (typeof method === 'function') method = { method, lazy: false } + else method = { ...method, lazy: typeof method.traverse !== 'undefined' ? !method.traverse : method.lazy } Object.assign(method, omitUndefined({ deterministic, optimizeUnary })) this.methods[name] = declareSync(method) } @@ -156,12 +154,18 @@ class LogicEngine { // END OPTIMIZER BLOCK // if (Array.isArray(logic)) { - const res = [] - for (let i = 0; i < logic.length; i++) res.push(this.run(logic[i], data, { above })) + const res = new Array(logic.length) + for (let i = 0; i < logic.length; i++) res[i] = this.run(logic[i], data, { above }) return res } - if (logic && typeof logic === 'object' && Object.keys(logic).length > 0) return this._parse(logic, data, above) + if (logic && typeof logic === 'object') { + const keys = Object.keys(logic) + if (keys.length > 0) { + const func = keys[0] + return this._parse(logic, data, above, func) + } + } return logic } diff --git a/optimizer.js b/optimizer.js index b9dc5cf..8e2f826 100644 --- a/optimizer.js +++ b/optimizer.js @@ -14,7 +14,7 @@ function getMethod (logic, engine, methodName, above) { const method = engine.methods[methodName] const called = method.method ? method.method : method - if (method.traverse === false) { + if (method.lazy) { const args = logic[methodName] return (data, abv) => called(args, data, abv || above, engine) } @@ -30,7 +30,7 @@ function getMethod (logic, engine, methodName, above) { return called(evaluatedArgs, data, abv || above, engine) } } else { - let optimizedArgs = optimize(args, engine, above) + const optimizedArgs = optimize(args, engine, above) if (method.optimizeUnary) { if (typeof optimizedArgs === 'function') return (data, abv) => called(optimizedArgs(data, abv), data, abv || above, engine) return (data, abv) => called(optimizedArgs, data, abv || above, engine) diff --git a/suites/divide.json b/suites/divide.json index 3685034..32740dd 100644 --- a/suites/divide.json +++ b/suites/divide.json @@ -146,5 +146,29 @@ "rule": { "/": [{ "val": "x" }, { "val": "y" }] }, "data": { "x": 8, "y": 2 }, "result": 4 + }, + { + "description": "Divide by Zero", + "rule": { "/": [0, 0] }, + "result": { "error": "NaN" }, + "data": null + }, + { + "description": "Divide by NaN", + "rule": { "/": [1, { "error": "NaN" }] }, + "result": { "error": "NaN" }, + "data": null + }, + { + "description": "Divide with String produces NaN", + "rule": { "/": [1, "a"] }, + "result": { "error": "NaN" }, + "data": null + }, + { + "description": "Divide with Array produces NaN", + "rule": { "/": [1, [1]] }, + "result": { "error": "NaN" }, + "data": null } ] \ No newline at end of file diff --git a/suites/errors.json b/suites/errors.json new file mode 100644 index 0000000..dd4ecac --- /dev/null +++ b/suites/errors.json @@ -0,0 +1,33 @@ +[ + "# Some error tests", + { + "description": "NaN is Truthy", + "rule": { "!!": { "error": "NaN" } }, + "result": true, + "data": null + }, + { + "description": "Arbitrary error is Truthy", + "rule": { "!!": { "error": "Some error" } }, + "result": true, + "data": null + }, + { + "description": "Coalesce an error", + "rule": { "try": [{ "error": "Some error" }, 1] }, + "result": 1, + "data": null + }, + { + "description": "Coalesce an emitted error", + "rule": { "try": [{ "+": [{ "val": "hello" }]}, 1] }, + "result": 1, + "data": { "hello": "world" } + }, + { + "description": "Errors are just data", + "rule": { "try": [{ "val": "x" }, 1]}, + "data": { "x": { "error": "Some error" }}, + "result": 1 + } +] \ No newline at end of file diff --git a/suites/minus.json b/suites/minus.json index 480a8af..3c0cdd7 100644 --- a/suites/minus.json +++ b/suites/minus.json @@ -114,5 +114,23 @@ "rule": { "-": [{ "val": "x" }, { "val": "y" }] }, "data": { "x": 1, "y": 2 }, "result": -1 + }, + { + "description": "Subtraction with NaN", + "rule": { "-": [{ "error": "NaN" }, 1] }, + "result": { "error": "NaN" }, + "data": null + }, + { + "description": "Subtraction with string produces NaN", + "rule": { "-": ["Hey", 1] }, + "result": { "error": "NaN" }, + "data": null + }, + { + "description": "Subtraction with Array produces NaN", + "rule": { "-": [[1], 1] }, + "result": { "error": "NaN" }, + "data": null } ] \ No newline at end of file diff --git a/suites/modulo.json b/suites/modulo.json index 45e75ec..4b82954 100644 --- a/suites/modulo.json +++ b/suites/modulo.json @@ -157,5 +157,23 @@ "rule": { "%": [{ "val": "x" }, { "val": "y" }] }, "data": { "x": 11, "y": 6 }, "result": 5 + }, + { + "description": "Modulo with NaN", + "rule": { "%": [{ "error": "NaN" }, 1] }, + "result": { "error": "NaN" }, + "data": null + }, + { + "description": "Modulo with string produces NaN", + "rule": { "%": ["Hey", 1] }, + "result": { "error": "NaN" }, + "data": null + }, + { + "description": "Modulo with array produces NaN", + "rule": { "%": [[1], 1] }, + "result": { "error": "NaN" }, + "data": null } ] \ No newline at end of file diff --git a/suites/multiply.json b/suites/multiply.json index 4e8e829..b43ca5d 100644 --- a/suites/multiply.json +++ b/suites/multiply.json @@ -144,5 +144,23 @@ "rule": { "*": [{ "val": "x" }, { "val": "y" }] }, "data": { "x": 8, "y": 2 }, "result": 16 + }, + { + "description": "Multiply with NaN", + "rule": { "*": [{ "error": "NaN" }, 1] }, + "result": { "error": "NaN" }, + "data": null + }, + { + "description": "Multiply with string produces NaN", + "rule": { "*": ["Hey", 1] }, + "result": { "error": "NaN" }, + "data": null + }, + { + "description": "Multiply with Array produces NaN", + "rule": { "*": [[1], 1] }, + "result": { "error": "NaN" }, + "data": null } ] \ No newline at end of file diff --git a/suites/plus.json b/suites/plus.json index 82aa130..d019d39 100644 --- a/suites/plus.json +++ b/suites/plus.json @@ -138,7 +138,49 @@ { "description": "Addition with val", "rule": { "+": [{ "val": "x" }, { "val": "y" }] }, - "data": { "x": 1, "y": 2 }, - "result": 3 + "result": 3, + "data": { "x": 1, "y": 2 } + }, + { + "description": "Addition with NaN", + "rule": { "+": [{ "error": "NaN" }, 1] }, + "result": { "error": "NaN" }, + "data": null + }, + { + "description": "Addition with string produces NaN", + "rule": { "+": ["Hey", 1] }, + "result": { "error": "NaN" }, + "data": null + }, + { + "description": "Addition with Array produces NaN", + "rule": { "+": [[1], 1] }, + "result": { "error": "NaN" }, + "data": null + }, + { + "description": "Addition with Array from context produces NaN", + "rule": { "+": [{ "val": "x" }, 1] }, + "result": { "error": "NaN" }, + "data": { "x": [1] } + }, + { + "description": "Addition with Object produces NaN", + "rule": { "+": [{ "val": "x" }, 1] }, + "result": { "error": "NaN" }, + "data": { "x": {} } + }, + { + "description": "Plus Operator with Single Operand, Invalid String Produces NaN", + "rule": { "+": "Hello" }, + "result": { "error": "NaN" }, + "data": null + }, + { + "description": "Plus Operator with Single Operand, Array Input Produces NaN", + "rule": { "+": [[1]] }, + "result": { "error": "NaN" }, + "data": null } ] \ No newline at end of file diff --git a/utilities/coerceArray.js b/utilities/coerceArray.js index 0c80880..1325ffe 100644 --- a/utilities/coerceArray.js +++ b/utilities/coerceArray.js @@ -3,7 +3,6 @@ * Coerces a value into an array. * This is used for unary value operations. */ -export function coerceArray (value, skip = false) { - if (skip) return value +export function coerceArray (value) { return Array.isArray(value) ? value : [value] } diff --git a/utilities/downgrade.js b/utilities/downgrade.js new file mode 100644 index 0000000..9fb056e --- /dev/null +++ b/utilities/downgrade.js @@ -0,0 +1,20 @@ + +/** + * Used to make an "error" piece of data null, for the purposes of coalescing. + * @param {any} item + */ +export function downgrade (item) { + if (item && typeof item === 'object' && 'error' in item) return null + if (Number.isNaN(item)) return null + return item +} + +/** + * Used to precoerce a data value to a number, for the purposes of coalescing. + * @param {any} item + */ +export function precoerceNumber (item) { + if (!item) return item + if (typeof item === 'object') return Number.isNaN + return item +}