From 62b9e81e5b9d5f4b8c844d89bc4fa14b7651eeff Mon Sep 17 00:00:00 2001 From: Jesse Mitchell Date: Mon, 9 Dec 2024 13:55:16 -0600 Subject: [PATCH 01/17] Separate out the val proposal --- defaultMethods.js | 31 +++++++++++++++++++- suites/val.json | 72 +++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 102 insertions(+), 1 deletion(-) create mode 100644 suites/val.json diff --git a/defaultMethods.js b/defaultMethods.js index 0318587..391f302 100644 --- a/defaultMethods.js +++ b/defaultMethods.js @@ -203,6 +203,35 @@ const defaultMethods = { } } }, + // Adding this to spec something out, not to merge it quite yet + val: { + method: (args, context, above) => { + let result = context + let start = 0 + if (Array.isArray(args[0]) && args[0].length === 1) { + start++ + const climb = +Math.abs(args[0][0]) + let pos = 0 + for (let i = 0; i < climb; i++) { + result = above[pos++] + if (i === above.length - 1 && Array.isArray(result)) { + above = result + result = result[0] + pos = 1 + } + } + } + + for (let i = start; i < args.length; i++) { + if (args[i] === null) continue + if (result === null || result === undefined) return null + result = result[args[i]] + } + if (typeof result === 'undefined') return null + return result + }, + deterministic: false + }, var: (key, context, above, engine) => { let b if (Array.isArray(key)) { @@ -539,7 +568,7 @@ function createArrayIterativeMethod (name, useTruthy = false) { } const method = build(mapper, mapState) - const aboveArray = method.aboveDetected ? buildState.compile`[{ item: null }, context, above]` : buildState.compile`null` + const aboveArray = method.aboveDetected ? buildState.compile`[{ item: null, index: x }, context, above]` : buildState.compile`null` if (async) { if (!isSyncDeep(mapper, buildState.engine, buildState)) { diff --git a/suites/val.json b/suites/val.json new file mode 100644 index 0000000..73de36e --- /dev/null +++ b/suites/val.json @@ -0,0 +1,72 @@ +[ + "Test Specification for val", + { + "description": "Fetches a value from an empty key", + "rule": { "val": "" }, + "data": { "" : 1 }, + "result": 1 + }, + { + "description": "Fetches a value from a nested empty key", + "rule": { "val": ["", ""] }, + "data": { "" : { "": 2 } }, + "result": 2 + }, + { + "description": "Fetches a value from a doubly nested empty key", + "rule": { "val": ["", "", ""] }, + "data": { "" : { "": { "": 3 } } }, + "result": 3 + }, + { + "description": "Fetches a value from a key that is purely a dot", + "rule": { "val": "." }, + "data": { "." : 20 }, + "result": 20 + }, + { + "description": "Fetches the entire context", + "rule": { "val": null }, + "data": { "": 21 }, + "result": { "": 21 } + }, + { + "description": "Fetches the entire context for a nested key", + "rule": { "val": "" }, + "data": { "": { "": 22 } }, + "result": { "": 22 } + }, + { + "description": "Using val in a map (remember that null gets the current context, not empty string)", + "rule": { "map": [[1,2,3], { "+": [{ "val": null }, 1] }] }, + "data": null, + "result": [2,3,4] + }, + { + "description": "Using val in a map (and remember [null] and null are the same)", + "rule": { "map": [[1,2,3], { "+": [{ "val": [null] }, 1] }] }, + "data": null, + "result": [2,3,4] + }, + "Testing out scopes", + { + "description": "Climb up to get adder", + "rule": { "map": [[1,2,3], { "+": [{ "val": null }, { "val": [[-2], "adder"] }] }] }, + "data": { "adder": 10 }, + "result": [11,12,13] + }, + { + "description": "Climb up to get index", + "rule": { "map": [[1,2,3], { "+": [{ "val": null }, { "val": [[-1], "index"] }] }] }, + "data": { "adder": 10 }, + "result": [1,3,5] + }, + { + "description": "Nested get adder", + "rule": { + "map": [["Test"], { "map": [[1,2,3], { "+": [{"val": null}, {"val": [[-4], "adder"]}] }]} ] + }, + "data": { "adder": 10 }, + "result": [[11,12,13]] + } +] \ No newline at end of file From 2960e6914c5d31c3b507ae3fb90990e31935c4d1 Mon Sep 17 00:00:00 2001 From: Jesse Mitchell Date: Mon, 9 Dec 2024 14:28:03 -0600 Subject: [PATCH 02/17] Make the val implementation more robust, so that it can run as optimally as var. --- defaultMethods.js | 41 +++++++++++++++++++++++++++++++++++++++-- 1 file changed, 39 insertions(+), 2 deletions(-) diff --git a/defaultMethods.js b/defaultMethods.js index 391f302..35608b1 100644 --- a/defaultMethods.js +++ b/defaultMethods.js @@ -206,6 +206,13 @@ const defaultMethods = { // Adding this to spec something out, not to merge it quite yet val: { method: (args, context, above) => { + if (Array.isArray(args) && args.length === 1) args = args[0] + if (!Array.isArray(args)) { + if (args === null || args === undefined) return context + const result = context[args] + if (typeof result === 'undefined') return null + return result + } let result = context let start = 0 if (Array.isArray(args[0]) && args[0].length === 1) { @@ -221,7 +228,6 @@ const defaultMethods = { } } } - for (let i = start; i < args.length; i++) { if (args[i] === null) continue if (result === null || result === undefined) return null @@ -230,7 +236,38 @@ const defaultMethods = { if (typeof result === 'undefined') return null return result }, - deterministic: false + optimizeUnary: true, + deterministic: (data, buildState) => { + if (buildState.insideIterator) { + if (Array.isArray(data) && Array.isArray(data[0]) && Math.abs(data[0][0]) >= 2) return false + return true + } + return false + }, + compile: (data, buildState) => { + function wrapNull (data) { + if (!chainingSupported) return buildState.compile`(((a) => a === null || a === undefined ? null : a)(${data}))` + return buildState.compile`(${data} ?? null)` + } + if (Array.isArray(data) && Array.isArray(data[0])) { + // A very, very specific optimization. + if (buildState.iteratorCompile && Math.abs(data[0][0] || 0) === 1 && data[1] === 'index') return buildState.compile`index` + return false + } + if (Array.isArray(data) && data.length === 1) data = data[0] + if (data === null) return wrapNull(buildState.compile`context`) + if (!Array.isArray(data)) return wrapNull(buildState.compile`context[${data}]`) + if (Array.isArray(data)) { + let res = buildState.compile`context` + for (let i = 0; i < data.length; i++) { + if (data[i] === null) continue + if (chainingSupported) res = buildState.compile`${res}?.[${data[i]}]` + else res = buildState.compile`(${res}|| 0)[${data[i]}]` + } + return wrapNull(buildState.compile`(${res})`) + } + return false + } }, var: (key, context, above, engine) => { let b From 0c51cbe5cc561b9e30d631a98ea3e20496ea01c0 Mon Sep 17 00:00:00 2001 From: Jesse Mitchell Date: Thu, 12 Dec 2024 20:17:26 -0600 Subject: [PATCH 03/17] Address short circuiting in and / or interpreted --- compiler.js | 2 +- defaultMethods.js | 102 +++++++++++++++++++++++++++++++++------------- general.test.js | 50 +++++++++++++++++++++++ package.json | 2 +- 4 files changed, 125 insertions(+), 31 deletions(-) diff --git a/compiler.js b/compiler.js index 2046e75..eb573f6 100644 --- a/compiler.js +++ b/compiler.js @@ -314,7 +314,7 @@ function processBuiltString (method, str, buildState) { return Object.assign( (typeof globalThis !== 'undefined' ? globalThis : global).eval(final)(values, methods, notTraversed, asyncIterators, engine, above, coerceArray), { [Sync]: !buildState.asyncDetected, - aboveDetected: str.includes(', above') + aboveDetected: typeof str === 'string' && str.includes(', above') }) } diff --git a/defaultMethods.js b/defaultMethods.js index 35608b1..b83f207 100644 --- a/defaultMethods.js +++ b/defaultMethods.js @@ -159,17 +159,81 @@ const defaultMethods = { '!=': ([a, b]) => a != b, '!==': ([a, b]) => a !== b, xor: ([a, b]) => a ^ b, - or: (arr, _1, _2, engine) => { - for (let i = 0; i < arr.length; i++) { - if (engine.truthy(arr[i])) return arr[i] - } - return arr[arr.length - 1] + // 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. + or: { + 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 (engine.truthy(item)) return item + } + + 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 (engine.truthy(item)) return item + } + + return item + }, + deterministic: (data, buildState) => isDeterministic(data, buildState.engine, buildState), + compile: (data, buildState) => { + if (!buildState.engine.truthy.IDENTITY) return false + if (Array.isArray(data)) { + return `(${data.map((i) => buildString(i, buildState)).join(' || ')})` + } else { + return `(${buildString(data, buildState)}).reduce((a,b) => a||b, false)` + } + }, + traverse: false }, - and: (arr, _1, _2, engine) => { - for (let i = 0; i < arr.length; i++) { - if (!engine.truthy(arr[i])) return arr[i] + and: { + 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 (!engine.truthy(item)) return item + } + 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 (!engine.truthy(item)) return item + } + return item + }, + traverse: false, + deterministic: (data, buildState) => isDeterministic(data, buildState.engine, buildState), + compile: (data, buildState) => { + if (!buildState.engine.truthy.IDENTITY) return false + if (Array.isArray(data)) { + return `(${data.map((i) => buildString(i, buildState)).join(' && ')})` + } else { + return `(${buildString(data, buildState)}).reduce((a,b) => a&&b, true)` + } } - return arr[arr.length - 1] }, substr: ([string, from, end]) => { if (end < 0) { @@ -743,32 +807,12 @@ defaultMethods['%'].compile = function (data, buildState) { } } -// @ts-ignore Allow custom attribute -defaultMethods.or.compile = function (data, buildState) { - if (!buildState.engine.truthy.IDENTITY) return false - if (Array.isArray(data)) { - return `(${data.map((i) => buildString(i, buildState)).join(' || ')})` - } else { - return `(${buildString(data, buildState)}).reduce((a,b) => a||b, false)` - } -} - // @ts-ignore Allow custom attribute defaultMethods.in.compile = function (data, buildState) { if (!Array.isArray(data)) return false return buildState.compile`(${data[1]} || []).includes(${data[0]})` } -// @ts-ignore Allow custom attribute -defaultMethods.and.compile = function (data, buildState) { - if (!buildState.engine.truthy.IDENTITY) return false - if (Array.isArray(data)) { - return `(${data.map((i) => buildString(i, buildState)).join(' && ')})` - } else { - return `(${buildString(data, buildState)}).reduce((a,b) => a&&b, true)` - } -} - // @ts-ignore Allow custom attribute defaultMethods['-'].compile = function (data, buildState) { if (Array.isArray(data)) { diff --git a/general.test.js b/general.test.js index 7ecbb93..3fd6015 100644 --- a/general.test.js +++ b/general.test.js @@ -198,6 +198,56 @@ describe('Various Test Cases', () => { for (const engine of [...normalEngines, ...permissiveEngines]) await testEngine(engine, { map: [[1, 2, 3], { map: [[1], { var: '../../../../name' }] }] }, { name: 'Bob' }, [['Bob'], ['Bob'], ['Bob']]) }) + it('correctly short circuits (or)', async () => { + for (const engine of [...normalEngines, ...permissiveEngines]) { + let count = 0 + + engine.addMethod('increment', { + method: () => { + count++ + return true + } + }) + await testEngine(engine, { or: [{ increment: true }, { increment: true }, { increment: true }] }, {}, true) + // It's called twice because it runs once for interpreted, and once for compiled. + assert.strictEqual(count, 2, 'Should have only called increment twice. (Count)') + } + }) + + it('correctly short circuits (and)', async () => { + for (const engine of [...normalEngines, ...permissiveEngines]) { + let count = 0 + + engine.addMethod('increment', { + method: () => { + count++ + return false + } + }) + await testEngine(engine, { and: [{ increment: true }, { increment: true }, { increment: true }] }, {}, false) + // It's called twice because it runs once for interpreted, and once for compiled. + assert.strictEqual(count, 2, 'Should have only called increment twice. (Count)') + } + }) + + it('does not execute any logic from and / or array if the argument is executed', async () => { + for (const engine of [...normalEngines, ...permissiveEngines]) { + let count = 0 + engine.addMethod('increment', { + method: () => { + count++ + return true + } + }) + + await testEngine(engine, { and: { preserve: [{ increment: true }, { increment: true }] } }, {}, { increment: true }) + assert.strictEqual(count, 0, 'Should not have called increment on and.') + + await testEngine(engine, { or: { preserve: [{ increment: true }, { increment: true }] } }, {}, { increment: true }) + assert.strictEqual(count, 0, 'Should not have called increment on or.') + } + }) + it('disables interpreted optimization when it realizes it will not be faster', async () => { for (const engine of [...normalEngines, ...permissiveEngines]) { const disableInterpretedOptimization = engine.disableInterpretedOptimization diff --git a/package.json b/package.json index 347f2f6..efea010 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "json-logic-engine", - "version": "4.0.1", + "version": "4.0.2", "description": "Construct complex rules with JSON & process them.", "main": "./dist/cjs/index.js", "module": "./dist/esm/index.js", From a28316d57f19112a4ec995a226610b74ed7219d6 Mon Sep 17 00:00:00 2001 From: Jesse Mitchell Date: Fri, 20 Dec 2024 10:22:52 -0600 Subject: [PATCH 04/17] Add array thing --- suites/val.json | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/suites/val.json b/suites/val.json index 73de36e..c6e443d 100644 --- a/suites/val.json +++ b/suites/val.json @@ -30,6 +30,12 @@ "data": { "": 21 }, "result": { "": 21 } }, + { + "description": "Fetches the entire context (array)", + "rule": { "val": [] }, + "data": { "": 21 }, + "result": { "": 21 } + }, { "description": "Fetches the entire context for a nested key", "rule": { "val": "" }, @@ -48,6 +54,12 @@ "data": null, "result": [2,3,4] }, + { + "description": "Using val in a map (using empty list)", + "rule": { "map": [[1,2,3], { "+": [{ "val": [] }, 1] }] }, + "data": null, + "result": [2,3,4] + }, "Testing out scopes", { "description": "Climb up to get adder", From 95adac670eea67fd3ddb57d544ed29403b1ab7e0 Mon Sep 17 00:00:00 2001 From: Jesse Mitchell Date: Thu, 26 Dec 2024 14:07:31 -0600 Subject: [PATCH 05/17] Improve val testing --- defaultMethods.js | 23 +- suites/additional.json | 14 +- suites/chained.json | 10 +- suites/scopes.json | 16 +- suites/val-compat.json | 667 +++++++++++++++++++++++++++++++++++++++++ suites/val.json | 44 +-- suites/vars.json | 81 ----- 7 files changed, 724 insertions(+), 131 deletions(-) create mode 100644 suites/val-compat.json delete mode 100644 suites/vars.json diff --git a/defaultMethods.js b/defaultMethods.js index ca61743..ec1f7f9 100644 --- a/defaultMethods.js +++ b/defaultMethods.js @@ -269,16 +269,17 @@ const defaultMethods = { }, // Adding this to spec something out, not to merge it quite yet val: { - method: (args, context, above) => { + method: (args, context, above, engine) => { if (Array.isArray(args) && args.length === 1) args = args[0] + // A unary optimization if (!Array.isArray(args)) { - if (args === null || args === undefined) return context const result = context[args] if (typeof result === 'undefined') return null return result } let result = context let start = 0 + // This block handles scope traversal if (Array.isArray(args[0]) && args[0].length === 1) { start++ const climb = +Math.abs(args[0][0]) @@ -292,12 +293,13 @@ const defaultMethods = { } } } + // This block handles traversing the path for (let i = start; i < args.length; i++) { - if (args[i] === null) continue if (result === null || result === undefined) return null result = result[args[i]] } if (typeof result === 'undefined') return null + if (typeof result === 'function' && !engine.allowFunctions) return null return result }, optimizeUnary: true, @@ -310,9 +312,14 @@ const defaultMethods = { }, compile: (data, buildState) => { function wrapNull (data) { - if (!chainingSupported) return buildState.compile`(((a) => a === null || a === undefined ? null : a)(${data}))` - return buildState.compile`(${data} ?? null)` + if (!chainingSupported) return buildState.compile`(methods.preventFunctions(((a) => a === null || a === undefined ? null : a)(${data})))` + return buildState.compile`(methods.preventFunctions(${data} ?? null))` } + + 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)) return false if (Array.isArray(data) && Array.isArray(data[0])) { // A very, very specific optimization. if (buildState.iteratorCompile && Math.abs(data[0][0] || 0) === 1 && data[1] === 'index') return buildState.compile`index` @@ -445,7 +452,7 @@ const defaultMethods = { buildState.methods.push(mapper) if (async) { if (!isSync(mapper) || selector.includes('await')) { - buildState.detectAsync = true + buildState.asyncDetected = true if (typeof defaultValue !== 'undefined') { return `await asyncIterators.reduce(${selector} || [], (a,b) => methods[${ buildState.methods.length - 1 @@ -672,8 +679,8 @@ function createArrayIterativeMethod (name, useTruthy = false) { const aboveArray = method.aboveDetected ? buildState.compile`[{ iterator: z, index: x }, context, above]` : buildState.compile`null` if (async) { - if (!isSyncDeep(mapper, buildState.engine, buildState)) { - buildState.detectAsync = true + if (!isSync(method)) { + buildState.asyncDetected = true return buildState.compile`await asyncIterators[${name}](${selector} || [], async (i, x, z) => ${method}(i, x, ${aboveArray}))` } } diff --git a/suites/additional.json b/suites/additional.json index 2360b52..17f0151 100644 --- a/suites/additional.json +++ b/suites/additional.json @@ -4,7 +4,7 @@ [ 1, { - "var": "x" + "val": "x" }, 3 ], @@ -21,11 +21,11 @@ { "if": [ { - "var": "x" + "val": "x" }, [ { - "var": "y" + "val": "y" } ], 99 @@ -43,20 +43,20 @@ { "reduce": [ { - "var": "integers" + "val": "integers" }, { "+": [ { - "var": "current" + "val": "current" }, { - "var": "accumulator" + "val": "accumulator" } ] }, { - "var": "start_with" + "val": "start_with" } ] }, diff --git a/suites/chained.json b/suites/chained.json index 0cd6dae..b0c95dd 100644 --- a/suites/chained.json +++ b/suites/chained.json @@ -14,7 +14,7 @@ }, { "description": "Max with Logic Chaining", - "rule": { "max": { "var": "data" } }, + "rule": { "max": { "val": "data" } }, "data": { "data": [1, 2, 3] }, "result": 3 }, @@ -26,7 +26,7 @@ }, { "description": "Cat with Logic Chaining (Simple)", - "rule": { "cat": { "var": "text" } }, + "rule": { "cat": { "val": "text" } }, "data": { "text": ["Hello ", "World", "!"] }, "result": "Hello World!" }, @@ -35,10 +35,10 @@ "max": { "map": [{ "filter": [ - { "var": "people" }, - { "===": [{ "var": "department" }, "Engineering"] } + { "val": "people" }, + { "===": [{ "val": "department" }, "Engineering"] } ]}, - { "var": "salary" } + { "val": "salary" } ] } }, diff --git a/suites/scopes.json b/suites/scopes.json index 1a56d89..06e713c 100644 --- a/suites/scopes.json +++ b/suites/scopes.json @@ -4,8 +4,8 @@ "description": "Map can add each number to index", "rule": { "map": [ - { "var": "numbers" }, - { "+": [{ "var": "../index" }, { "var": "" }]} + { "val": "numbers" }, + { "+": [{ "val": [[1], "index"] }, { "val": [] }]} ] }, "data": { "numbers": [1,2,3] }, @@ -15,8 +15,8 @@ "description": "Map can add each number to value from context", "rule": { "map": [ - { "var": "numbers" }, - { "+": [{ "var": "../../value" }, { "var": "" }]} + { "val": "numbers" }, + { "+": [{ "val": [[2], "value"] }, { "val": [] }]} ] }, "data": { "numbers": [1,2,3], "value": 10 }, @@ -26,8 +26,8 @@ "description": "Filter can use parent context to filter", "rule": { "filter": [ - { "var": "people" }, - { "===": [{ "var": "department" }, { "var": "../../department" }] } + { "val": "people" }, + { "===": [{ "val": "department" }, { "val": [[2], "department"] }] } ] }, "data": { @@ -57,8 +57,8 @@ "description": "Access an escaped key from above", "rule": { "map": [ - { "var": "arr" }, - { "+": [{ "var": "../../\\.\\./" }, { "var": "../../..\\/" }]} + { "val": "arr" }, + { "+": [{ "val": [[2], "", "", "/"] }, { "val": [[2], "../"] }]} ] }, "data": { "arr": [1,2,3], "../": 10, "": { "": { "/": 7 }} }, diff --git a/suites/val-compat.json b/suites/val-compat.json new file mode 100644 index 0000000..b3be786 --- /dev/null +++ b/suites/val-compat.json @@ -0,0 +1,667 @@ +[ + "If the consequents are logic, they get evaluated", + { + "description": "[1,{\"var\":\"x\"},3]", + "rule": [ 1, {"val": "x"}, 3 ], + "data": {"x": 2}, + "result": [1, 2, 3] + }, + { + "description": "{\"if\":[{\"var\":\"x\"},[{\"var\":\"y\"}],99]}", + "rule": { + "if": [ + {"val": "x"}, + [ {"val": "y"} ], + 99 + ] + }, + "data": {"x": true, "y": 42}, + "result": [42] + }, + { + "description": "{\"var\":[\"a\"]}", + "rule": { "val": ["a"] }, + "data": {"a": 1}, + "result": 1 + }, + { + "description": "{\"var\":[\"b\"]}", + "rule": { "val": ["b"] }, + "data": {"a": 1}, + "result": null + }, + { + "description": "{\"var\":[\"a\"]}", + "rule": { "val": ["a"] }, + "data": null, + "result": null + }, + { "description": "{\"var\":\"a\"}", "rule": {"val": "a"}, "data": {"a": 1}, "result": 1 }, + { "description": "{\"var\":\"b\"}", "rule": {"val": "b"}, "data": {"a": 1}, "result": null }, + { "description": "{\"var\":\"a\"}", "rule": {"val": "a"}, "data": null, "result": null }, + { + "description": "{\"var\":\"a.b\"}", + "rule": { "val": ["a", "b"] }, + "data": { "a": {"b": "c"} }, + "result": "c" + }, + { + "description": "{\"var\":\"a.q\"}", + "rule": { "val": ["a", "q"] }, + "data": { "a": {"b": "c"} }, + "result": null + }, + { + "description": "{\"var\":1}", + "rule": {"val": 1}, + "data": ["apple", "banana"], + "result": "banana" + }, + { + "description": "{\"var\":\"1\"}", + "rule": {"val": "1"}, + "data": ["apple", "banana"], + "result": "banana" + }, + { + "description": "{\"var\":\"1.1\"}", + "rule": { "val": [1, 1] }, + "data": [ "apple", ["banana", "beer"] ], + "result": "beer" + }, + { + "description": "{\"and\":[{\"<\":[{\"var\":\"temp\"},110]},{\"==\":[{\"var\":\"pie.filling\"},\"apple\"]}]}", + "rule": { + "and": [ + { + "<": [ {"val": "temp"}, 110 ] + }, + { + "==": [ + { "val": ["pie", "filling"] }, + "apple" + ] + } + ] + }, + "data": { "temp": 100, "pie": {"filling": "apple"} }, + "result": true + }, + { + "description": "{\"var\":[{\"?:\":[{\"<\":[{\"var\":\"temp\"},110]},\"pie.filling\",\"pie.eta\"]}]}", + "rule": { + "val": { + "?:": [ + { "<": [ {"val": "temp"}, 110 ] }, + ["pie", "filling"], + ["pie", "eta"] + ] + } + }, + "data": { "temp": 100, "pie": {"filling": "apple", "eta": "60s"} }, + "result": "apple" + }, + { + "description": "{\"in\":[{\"var\":\"filling\"},[\"apple\",\"cherry\"]]}", + "rule": { + "in": [ {"val": "filling"}, ["apple", "cherry"] ] + }, + "data": {"filling": "apple"}, + "result": true + }, + { + "description": "{\"var\":\"a.b.c\"}", + "rule": { "val": ["a", "b", "c"] }, + "data": null, + "result": null + }, + { + "description": "{\"var\":\"a.b.c\"}", + "rule": { "val": ["a", "b", "c"] }, + "data": {"a": null}, + "result": null + }, + { + "description": "{\"var\":\"a.b.c\"}", + "rule": { "val": ["a", "b", "c"] }, + "data": { "a": {"b": null} }, + "result": null + }, + { "description": "{\"var\":\"\"}", "rule": {"val": []}, "data": 1, "result": 1 }, + { "description": "{\"val\":[]}", "rule": {"val": []}, "data": 1, "result": 1 }, + { + "description": "{\"missing\":{\"merge\":[\"vin\",{\"if\":[{\"var\":\"financing\"},[\"apr\"],[]]}]}}", + "rule": { + "missing": { + "merge": [ + "vin", + { + "if": [ {"val": "financing"}, ["apr"], [] ] + } + ] + } + }, + "data": {"financing": true}, + "result": ["vin", "apr"] + }, + { + "description": "{\"missing\":{\"merge\":[\"vin\",{\"if\":[{\"var\":\"financing\"},[\"apr\"],[]]}]}}", + "rule": { + "missing": { + "merge": [ + "vin", + { + "if": [ {"val": "financing"}, ["apr"], [] ] + } + ] + } + }, + "data": {"financing": false}, + "result": ["vin"] + }, + { + "description": "{\"filter\":[{\"var\":\"integers\"},true]}", + "rule": { + "filter": [ {"val": "integers"}, true ] + }, + "data": { "integers": [1, 2, 3] }, + "result": [1, 2, 3] + }, + { + "description": "{\"filter\":[{\"var\":\"integers\"},false]}", + "rule": { + "filter": [ {"val": "integers"}, false ] + }, + "data": { "integers": [1, 2, 3] }, + "result": [] + }, + { + "description": "{\"filter\":[{\"var\":\"integers\"},{\">=\":[{\"var\":\"\"},2]}]}", + "rule": { + "filter": [ + {"val": "integers"}, + { + ">=": [ {"val": []}, 2 ] + } + ] + }, + "data": { "integers": [1, 2, 3] }, + "result": [2, 3] + }, + { + "description": "{\"filter\":[{\"var\":\"integers\"},{\"%\":[{\"var\":\"\"},2]}]}", + "rule": { + "filter": [ + {"val": "integers"}, + { + "%": [ {"val": []}, 2 ] + } + ] + }, + "data": { "integers": [1, 2, 3] }, + "result": [1, 3] + }, + { + "description": "{\"map\":[{\"var\":\"integers\"},{\"*\":[{\"var\":\"\"},2]}]}", + "rule": { + "map": [ + {"val": "integers"}, + { + "*": [ {"val": []}, 2 ] + } + ] + }, + "data": { "integers": [1, 2, 3] }, + "result": [2, 4, 6] + }, + { + "description": "{\"map\":[{\"var\":\"integers\"},{\"*\":[{\"var\":\"\"},2]}]}", + "rule": { + "map": [ + {"val": "integers"}, + { + "*": [ {"val": []}, 2 ] + } + ] + }, + "data": null, + "result": [] + }, + { + "description": "{\"map\":[{\"var\":\"desserts\"},{\"var\":\"qty\"}]}", + "rule": { + "map": [ {"val": "desserts"}, {"val": "qty"} ] + }, + "data": { + "desserts": [ + {"name": "apple" , "qty": 1}, + {"name": "brownie", "qty": 2}, + {"name": "cupcake", "qty": 3} + ] + }, + "result": [1, 2, 3] + }, + { + "description": "{\"reduce\":[{\"var\":\"integers\"},{\"+\":[{\"var\":\"current\"},{\"var\":\"accumulator\"}]},0]}", + "rule": { + "reduce": [ + {"val": "integers"}, + { + "+": [ {"val": "current"}, {"val": "accumulator"} ] + }, + 0 + ] + }, + "data": { "integers": [1, 2, 3, 4] }, + "result": 10 + }, + { + "description": "{\"reduce\":[{\"var\":\"integers\"},{\"+\":[{\"var\":\"current\"},{\"var\":\"accumulator\"}]},{\"var\":\"start_with\"}]}", + "rule": { + "reduce": [ + {"val": "integers"}, + { + "+": [ {"val": "current"}, {"val": "accumulator"} ] + }, + {"val": "start_with"} + ] + }, + "data": { "integers": [1, 2, 3, 4], "start_with": 59 }, + "result": 69 + }, + { + "description": "{\"reduce\":[{\"var\":\"integers\"},{\"+\":[{\"var\":\"current\"},{\"var\":\"accumulator\"}]},0]}", + "rule": { + "reduce": [ + {"val": "integers"}, + { + "+": [ {"val": "current"}, {"val": "accumulator"} ] + }, + 0 + ] + }, + "data": null, + "result": 0 + }, + { + "description": "{\"reduce\":[{\"var\":\"integers\"},{\"*\":[{\"var\":\"current\"},{\"var\":\"accumulator\"}]},1]}", + "rule": { + "reduce": [ + {"val": "integers"}, + { + "*": [ {"val": "current"}, {"val": "accumulator"} ] + }, + 1 + ] + }, + "data": { "integers": [1, 2, 3, 4] }, + "result": 24 + }, + { + "description": "{\"reduce\":[{\"var\":\"integers\"},{\"*\":[{\"var\":\"current\"},{\"var\":\"accumulator\"}]},0]}", + "rule": { + "reduce": [ + {"val": "integers"}, + { + "*": [ {"val": "current"}, {"val": "accumulator"} ] + }, + 0 + ] + }, + "data": { "integers": [1, 2, 3, 4] }, + "result": 0 + }, + { + "description": "{\"reduce\":[{\"var\":\"desserts\"},{\"+\":[{\"var\":\"accumulator\"},{\"var\":\"current.qty\"}]},0]}", + "rule": { + "reduce": [ + {"val": "desserts"}, + { + "+": [ + { "val": "accumulator" }, + { "val": ["current", "qty"] } + ] + }, + 0 + ] + }, + "data": { + "desserts": [ + {"name": "apple" , "qty": 1}, + {"name": "brownie", "qty": 2}, + {"name": "cupcake", "qty": 3} + ] + }, + "result": 6 + }, + { + "description": "{\"all\":[{\"var\":\"integers\"},{\">=\":[{\"var\":\"\"},1]}]}", + "rule": { + "all": [ + {"val": "integers"}, + { + ">=": [ {"val": []}, 1 ] + } + ] + }, + "data": { "integers": [1, 2, 3] }, + "result": true + }, + { + "description": "{\"all\":[{\"var\":\"integers\"},{\"==\":[{\"var\":\"\"},1]}]}", + "rule": { + "all": [ + {"val": "integers"}, + { + "==": [ {"val": []}, 1 ] + } + ] + }, + "data": { "integers": [1, 2, 3] }, + "result": false + }, + { + "description": "{\"all\":[{\"var\":\"integers\"},{\"<\":[{\"var\":\"\"},1]}]}", + "rule": { + "all": [ + {"val": "integers"}, + { + "<": [ {"val": []}, 1 ] + } + ] + }, + "data": { "integers": [1, 2, 3] }, + "result": false + }, + { + "description": "{\"all\":[{\"var\":\"integers\"},{\"<\":[{\"var\":\"\"},1]}]}", + "rule": { + "all": [ + {"val": "integers"}, + { + "<": [ {"val": []}, 1 ] + } + ] + }, + "data": {"integers": []}, + "result": false + }, + { + "description": "{\"all\":[{\"var\":\"items\"},{\">=\":[{\"var\":\"qty\"},1]}]}", + "rule": { + "all": [ + {"val": "items"}, + { + ">=": [ {"val": "qty"}, 1 ] + } + ] + }, + "data": { + "items": [ {"qty": 1, "sku": "apple"}, {"qty": 2, "sku": "banana"} ] + }, + "result": true + }, + { + "description": "{\"all\":[{\"var\":\"items\"},{\">\":[{\"var\":\"qty\"},1]}]}", + "rule": { + "all": [ + {"val": "items"}, + { + ">": [ {"val": "qty"}, 1 ] + } + ] + }, + "data": { + "items": [ {"qty": 1, "sku": "apple"}, {"qty": 2, "sku": "banana"} ] + }, + "result": false + }, + { + "description": "{\"all\":[{\"var\":\"items\"},{\"<\":[{\"var\":\"qty\"},1]}]}", + "rule": { + "all": [ + {"val": "items"}, + { + "<": [ {"val": "qty"}, 1 ] + } + ] + }, + "data": { + "items": [ {"qty": 1, "sku": "apple"}, {"qty": 2, "sku": "banana"} ] + }, + "result": false + }, + { + "description": "{\"all\":[{\"var\":\"items\"},{\">=\":[{\"var\":\"qty\"},1]}]}", + "rule": { + "all": [ + {"val": "items"}, + { + ">=": [ {"val": "qty"}, 1 ] + } + ] + }, + "data": {"items": []}, + "result": false + }, + { + "description": "{\"none\":[{\"var\":\"integers\"},{\">=\":[{\"var\":\"\"},1]}]}", + "rule": { + "none": [ + {"val": "integers"}, + { + ">=": [ {"val": []}, 1 ] + } + ] + }, + "data": { "integers": [1, 2, 3] }, + "result": false + }, + { + "description": "{\"none\":[{\"var\":\"integers\"},{\"==\":[{\"var\":\"\"},1]}]}", + "rule": { + "none": [ + {"val": "integers"}, + { + "==": [ {"val": []}, 1 ] + } + ] + }, + "data": { "integers": [1, 2, 3] }, + "result": false + }, + { + "description": "{\"none\":[{\"var\":\"integers\"},{\"<\":[{\"var\":\"\"},1]}]}", + "rule": { + "none": [ + {"val": "integers"}, + { + "<": [ {"val": []}, 1 ] + } + ] + }, + "data": { "integers": [1, 2, 3] }, + "result": true + }, + { + "description": "{\"none\":[{\"var\":\"integers\"},{\"<\":[{\"var\":\"\"},1]}]}", + "rule": { + "none": [ + {"val": "integers"}, + { + "<": [ {"val": []}, 1 ] + } + ] + }, + "data": {"integers": []}, + "result": true + }, + { + "description": "{\"none\":[{\"var\":\"items\"},{\">=\":[{\"var\":\"qty\"},1]}]}", + "rule": { + "none": [ + {"val": "items"}, + { + ">=": [ {"val": "qty"}, 1 ] + } + ] + }, + "data": { + "items": [ {"qty": 1, "sku": "apple"}, {"qty": 2, "sku": "banana"} ] + }, + "result": false + }, + { + "description": "{\"none\":[{\"var\":\"items\"},{\">\":[{\"var\":\"qty\"},1]}]}", + "rule": { + "none": [ + {"val": "items"}, + { + ">": [ {"val": "qty"}, 1 ] + } + ] + }, + "data": { + "items": [ {"qty": 1, "sku": "apple"}, {"qty": 2, "sku": "banana"} ] + }, + "result": false + }, + { + "description": "{\"none\":[{\"var\":\"items\"},{\"<\":[{\"var\":\"qty\"},1]}]}", + "rule": { + "none": [ + {"val": "items"}, + { + "<": [ {"val": "qty"}, 1 ] + } + ] + }, + "data": { + "items": [ {"qty": 1, "sku": "apple"}, {"qty": 2, "sku": "banana"} ] + }, + "result": true + }, + { + "description": "{\"none\":[{\"var\":\"items\"},{\">=\":[{\"var\":\"qty\"},1]}]}", + "rule": { + "none": [ + {"val": "items"}, + { + ">=": [ {"val": "qty"}, 1 ] + } + ] + }, + "data": {"items": []}, + "result": true + }, + { + "description": "{\"some\":[{\"var\":\"integers\"},{\">=\":[{\"var\":\"\"},1]}]}", + "rule": { + "some": [ + {"val": "integers"}, + { + ">=": [ {"val": []}, 1 ] + } + ] + }, + "data": { "integers": [1, 2, 3] }, + "result": true + }, + { + "description": "{\"some\":[{\"var\":\"integers\"},{\"==\":[{\"var\":\"\"},1]}]}", + "rule": { + "some": [ + {"val": "integers"}, + { + "==": [ {"val": []}, 1 ] + } + ] + }, + "data": { "integers": [1, 2, 3] }, + "result": true + }, + { + "description": "{\"some\":[{\"var\":\"integers\"},{\"<\":[{\"var\":\"\"},1]}]}", + "rule": { + "some": [ + {"val": "integers"}, + { + "<": [ {"val": []}, 1 ] + } + ] + }, + "data": { "integers": [1, 2, 3] }, + "result": false + }, + { + "description": "{\"some\":[{\"var\":\"integers\"},{\"<\":[{\"var\":\"\"},1]}]}", + "rule": { + "some": [ + {"val": "integers"}, + { + "<": [ {"val": []}, 1 ] + } + ] + }, + "data": {"integers": []}, + "result": false + }, + { + "description": "{\"some\":[{\"var\":\"items\"},{\">=\":[{\"var\":\"qty\"},1]}]}", + "rule": { + "some": [ + {"val": "items"}, + { + ">=": [ {"val": "qty"}, 1 ] + } + ] + }, + "data": { + "items": [ {"qty": 1, "sku": "apple"}, {"qty": 2, "sku": "banana"} ] + }, + "result": true + }, + { + "description": "{\"some\":[{\"var\":\"items\"},{\">\":[{\"var\":\"qty\"},1]}]}", + "rule": { + "some": [ + {"val": "items"}, + { + ">": [ {"val": "qty"}, 1 ] + } + ] + }, + "data": { + "items": [ {"qty": 1, "sku": "apple"}, {"qty": 2, "sku": "banana"} ] + }, + "result": true + }, + { + "description": "{\"some\":[{\"var\":\"items\"},{\"<\":[{\"var\":\"qty\"},1]}]}", + "rule": { + "some": [ + {"val": "items"}, + { + "<": [ {"val": "qty"}, 1 ] + } + ] + }, + "data": { + "items": [ {"qty": 1, "sku": "apple"}, {"qty": 2, "sku": "banana"} ] + }, + "result": false + }, + { + "description": "{\"some\":[{\"var\":\"items\"},{\">=\":[{\"var\":\"qty\"},1]}]}", + "rule": { + "some": [ + {"val": "items"}, + { + ">=": [ {"val": "qty"}, 1 ] + } + ] + }, + "data": {"items": []}, + "result": false + } +] diff --git a/suites/val.json b/suites/val.json index c6e443d..a631e6d 100644 --- a/suites/val.json +++ b/suites/val.json @@ -1,5 +1,17 @@ [ "Test Specification for val", + { + "description": "Fetches a value", + "rule": { "val": "hello" }, + "data": { "hello" : 0 }, + "result": 0 + }, + { + "description": "Fetches a nested value", + "rule": { "val": ["hello", "world"] }, + "data": { "hello" : { "world": 1 } }, + "result": 1 + }, { "description": "Fetches a value from an empty key", "rule": { "val": "" }, @@ -12,6 +24,12 @@ "data": { "" : { "": 2 } }, "result": 2 }, + { + "description": "Fetches a value from an array", + "rule": { "val": ["arr", 1] }, + "data": { "arr": [1, 2] }, + "result": 2 + }, { "description": "Fetches a value from a doubly nested empty key", "rule": { "val": ["", "", ""] }, @@ -26,12 +44,6 @@ }, { "description": "Fetches the entire context", - "rule": { "val": null }, - "data": { "": 21 }, - "result": { "": 21 } - }, - { - "description": "Fetches the entire context (array)", "rule": { "val": [] }, "data": { "": 21 }, "result": { "": 21 } @@ -43,19 +55,7 @@ "result": { "": 22 } }, { - "description": "Using val in a map (remember that null gets the current context, not empty string)", - "rule": { "map": [[1,2,3], { "+": [{ "val": null }, 1] }] }, - "data": null, - "result": [2,3,4] - }, - { - "description": "Using val in a map (and remember [null] and null are the same)", - "rule": { "map": [[1,2,3], { "+": [{ "val": [null] }, 1] }] }, - "data": null, - "result": [2,3,4] - }, - { - "description": "Using val in a map (using empty list)", + "description": "Using val in a map", "rule": { "map": [[1,2,3], { "+": [{ "val": [] }, 1] }] }, "data": null, "result": [2,3,4] @@ -63,20 +63,20 @@ "Testing out scopes", { "description": "Climb up to get adder", - "rule": { "map": [[1,2,3], { "+": [{ "val": null }, { "val": [[-2], "adder"] }] }] }, + "rule": { "map": [[1,2,3], { "+": [{ "val": [] }, { "val": [[-2], "adder"] }] }] }, "data": { "adder": 10 }, "result": [11,12,13] }, { "description": "Climb up to get index", - "rule": { "map": [[1,2,3], { "+": [{ "val": null }, { "val": [[-1], "index"] }] }] }, + "rule": { "map": [[1,2,3], { "+": [{ "val": [] }, { "val": [[-1], "index"] }] }] }, "data": { "adder": 10 }, "result": [1,3,5] }, { "description": "Nested get adder", "rule": { - "map": [["Test"], { "map": [[1,2,3], { "+": [{"val": null}, {"val": [[-4], "adder"]}] }]} ] + "map": [["Test"], { "map": [[1,2,3], { "+": [{"val": []}, {"val": [[-4], "adder"]}] }]} ] }, "data": { "adder": 10 }, "result": [[11,12,13]] diff --git a/suites/vars.json b/suites/vars.json deleted file mode 100644 index 06ba8be..0000000 --- a/suites/vars.json +++ /dev/null @@ -1,81 +0,0 @@ -[ - "Test Specification for Handling esoteric path traversal", - { - "description": "Fetches a value from an empty key", - "rule": { "var": "." }, - "data": { "" : 1 }, - "result": 1 - }, - { - "description": "Fetches a value from a nested empty key", - "rule": { "var": ".." }, - "data": { "" : { "": 2 } }, - "result": 2 - }, - { - "description": "Fetches a value from a doubly nested empty key", - "rule": { "var": "..." }, - "data": { "" : { "": { "": 3 } } }, - "result": 3 - }, - { - "description": "Fetches a value from a key that is purely a dot", - "rule": { "var": "\\." }, - "data": { "." : 20 }, - "result": 20 - }, - { - "description": "Fetches a value from a key with a dot in it", - "rule": { "var": "\\.key" }, - "data": { ".key" : 4 }, - "result": 4 - }, - { - "description":"Fetches a value from a key with a dot in it (2)", - "rule": { "var": "hello\\.world" }, - "data": { "hello.world" : 5 }, - "result": 5 - }, - { - "description": "Fetches a value from a key inside an empty key with a dot in it", - "rule": { "var": ".\\.key" }, - "data": { "": { ".key" : 6 } }, - "result": 6 - }, - { - "description": "Going a few levels deep", - "rule": { "var": "..\\.key." }, - "data": { "": { "": { ".key": { "": 7 }} }}, - "result": 7 - }, - { - "description": "Escape / as well, which is useful for the scope proposal", - "rule": { "var": "\\/" }, - "data": { "/" : 8 }, - "result": 8 - }, - { - "description": "Though / doesn't inherently need to be escaped", - "rule": { "var": "/" }, - "data": { "/" : 9 }, - "result": 9 - }, - { - "description": "Dot then empty key", - "rule": { "var": "\\.." }, - "data": { "." : { "" : 10 } }, - "result": 10 - }, - { - "description": "Empty key then dot", - "rule": { "var": ".\\." }, - "data": { "" : { "." : 11 } }, - "result": 11 - }, - { - "description": "Can use backslack in name, too", - "rule": { "var": "\\\\.Hello" }, - "data": { "\\" : { "Hello" : 12 } }, - "result": 12 - } -] \ No newline at end of file From c197e74260d9b3399346f23620265d14038c8c5d Mon Sep 17 00:00:00 2001 From: Jesse Mitchell Date: Thu, 26 Dec 2024 14:32:41 -0600 Subject: [PATCH 06/17] Add a simple optimization to inline chained val logic. --- defaultMethods.js | 6 +++++- suites/additional.json | 8 +++++++- 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/defaultMethods.js b/defaultMethods.js index ec1f7f9..fedd62b 100644 --- a/defaultMethods.js +++ b/defaultMethods.js @@ -319,7 +319,11 @@ const defaultMethods = { 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)) return false + 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.disableInline) data = (buildState.engine.fallback || buildState.engine).run(data, buildState.context, { above: buildState.above }) + else return false + } if (Array.isArray(data) && Array.isArray(data[0])) { // A very, very specific optimization. if (buildState.iteratorCompile && Math.abs(data[0][0] || 0) === 1 && data[1] === 'index') return buildState.compile`index` diff --git a/suites/additional.json b/suites/additional.json index 17f0151..1f6cf6d 100644 --- a/suites/additional.json +++ b/suites/additional.json @@ -70,5 +70,11 @@ "start_with": 59 }, 69 - ] + ], + { + "description": "Simple Inlineable Val Chained", + "rule": { "val": { "cat": ["te", "st"] } }, + "data": { "test": 1 }, + "result": 1 + } ] \ No newline at end of file From d1a3b4a928005f4430c0a8c758bf735e4bda0610 Mon Sep 17 00:00:00 2001 From: Jesse Mitchell Date: Thu, 26 Dec 2024 14:38:04 -0600 Subject: [PATCH 07/17] disableInline not flagged properly in val, corrected. --- defaultMethods.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/defaultMethods.js b/defaultMethods.js index fedd62b..2dd70e1 100644 --- a/defaultMethods.js +++ b/defaultMethods.js @@ -321,7 +321,7 @@ const defaultMethods = { 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.disableInline) data = (buildState.engine.fallback || buildState.engine).run(data, buildState.context, { above: buildState.above }) + 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 }) else return false } if (Array.isArray(data) && Array.isArray(data[0])) { From 75e096b790280c26cf56c179e6bad85eca7e09df Mon Sep 17 00:00:00 2001 From: Jesse Mitchell Date: Thu, 26 Dec 2024 14:42:49 -0600 Subject: [PATCH 08/17] Erase varTop buildState --- defaultMethods.js | 3 --- 1 file changed, 3 deletions(-) diff --git a/defaultMethods.js b/defaultMethods.js index 2dd70e1..cfba9b8 100644 --- a/defaultMethods.js +++ b/defaultMethods.js @@ -918,7 +918,6 @@ defaultMethods.get.compile = function (data, buildState) { defaultMethods.var.compile = function (data, buildState) { let key = data let defaultValue = null - buildState.varTop = buildState.varTop || new Set() if ( !key || typeof data === 'string' || @@ -941,8 +940,6 @@ defaultMethods.var.compile = function (data, buildState) { if (key.includes('../')) return false const pieces = splitPathMemoized(key) - const [top] = pieces - buildState.varTop.add(top) if (!buildState.engine.allowFunctions) buildState.methods.preventFunctions = a => typeof a === 'function' ? null : a else buildState.methods.preventFunctions = a => a From d9b89bd57d85a03b9220fdcd506189d158e8fef9 Mon Sep 17 00:00:00 2001 From: Jesse Mitchell Date: Fri, 3 Jan 2025 12:21:30 -0600 Subject: [PATCH 09/17] Add implementation for exists, and coalescing, and test suites for each --- constants.js | 1 + defaultMethods.js | 70 +++++++++++++++++++++++++++-------- suites/coalesce.json | 87 ++++++++++++++++++++++++++++++++++++++++++++ suites/exists.json | 51 ++++++++++++++++++++++++++ 4 files changed, 194 insertions(+), 15 deletions(-) create mode 100644 suites/coalesce.json create mode 100644 suites/exists.json diff --git a/constants.js b/constants.js index a4e2e7b..33533ac 100644 --- a/constants.js +++ b/constants.js @@ -4,6 +4,7 @@ export const Sync = Symbol.for('json_logic_sync') export const Compiled = Symbol.for('json_logic_compiled') export const EfficientTop = Symbol.for('json_logic_efficientTop') +export const Unfound = Symbol.for('json_logic_unfound') /** * Checks if an item is synchronous. diff --git a/defaultMethods.js b/defaultMethods.js index cfba9b8..31d7f0d 100644 --- a/defaultMethods.js +++ b/defaultMethods.js @@ -2,7 +2,7 @@ 'use strict' import asyncIterators from './async_iterators.js' -import { Sync, isSync } from './constants.js' +import { Sync, isSync, Unfound } from './constants.js' import declareSync from './utilities/declareSync.js' import { build, buildString } from './compiler.js' import chainingSupported from './utilities/chainingSupported.js' @@ -161,6 +161,43 @@ 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. + '??': { + 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: { method: (arr, _1, _2, engine) => { // See "executeInLoop" above @@ -191,11 +228,8 @@ const defaultMethods = { deterministic: (data, buildState) => isDeterministic(data, buildState.engine, buildState), compile: (data, buildState) => { if (!buildState.engine.truthy.IDENTITY) return false - if (Array.isArray(data)) { - return `(${data.map((i) => buildString(i, buildState)).join(' || ')})` - } else { - return `(${buildString(data, buildState)}).reduce((a,b) => a||b, 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 }, @@ -228,11 +262,8 @@ const defaultMethods = { deterministic: (data, buildState) => isDeterministic(data, buildState.engine, buildState), compile: (data, buildState) => { if (!buildState.engine.truthy.IDENTITY) return false - if (Array.isArray(data)) { - return `(${data.map((i) => buildString(i, buildState)).join(' && ')})` - } else { - return `(${buildString(data, buildState)}).reduce((a,b) => a&&b, true)` - } + if (Array.isArray(data) && data.length) return `(${data.map((i) => buildString(i, buildState)).join(' && ')})` + return `(${buildString(data, buildState)}).reduce((a,b) => a&&b, true)` } }, substr: ([string, from, end]) => { @@ -267,12 +298,20 @@ const defaultMethods = { } } }, - // Adding this to spec something out, not to merge it quite yet + exists: { + method: (key, context, above, engine) => { + const result = defaultMethods.val.method(key, context, above, engine, Unfound) + return result !== Unfound + }, + traverse: true, + deterministic: false + }, val: { - method: (args, context, above, engine) => { + method: (args, context, above, engine, /** @type {null | Symbol} */ unFound = null) => { if (Array.isArray(args) && args.length === 1) args = args[0] // A unary optimization if (!Array.isArray(args)) { + if (unFound && !(context && args in context)) return unFound const result = context[args] if (typeof result === 'undefined') return null return result @@ -295,11 +334,12 @@ const defaultMethods = { } // This block handles traversing the path for (let i = start; i < args.length; i++) { + if (unFound && !(result && args[i] in result)) return unFound if (result === null || result === undefined) return null result = result[args[i]] } - if (typeof result === 'undefined') return null - if (typeof result === 'function' && !engine.allowFunctions) return null + if (typeof result === 'undefined') return unFound + if (typeof result === 'function' && !engine.allowFunctions) return unFound return result }, optimizeUnary: true, diff --git a/suites/coalesce.json b/suites/coalesce.json new file mode 100644 index 0000000..8689297 --- /dev/null +++ b/suites/coalesce.json @@ -0,0 +1,87 @@ +[ + "Test Specification for ??", + { + "description": "Coalesces a string alone", + "rule": { "??": ["hello"] }, + "data": null, + "result": "hello" + }, + { + "description": "Coalesces a number alone", + "rule": { "??": [1] }, + "data": null, + "result": 1 + }, + { + "description": "Coalesces a boolean alone", + "rule": { "??": [true] }, + "data": null, + "result": true + }, + { + "description": "Coalesces an object from context alone", + "rule": { "??": [{ "val": "person" }]}, + "data": { "person": { "name": "John" } }, + "result": { "name": "John" } + }, + { + "description": "Empty behavior", + "rule": { "??": [] }, + "data": null, + "result": null + }, + { + "description": "Coalesces a string with nulls before", + "rule": { "??": [null, "hello"] }, + "data": null, + "result": "hello" + }, + { + "description": "Coalesces a string with multiple nulls before", + "rule": { "??": [null, null, null, "hello"] }, + "data": null, + "result": "hello" + }, + { + "description": "Coalesces a string with nulls after", + "rule": { "??": ["hello", null] }, + "data": null, + "result": "hello" + }, + { + "description": "Coalesces a string with nulls both before and after", + "rule": { "??": [null, "hello", null] }, + "data": null, + "result": "hello" + }, + { + "description": "Coalesces a number with nulls both before and after", + "rule": { "??": [null, 1, null] }, + "data": null, + "result": 1 + }, + { + "description": "Uses the first non-null value", + "rule": { "??": [null, 1, "hello"] }, + "data": null, + "result": 1 + }, + { + "description": "Uses the first non-null value, even if it is false", + "rule": { "??": [null, false, "hello"] }, + "data": null, + "result": false + }, + { + "description": "Uses the first non-null value from context", + "rule": { "??": [{ "val": ["person", "name"] }, { "val": "name" }] }, + "data": { "person": { "name": "John" }, "name": "Jane" }, + "result": "John" + }, + { + "description": "Uses the first non-null value from context (with person undefined)", + "rule": { "??": [{ "val": ["person", "name"] }, { "val": "name" }] }, + "data": { "name": "Jane" }, + "result": "Jane" + } +] \ No newline at end of file diff --git a/suites/exists.json b/suites/exists.json new file mode 100644 index 0000000..af03dfd --- /dev/null +++ b/suites/exists.json @@ -0,0 +1,51 @@ +[ + "Test Specification for exists", + { + "description": "Checks if a normal key exists", + "rule": { "exists": "hello" }, + "data": { "hello" : 1 }, + "result": true + }, + { + "description": "Checks if a normal key exists (array)", + "rule": { "exists": ["hello"] }, + "data": { "hello" : 1 }, + "result": true + }, + { + "description": "Checks if a normal key exists (false)", + "rule": { "exists": "hello" }, + "data": { "world" : 1 }, + "result": false + }, + { + "description": "Checks if an empty key exists (true)", + "rule": { "exists": [""] }, + "data": { "" : 1 }, + "result": true + }, + { + "description": "Checks if an empty key exists (false)", + "rule": { "exists": [""] }, + "data": { "hello" : 1 }, + "result": false + }, + { + "description": "Checks if a nested key exists", + "rule": { "exists": ["hello", "world"] }, + "data": { "hello" : { "world": false } }, + "result": true + }, + { + "description": "Checks if a nested key exists (false)", + "rule": { "exists": ["hello", "world"] }, + "data": { "hello" : { "x": false } }, + "result": false + }, + { + "description": "Checks if a null value exists", + "rule": { "exists": "hello" }, + "data": { "hello" : null }, + "result": true + } +] \ No newline at end of file From 4422db5bf5f4c170ddf14347ee1d90c433a3a6e4 Mon Sep 17 00:00:00 2001 From: Jesse Mitchell Date: Fri, 3 Jan 2025 12:24:04 -0600 Subject: [PATCH 10/17] Touch up coalesce test suite --- suites/coalesce.json | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/suites/coalesce.json b/suites/coalesce.json index 8689297..36121ca 100644 --- a/suites/coalesce.json +++ b/suites/coalesce.json @@ -74,14 +74,20 @@ }, { "description": "Uses the first non-null value from context", - "rule": { "??": [{ "val": ["person", "name"] }, { "val": "name" }] }, + "rule": { "??": [{ "val": ["person", "name"] }, { "val": "name" }, "Unknown Name"] }, "data": { "person": { "name": "John" }, "name": "Jane" }, "result": "John" }, { "description": "Uses the first non-null value from context (with person undefined)", - "rule": { "??": [{ "val": ["person", "name"] }, { "val": "name" }] }, + "rule": { "??": [{ "val": ["person", "name"] }, { "val": "name" }, "Unknown Name"] }, "data": { "name": "Jane" }, "result": "Jane" + }, + { + "description": "Uses the first non-null value from context (without any context)", + "rule": { "??": [{ "val": ["person", "name"] }, { "val": "name" }, "Unknown Name"] }, + "data": {}, + "result": "Unknown Name" } ] \ No newline at end of file From 5d8ab2069d1d2c984fac619928391cf0dbcfe3a0 Mon Sep 17 00:00:00 2001 From: Jesse Mitchell Date: Fri, 3 Jan 2025 12:40:09 -0600 Subject: [PATCH 11/17] Remove the legacy methods from the default methods javascript file --- defaultMethods.js | 167 ++-------------------------------------------- legacy.js | 156 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 161 insertions(+), 162 deletions(-) create mode 100644 legacy.js diff --git a/defaultMethods.js b/defaultMethods.js index 31d7f0d..7178be4 100644 --- a/defaultMethods.js +++ b/defaultMethods.js @@ -7,7 +7,7 @@ import declareSync from './utilities/declareSync.js' import { build, buildString } from './compiler.js' import chainingSupported from './utilities/chainingSupported.js' import InvalidControlInput from './errors/InvalidControlInput.js' -import { splitPathMemoized } from './utilities/splitPath.js' +import legacyMethods from './legacy.js' function isDeterministic (method, engine, buildState) { if (Array.isArray(method)) { @@ -278,26 +278,6 @@ const defaultMethods = { if (i && typeof i === 'object') return Object.keys(i).length return 0 }, - get: { - method: ([data, key, defaultValue], context, above, engine) => { - const notFound = defaultValue === undefined ? null : defaultValue - - const subProps = splitPathMemoized(String(key)) - for (let i = 0; i < subProps.length; i++) { - if (data === null || data === undefined) { - return notFound - } - // Descending into context - data = data[subProps[i]] - if (data === undefined) { - return notFound - } - } - if (engine.allowFunctions || typeof data[key] !== 'function') { - return data - } - } - }, exists: { method: (key, context, above, engine) => { const result = defaultMethods.val.method(key, context, above, engine, Unfound) @@ -384,64 +364,6 @@ const defaultMethods = { return false } }, - var: (key, context, above, engine) => { - let b - if (Array.isArray(key)) { - b = key[1] - key = key[0] - } - let iter = 0 - while ( - typeof key === 'string' && - key.startsWith('../') && - iter < above.length - ) { - context = above[iter++] - key = key.substring(3) - // A performance optimization that allows you to pass the previous above array without spreading it as the last argument - if (iter === above.length && Array.isArray(context)) { - iter = 0 - above = context - context = above[iter++] - } - } - - const notFound = b === undefined ? null : b - if (typeof key === 'undefined' || key === '' || key === null) { - if (engine.allowFunctions || typeof context !== 'function') { - return context - } - return null - } - const subProps = splitPathMemoized(String(key)) - for (let i = 0; i < subProps.length; i++) { - if (context === null || context === undefined) { - return notFound - } - // Descending into context - context = context[subProps[i]] - if (context === undefined) { - return notFound - } - } - if (engine.allowFunctions || typeof context !== 'function') { - return context - } - return null - }, - missing: (checked, context, above, engine) => { - return (Array.isArray(checked) ? checked : [checked]).filter((key) => { - return defaultMethods.var(key, context, above, engine) === null - }) - }, - missing_some: ([needCount, options], context, above, engine) => { - const missing = defaultMethods.missing(options, context, above, engine) - if (options.length - missing.length >= needCount) { - return [] - } else { - return missing - } - }, map: createArrayIterativeMethod('map'), some: createArrayIterativeMethod('some', true), all: createArrayIterativeMethod('every', true), @@ -745,16 +667,7 @@ Object.keys(defaultMethods).forEach((item) => { ? true : defaultMethods[item].deterministic }) -// @ts-ignore Allow custom attribute -defaultMethods.var.deterministic = (data, buildState) => { - return buildState.insideIterator && !String(data).includes('../../') -} -Object.assign(defaultMethods.missing, { - deterministic: false -}) -Object.assign(defaultMethods.missing_some, { - deterministic: false -}) + // @ts-ignore Allow custom attribute defaultMethods['<'].compile = function (data, buildState) { if (!Array.isArray(data)) return false @@ -926,81 +839,11 @@ defaultMethods['!!'].compile = function (data, buildState) { return `(!!engine.truthy(${data}))` } defaultMethods.none.deterministic = defaultMethods.some.deterministic -defaultMethods.get.compile = function (data, buildState) { - let defaultValue = null - let key = data - let obj = null - if (Array.isArray(data) && data.length <= 3) { - obj = data[0] - key = data[1] - defaultValue = typeof data[2] === 'undefined' ? null : data[2] - - // Bail out if the key is dynamic; dynamic keys are not really optimized by this block. - if (key && typeof key === 'object') return false - - key = key.toString() - const pieces = splitPathMemoized(key) - if (!chainingSupported) { - return `(((a,b) => (typeof a === 'undefined' || a === null) ? b : a)(${pieces.reduce( - (text, i) => { - return `(${text}||0)[${JSON.stringify(i)}]` - }, - `(${buildString(obj, buildState)}||0)` - )}, ${buildString(defaultValue, buildState)}))` - } - return `((${buildString(obj, buildState)})${pieces - .map((i) => `?.[${buildString(i, buildState)}]`) - .join('')} ?? ${buildString(defaultValue, buildState)})` - } - return false -} -// @ts-ignore Allow custom attribute -defaultMethods.var.compile = function (data, buildState) { - let key = data - let defaultValue = null - if ( - !key || - typeof data === 'string' || - typeof data === 'number' || - (Array.isArray(data) && data.length <= 2) - ) { - 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' - if (typeof key !== 'string' && typeof key !== 'number') return false - - key = key.toString() - if (key.includes('../')) return false - - 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( - (text, i) => `(${text}||0)[${JSON.stringify(i)}]`, - '(context||0)' - )}, ${buildString(defaultValue, buildState)})))` - } - return `(methods.preventFunctions(context${pieces - .map((i) => `?.[${JSON.stringify(i)}]`) - .join('')} ?? ${buildString(defaultValue, 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 +defaultMethods['+'].optimizeUnary = defaultMethods['-'].optimizeUnary = defaultMethods['!'].optimizeUnary = defaultMethods['!!'].optimizeUnary = defaultMethods.cat.optimizeUnary = true export default { - ...defaultMethods + ...defaultMethods, + ...legacyMethods } diff --git a/legacy.js b/legacy.js new file mode 100644 index 0000000..bd0b03c --- /dev/null +++ b/legacy.js @@ -0,0 +1,156 @@ +import { buildString } from './compiler.js' +import { splitPathMemoized } from './utilities/splitPath.js' +import chainingSupported from './utilities/chainingSupported.js' +import { Sync } from './constants.js' + +const legacyMethods = { + get: { + [Sync]: true, + method: ([data, key, defaultValue], context, above, engine) => { + const notFound = defaultValue === undefined ? null : defaultValue + + const subProps = splitPathMemoized(String(key)) + for (let i = 0; i < subProps.length; i++) { + if (data === null || data === undefined) return notFound + // Descending into context + data = data[subProps[i]] + if (data === undefined) return notFound + } + + if (engine.allowFunctions || typeof data[key] !== 'function') return data + return null + }, + deterministic: true, + compile: (data, buildState) => { + let defaultValue = null + let key = data + let obj = null + if (Array.isArray(data) && data.length <= 3) { + obj = data[0] + key = data[1] + defaultValue = typeof data[2] === 'undefined' ? null : data[2] + + // Bail out if the key is dynamic; dynamic keys are not really optimized by this block. + if (key && typeof key === 'object') return false + + key = key.toString() + const pieces = splitPathMemoized(key) + if (!chainingSupported) { + return `(((a,b) => (typeof a === 'undefined' || a === null) ? b : a)(${pieces.reduce( + (text, i) => `(${text}||0)[${JSON.stringify(i)}]`, + `(${buildString(obj, buildState)}||0)` + )}, ${buildString(defaultValue, buildState)}))` + } + return `((${buildString(obj, buildState)})${pieces + .map((i) => `?.[${buildString(i, buildState)}]`) + .join('')} ?? ${buildString(defaultValue, buildState)})` + } + return false + } + }, + var: { + [Sync]: true, + method: (key, context, above, engine) => { + let b + if (Array.isArray(key)) { + b = key[1] + key = key[0] + } + let iter = 0 + while (typeof key === 'string' && key.startsWith('../') && iter < above.length) { + context = above[iter++] + key = key.substring(3) + // A performance optimization that allows you to pass the previous above array without spreading it as the last argument + if (iter === above.length && Array.isArray(context)) { + iter = 0 + above = context + context = above[iter++] + } + } + + const notFound = b === undefined ? null : b + if (typeof key === 'undefined' || key === '' || key === null) { + if (engine.allowFunctions || typeof context !== 'function') return context + return null + } + const subProps = splitPathMemoized(String(key)) + for (let i = 0; i < subProps.length; i++) { + if (context === null || context === undefined) return notFound + + // Descending into context + context = context[subProps[i]] + if (context === undefined) return notFound + } + + if (engine.allowFunctions || typeof context !== 'function') return context + return null + }, + deterministic: (data, buildState) => buildState.insideIterator && !String(data).includes('../../'), + optimizeUnary: true, + compile: (data, buildState) => { + let key = data + let defaultValue = null + if ( + !key || + typeof data === 'string' || + typeof data === 'number' || + (Array.isArray(data) && data.length <= 2) + ) { + 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' + if (typeof key !== 'string' && typeof key !== 'number') return false + + key = key.toString() + if (key.includes('../')) return false + + 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( + (text, i) => `(${text}||0)[${JSON.stringify(i)}]`, + '(context||0)' + )}, ${buildString(defaultValue, buildState)})))` + } + return `(methods.preventFunctions(context${pieces + .map((i) => `?.[${JSON.stringify(i)}]`) + .join('')} ?? ${buildString(defaultValue, buildState)}))` + } + return false + } + }, + missing: { + [Sync]: true, + method: (checked, context, above, engine) => { + return (Array.isArray(checked) ? checked : [checked]).filter((key) => { + return legacyMethods.var.method(key, context, above, engine) === null + }) + }, + deterministic: false + }, + missing_some: { + [Sync]: true, + method: ([needCount, options], context, above, engine) => { + const missing = legacyMethods.missing.method(options, context, above, engine) + if (options.length - missing.length >= needCount) { + return [] + } else { + return missing + } + }, + deterministic: false + } +} + +export default legacyMethods From ddd886e32776d51d4d9c199603cefe7e6b3321ea Mon Sep 17 00:00:00 2001 From: Jesse Mitchell Date: Fri, 3 Jan 2025 13:30:42 -0600 Subject: [PATCH 12/17] Some of the rewrites made things mildly slower, so I looked for an optimization to speed things back up. I've added a micro-optimization for val/var, making things generally more performant. --- asyncLogic.js | 11 +++++++++-- constants.js | 4 ++-- defaultMethods.js | 7 ++++--- legacy.js | 6 +++++- logic.js | 10 +++++++++- 5 files changed, 29 insertions(+), 9 deletions(-) diff --git a/asyncLogic.js b/asyncLogic.js index 96d6208..fb1d95e 100644 --- a/asyncLogic.js +++ b/asyncLogic.js @@ -3,7 +3,7 @@ import defaultMethods from './defaultMethods.js' import LogicEngine from './logic.js' -import { isSync } from './constants.js' +import { isSync, OriginalImpl } from './constants.js' import declareSync from './utilities/declareSync.js' import { buildAsync } from './compiler.js' import omitUndefined from './utilities/omitUndefined.js' @@ -75,6 +75,13 @@ class AsyncLogicEngine { if (this.isData(logic, func)) return logic if (!this.methods[func]) throw new Error(`Method '${func}' was not found in the Logic Engine.`) + // A small but useful micro-optimization for some of the most common functions. + // Later on, I could define something to shut this off if var / val are redefined. + if ((func === 'var' || func === 'val') && this.methods[func][OriginalImpl]) { + const input = (!data || typeof data !== 'object') ? data : this.fallback.run(data, context, { above }) + return this.methods[func].method(input, context, above, this) + } + if (typeof this.methods[func] === 'function') { const input = (!data || typeof data !== 'object') ? [data] : await this.run(data, context, { above }) const result = await this.methods[func](coerceArray(input), context, above, this) @@ -210,5 +217,5 @@ class AsyncLogicEngine { return logic } } -Object.assign(AsyncLogicEngine.prototype.truthy, { IDENTITY: true }) +Object.assign(AsyncLogicEngine.prototype.truthy, { [OriginalImpl]: true }) export default AsyncLogicEngine diff --git a/constants.js b/constants.js index 33533ac..8b59eb2 100644 --- a/constants.js +++ b/constants.js @@ -3,7 +3,7 @@ export const Sync = Symbol.for('json_logic_sync') export const Compiled = Symbol.for('json_logic_compiled') -export const EfficientTop = Symbol.for('json_logic_efficientTop') +export const OriginalImpl = Symbol.for('json_logic_original') export const Unfound = Symbol.for('json_logic_unfound') /** @@ -23,6 +23,6 @@ export function isSync (item) { export default { Sync, - EfficientTop, + OriginalImpl, isSync } diff --git a/defaultMethods.js b/defaultMethods.js index 16ee2e2..7386b9c 100644 --- a/defaultMethods.js +++ b/defaultMethods.js @@ -2,7 +2,7 @@ 'use strict' import asyncIterators from './async_iterators.js' -import { Sync, isSync, Unfound } from './constants.js' +import { Sync, isSync, Unfound, OriginalImpl } from './constants.js' import declareSync from './utilities/declareSync.js' import { build, buildString } from './compiler.js' import chainingSupported from './utilities/chainingSupported.js' @@ -277,7 +277,7 @@ const defaultMethods = { }, deterministic: (data, buildState) => isDeterministic(data, buildState.engine, buildState), compile: (data, buildState) => { - if (!buildState.engine.truthy.IDENTITY) return false + if (!buildState.engine.truthy[OriginalImpl]) 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)` }, @@ -311,7 +311,7 @@ const defaultMethods = { traverse: false, deterministic: (data, buildState) => isDeterministic(data, buildState.engine, buildState), compile: (data, buildState) => { - if (!buildState.engine.truthy.IDENTITY) return false + if (!buildState.engine.truthy[OriginalImpl]) 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)` } @@ -337,6 +337,7 @@ const defaultMethods = { deterministic: false }, val: { + [OriginalImpl]: true, method: (args, context, above, engine, /** @type {null | Symbol} */ unFound = null) => { if (Array.isArray(args) && args.length === 1) args = args[0] // A unary optimization diff --git a/legacy.js b/legacy.js index bd0b03c..7880f23 100644 --- a/legacy.js +++ b/legacy.js @@ -1,7 +1,8 @@ +'use strict' import { buildString } from './compiler.js' import { splitPathMemoized } from './utilities/splitPath.js' import chainingSupported from './utilities/chainingSupported.js' -import { Sync } from './constants.js' +import { Sync, OriginalImpl } from './constants.js' const legacyMethods = { get: { @@ -49,6 +50,7 @@ const legacyMethods = { } }, var: { + [OriginalImpl]: true, [Sync]: true, method: (key, context, above, engine) => { let b @@ -132,6 +134,7 @@ const legacyMethods = { }, missing: { [Sync]: true, + optimizeUnary: false, method: (checked, context, above, engine) => { return (Array.isArray(checked) ? checked : [checked]).filter((key) => { return legacyMethods.var.method(key, context, above, engine) === null @@ -141,6 +144,7 @@ const legacyMethods = { }, missing_some: { [Sync]: true, + optimizeUnary: false, method: ([needCount, options], context, above, engine) => { const missing = legacyMethods.missing.method(options, context, above, engine) if (options.length - missing.length >= needCount) { diff --git a/logic.js b/logic.js index 45e8a0e..f70cf0f 100644 --- a/logic.js +++ b/logic.js @@ -9,6 +9,7 @@ import omitUndefined from './utilities/omitUndefined.js' import { optimize } from './optimizer.js' import { applyPatches } from './compatibility.js' import { coerceArray } from './utilities/coerceArray.js' +import { OriginalImpl } from './constants.js' /** * An engine capable of running synchronous JSON Logic. @@ -71,6 +72,13 @@ class LogicEngine { if (!this.methods[func]) throw new Error(`Method '${func}' was not found in the Logic Engine.`) + // A small but useful micro-optimization for some of the most common functions. + // Later on, I could define something to shut this off if var / val are redefined. + if ((func === 'var' || func === 'val') && this.methods[func][OriginalImpl]) { + const input = (!data || typeof data !== 'object') ? data : this.run(data, context, { above }) + return this.methods[func].method(input, context, above, this) + } + if (typeof this.methods[func] === 'function') { const input = (!data || typeof data !== 'object') ? [data] : coerceArray(this.run(data, context, { above })) return this.methods[func](input, context, above, this) @@ -174,5 +182,5 @@ class LogicEngine { return logic } } -Object.assign(LogicEngine.prototype.truthy, { IDENTITY: true }) +Object.assign(LogicEngine.prototype.truthy, { [OriginalImpl]: true }) export default LogicEngine From 83f8b4d57e49179616905f92f7d5fe34c5bb69e9 Mon Sep 17 00:00:00 2001 From: Jesse Mitchell Date: Fri, 3 Jan 2025 13:58:20 -0600 Subject: [PATCH 13/17] Further improvements and optimizations (some carried over from the precision work) --- async_optimizer.js | 10 +++++----- compatibility.js | 2 ++ defaultMethods.js | 41 +++++++++++++++++++++++++++++++---------- optimizer.js | 7 +++---- 4 files changed, 41 insertions(+), 19 deletions(-) diff --git a/async_optimizer.js b/async_optimizer.js index 284f7ce..7c27584 100644 --- a/async_optimizer.js +++ b/async_optimizer.js @@ -28,7 +28,7 @@ function getMethod (logic, engine, methodName, above) { } let args = logic[methodName] - if (!args || typeof args !== 'object') args = [args] + if ((!args || typeof args !== 'object') && !method.optimizeUnary) args = [args] if (Array.isArray(args)) { const optimizedArgs = args.map(l => optimize(l, engine, above)) @@ -50,12 +50,12 @@ 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(coerceArray(typeof optimizedArgs === 'function' ? optimizedArgs(data, abv) : optimizedArgs, method.optimizeUnary), data, abv || above, engine), true) + if (method.optimizeUnary) return declareSync((data, abv) => called(typeof optimizedArgs === 'function' ? optimizedArgs(data, abv) : optimizedArgs, data, abv || above, engine.fallback), true) + return declareSync((data, abv) => called(coerceArray(typeof optimizedArgs === 'function' ? optimizedArgs(data, abv) : optimizedArgs), data, abv || above, engine), true) } - return async (data, abv) => { - return called(coerceArray(typeof optimizedArgs === 'function' ? await optimizedArgs(data, abv) : optimizedArgs, method.optimizeUnary), data, abv || above, engine) - } + if (method.optimizeUnary) return async (data, abv) => called(typeof optimizedArgs === 'function' ? await optimizedArgs(data, abv) : optimizedArgs, data, abv || above, engine) + return async (data, abv) => called(coerceArray(typeof optimizedArgs === 'function' ? await optimizedArgs(data, abv) : optimizedArgs), data, abv || above, engine) } } diff --git a/compatibility.js b/compatibility.js index 0c27844..6bc75de 100644 --- a/compatibility.js +++ b/compatibility.js @@ -1,7 +1,9 @@ +import { Sync } from './constants.js' import defaultMethods from './defaultMethods.js' const oldAll = defaultMethods.all const all = { + [Sync]: oldAll[Sync], method: (args, context, above, engine) => { if (Array.isArray(args)) { const first = engine.run(args[0], context, above) diff --git a/defaultMethods.js b/defaultMethods.js index 7386b9c..b32071f 100644 --- a/defaultMethods.js +++ b/defaultMethods.js @@ -212,6 +212,7 @@ const defaultMethods = { // 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) @@ -249,6 +250,7 @@ const defaultMethods = { traverse: false }, or: { + [Sync]: (data, buildState) => isSyncDeep(data, buildState.engine, buildState), method: (arr, _1, _2, engine) => { // See "executeInLoop" above const executeInLoop = Array.isArray(arr) @@ -284,6 +286,7 @@ const defaultMethods = { traverse: false }, and: { + [Sync]: (data, buildState) => isSyncDeep(data, buildState.engine, buildState), method: (arr, _1, _2, engine) => { // See "executeInLoop" above const executeInLoop = Array.isArray(arr) @@ -419,6 +422,7 @@ const defaultMethods = { some: createArrayIterativeMethod('some', true), all: createArrayIterativeMethod('every', true), none: { + [Sync]: (data, buildState) => isSyncDeep(data, buildState.engine, buildState), traverse: false, // todo: add async build & build method: (val, context, above, engine) => { @@ -439,7 +443,7 @@ const defaultMethods = { }, merge: (arrays) => (Array.isArray(arrays) ? [].concat(...arrays) : [arrays]), every: createArrayIterativeMethod('every'), - filter: createArrayIterativeMethod('filter'), + filter: createArrayIterativeMethod('filter', true), reduce: { deterministic: (data, buildState) => { return ( @@ -548,11 +552,27 @@ const defaultMethods = { }, '!': (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)), - cat: (arr) => { - if (typeof arr === 'string') return arr - let res = '' - for (let i = 0; i < arr.length; i++) res += arr[i] - return res + cat: { + [OriginalImpl]: true, + [Sync]: true, + method: (arr) => { + if (typeof arr === 'string') return arr + if (!Array.isArray(arr)) return arr.toString() + let res = '' + for (let i = 0; i < arr.length; i++) res += arr[i].toString() + return res + }, + deterministic: true, + traverse: true, + optimizeUnary: true, + compile: (data, buildState) => { + if (typeof data === 'string') return JSON.stringify(data) + if (typeof data === 'number') 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})` + } }, keys: ([obj]) => typeof obj === 'object' ? Object.keys(obj) : [], pipe: { @@ -673,8 +693,8 @@ function createArrayIterativeMethod (name, useTruthy = false) { (await engine.run(selector, context, { above })) || [] - return asyncIterators[name](selector, (i, index) => { - const result = engine.run(mapper, i, { + return asyncIterators[name](selector, async (i, index) => { + const result = await engine.run(mapper, i, { above: [{ iterator: selector, index }, context, above] }) return useTruthy ? engine.truthy(result) : result @@ -694,15 +714,16 @@ function createArrayIterativeMethod (name, useTruthy = false) { const method = build(mapper, mapState) const aboveArray = method.aboveDetected ? buildState.compile`[{ iterator: z, index: x }, context, above]` : buildState.compile`null` + const useTruthyMethod = useTruthy ? buildState.compile`engine.truthy` : buildState.compile`` if (async) { if (!isSync(method)) { buildState.asyncDetected = true - return buildState.compile`await asyncIterators[${name}](${selector} || [], async (i, x, z) => ${method}(i, x, ${aboveArray}))` + return buildState.compile`await asyncIterators[${name}](${selector} || [], async (i, x, z) => ${useTruthyMethod}(${method}(i, x, ${aboveArray})))` } } - return buildState.compile`(${selector} || [])[${name}]((i, x, z) => ${method}(i, x, ${aboveArray}))` + return buildState.compile`(${selector} || [])[${name}]((i, x, z) => ${useTruthyMethod}(${method}(i, x, ${aboveArray})))` }, traverse: false } diff --git a/optimizer.js b/optimizer.js index dc90e2f..767124f 100644 --- a/optimizer.js +++ b/optimizer.js @@ -20,7 +20,7 @@ function getMethod (logic, engine, methodName, above) { } let args = logic[methodName] - if (!args || typeof args !== 'object') args = [args] + if ((!args || typeof args !== 'object') && !method.optimizeUnary) args = [args] if (Array.isArray(args)) { const optimizedArgs = args.map(l => optimize(l, engine, above)) @@ -30,9 +30,8 @@ function getMethod (logic, engine, methodName, above) { } } else { const optimizedArgs = optimize(args, engine, above) - return (data, abv) => { - return called(coerceArray(typeof optimizedArgs === 'function' ? optimizedArgs(data, abv) : optimizedArgs, method.optimizeUnary), data, abv || above, engine) - } + if (method.optimizeUnary) return (data, abv) => called(typeof optimizedArgs === 'function' ? optimizedArgs(data, abv) : optimizedArgs, data, abv || above, engine) + return (data, abv) => called(coerceArray(typeof optimizedArgs === 'function' ? optimizedArgs(data, abv) : optimizedArgs), data, abv || above, engine) } } From 2292bf934d0f1885ed6a9101714e699eb7ea465f Mon Sep 17 00:00:00 2001 From: Jesse Mitchell Date: Fri, 3 Jan 2025 14:13:16 -0600 Subject: [PATCH 14/17] Add annotation to work around exotic build issue... --- legacy.js | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/legacy.js b/legacy.js index 7880f23..98ea589 100644 --- a/legacy.js +++ b/legacy.js @@ -4,6 +4,8 @@ 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 }>} **/ const legacyMethods = { get: { [Sync]: true, @@ -157,4 +159,5 @@ const legacyMethods = { } } -export default legacyMethods + +export default {...legacyMethods} From cf0ec8a160793b0bd8bf655142ae07a3ff7478cd Mon Sep 17 00:00:00 2001 From: Jesse Mitchell Date: Fri, 3 Jan 2025 14:49:31 -0600 Subject: [PATCH 15/17] Expand test suite --- defaultMethods.js | 3 ++- logic.js | 2 +- suites/val.json | 12 ++++++++++++ 3 files changed, 15 insertions(+), 2 deletions(-) diff --git a/defaultMethods.js b/defaultMethods.js index b32071f..556123b 100644 --- a/defaultMethods.js +++ b/defaultMethods.js @@ -346,6 +346,7 @@ const defaultMethods = { // A unary optimization if (!Array.isArray(args)) { if (unFound && !(context && args in context)) return unFound + if (context === null || context === undefined) return null const result = context[args] if (typeof result === 'undefined') return null return result @@ -405,7 +406,7 @@ const defaultMethods = { } if (Array.isArray(data) && data.length === 1) data = data[0] if (data === null) return wrapNull(buildState.compile`context`) - if (!Array.isArray(data)) return wrapNull(buildState.compile`context[${data}]`) + if (!Array.isArray(data)) return wrapNull(buildState.compile`(context || 0)[${data}]`) if (Array.isArray(data)) { let res = buildState.compile`context` for (let i = 0; i < data.length; i++) { diff --git a/logic.js b/logic.js index f70cf0f..3348d22 100644 --- a/logic.js +++ b/logic.js @@ -76,7 +76,7 @@ class LogicEngine { // Later on, I could define something to shut this off if var / val are redefined. if ((func === 'var' || func === 'val') && this.methods[func][OriginalImpl]) { const input = (!data || typeof data !== 'object') ? data : this.run(data, context, { above }) - return this.methods[func].method(input, context, above, this) + return this.methods[func].method(input, context, above, this, null) } if (typeof this.methods[func] === 'function') { diff --git a/suites/val.json b/suites/val.json index a631e6d..f100d8a 100644 --- a/suites/val.json +++ b/suites/val.json @@ -42,6 +42,18 @@ "data": { "." : 20 }, "result": 20 }, + { + "description": "Fetching a value from null returns null", + "rule": { "val": "hello" }, + "data": { "hello" : null }, + "result": null + }, + { + "description": "Fetching a value from a null fetched value returns null", + "rule": { "val": ["hello", "world"] }, + "data": { "hello" : null }, + "result": null + }, { "description": "Fetches the entire context", "rule": { "val": [] }, From 2b22100fc5d3a38ac543136af60a69581786d3b6 Mon Sep 17 00:00:00 2001 From: Jesse Mitchell Date: Fri, 3 Jan 2025 19:25:08 -0600 Subject: [PATCH 16/17] Commit one more test (I guess this guy didn't get added earlier) --- suites/val.json | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/suites/val.json b/suites/val.json index f100d8a..d52eb61 100644 --- a/suites/val.json +++ b/suites/val.json @@ -26,6 +26,12 @@ }, { "description": "Fetches a value from an array", + "rule": { "val": [1] }, + "data": [1, 2], + "result": 2 + }, + { + "description": "Fetches a value from an array in an object", "rule": { "val": ["arr", 1] }, "data": { "arr": [1, 2] }, "result": 2 From e52f768f9e422b48d857cf6cece0176044bfe149 Mon Sep 17 00:00:00 2001 From: Jesse Mitchell Date: Mon, 6 Jan 2025 11:37:55 -0600 Subject: [PATCH 17/17] Switch more tests to `val`, make further optimizations. --- compiler.js | 2 +- defaultMethods.js | 13 ++++++++++--- perf.js | 6 +++--- perf2.js | 6 +++--- perf3.js | 2 +- perf4.js | 6 +++--- 6 files changed, 21 insertions(+), 14 deletions(-) diff --git a/compiler.js b/compiler.js index eb573f6..128eac9 100644 --- a/compiler.js +++ b/compiler.js @@ -205,7 +205,7 @@ function buildString (method, buildState = {}) { } let coerce = engine.methods[func].optimizeUnary ? '' : 'coerceArray' - if (!coerce && Array.isArray(lower) && lower.length === 1) lower = lower[0] + if (!coerce && Array.isArray(lower) && lower.length === 1 && !Array.isArray(lower[0])) lower = lower[0] else if (coerce && Array.isArray(lower)) coerce = '' const argumentsDict = [', context', ', context, above', ', context, above, engine'] diff --git a/defaultMethods.js b/defaultMethods.js index 556123b..7b5565c 100644 --- a/defaultMethods.js +++ b/defaultMethods.js @@ -341,8 +341,9 @@ const defaultMethods = { }, val: { [OriginalImpl]: true, + [Sync]: true, method: (args, context, above, engine, /** @type {null | Symbol} */ unFound = null) => { - if (Array.isArray(args) && args.length === 1) args = args[0] + if (Array.isArray(args) && args.length === 1 && !Array.isArray(args[0])) args = args[0] // A unary optimization if (!Array.isArray(args)) { if (unFound && !(context && args in context)) return unFound @@ -406,7 +407,10 @@ const defaultMethods = { } if (Array.isArray(data) && data.length === 1) data = data[0] if (data === null) return wrapNull(buildState.compile`context`) - if (!Array.isArray(data)) return wrapNull(buildState.compile`(context || 0)[${data}]`) + if (!Array.isArray(data)) { + if (chainingSupported) return wrapNull(buildState.compile`context?.[${data}]`) + return wrapNull(buildState.compile`(context || 0)[${data}]`) + } if (Array.isArray(data)) { let res = buildState.compile`context` for (let i = 0; i < data.length; i++) { @@ -560,7 +564,10 @@ const defaultMethods = { if (typeof arr === 'string') return arr if (!Array.isArray(arr)) return arr.toString() let res = '' - for (let i = 0; i < arr.length; i++) res += arr[i].toString() + for (let i = 0; i < arr.length; i++) { + if (arr[i] === null || arr[i] === undefined) continue + res += arr[i] + } return res }, deterministic: true, diff --git a/perf.js b/perf.js index 3d2678a..8197e23 100644 --- a/perf.js +++ b/perf.js @@ -4,13 +4,13 @@ async function test () { const logic = { if: [ { - '>': [{ var: 'x' }, { '+': [11, 5, { '+': [1, { var: 'y' }, 1] }, 2] }] + '>': [{ val: 'x' }, { '+': [11, 5, { '+': [1, { val: 'y' }, 1] }, 2] }] }, { - '*': [{ var: 'x' }, { '*': { map: [[2, 5, 5], { var: '' }] } }] + '*': [{ val: 'x' }, { '*': { map: [[2, 5, 5], { val: [] }] } }] }, { - '/': [{ var: 'x' }, { '-': { map: [[100, 50, 30, 10], { var: '' }] } }] + '/': [{ val: 'x' }, { '-': { map: [[100, 50, 30, 10], { val: [] }] } }] } ] } diff --git a/perf2.js b/perf2.js index 0d57deb..d19a5e9 100644 --- a/perf2.js +++ b/perf2.js @@ -8,13 +8,13 @@ async function main () { const logic = { if: [ { - '>': [{ var: 'x' }, { '+': [11, 5, { '+': [1, { var: 'y' }, 1] }, 2] }] + '>': [{ val: 'x' }, { '+': [11, 5, { '+': [1, { val: 'y' }, 1] }, 2] }] }, { - '*': [{ var: 'x' }, { '*': { map: [[2, 5, 5], { var: '' }] } }] + '*': [{ val: 'x' }, { '*': { map: [[2, 5, 5], { val: [] }] } }] }, { - '/': [{ var: 'x' }, { '-': { map: [[100, 50, 30, 10], { var: '' }] } }] + '/': [{ val: 'x' }, { '-': { map: [[100, 50, 30, 10], { val: [] }] } }] } ] } diff --git a/perf3.js b/perf3.js index 8ffb5d4..6dacee3 100644 --- a/perf3.js +++ b/perf3.js @@ -1,6 +1,6 @@ import { LogicEngine } from './index.js' const x = new LogicEngine() -const f = x.build({ '<': [{ var: '' }, 10] }) +const f = x.build({ '<': [{ val: [] }, 10] }) console.time('Logic Built') for (let i = 0; i < 1e6; i++) { f(i % 20) diff --git a/perf4.js b/perf4.js index e610c4c..5332e6f 100644 --- a/perf4.js +++ b/perf4.js @@ -4,7 +4,7 @@ import { LogicEngine } from './index.js' const optimized = new LogicEngine() const unoptimized = new LogicEngine(undefined, { disableInterpretedOptimization: true }) -const dynamicRule = { '+': [1, 2, 3, { var: 'a' }] } +const dynamicRule = { '+': [1, 2, 3, { val: 'a' }] } const staticRule = { '+': [1, 2, 3, 4] } console.time('Optimized, Same Object, Dynamic') @@ -35,7 +35,7 @@ console.log('----') console.time('Optimized, Different Object, Dynamic') for (let i = 0; i < 1e6; i++) { - optimized.run({ '+': [1, 2, 3, { var: 'a' }] }, { a: i }) + optimized.run({ '+': [1, 2, 3, { val: 'a' }] }, { a: i }) } console.timeEnd('Optimized, Different Object, Dynamic') @@ -49,7 +49,7 @@ console.timeEnd('Optimized, Different Object, Static') console.time('Unoptimized, Different Object, Dynamic') for (let i = 0; i < 1e6; i++) { - unoptimized.run({ '+': [1, 2, 3, { var: 'a' }] }, { a: i }) + unoptimized.run({ '+': [1, 2, 3, { val: 'a' }] }, { a: i }) } console.timeEnd('Unoptimized, Different Object, Dynamic')