From e9307033a297b70406b96bab1fa1b08f4d68f2a8 Mon Sep 17 00:00:00 2001 From: Jesse Mitchell Date: Sun, 19 Jan 2025 18:13:04 -0600 Subject: [PATCH 1/2] Add a shim and some type annotations to create a better drop-in replacement experience for json-logic-js --- asyncLogic.js | 9 +-- bench/test.js | 22 ++++-- index.js | 4 +- logic.js | 10 +-- shim.js | 182 ++++++++++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 211 insertions(+), 16 deletions(-) create mode 100644 shim.js diff --git a/asyncLogic.js b/asyncLogic.js index 28c2076..af4ac9f 100644 --- a/asyncLogic.js +++ b/asyncLogic.js @@ -144,11 +144,11 @@ class AsyncLogicEngine { * * If it detects that a bunch of dynamic objects are being passed in, and it doesn't see the same object, * it will disable the interpreted optimization. - * + * @template T * @param {*} logic The logic to be executed * @param {*} data The data being passed in to the logic to be executed against. * @param {{ above?: any }} options Options for the invocation - * @returns {Promise} + * @returns {Promise} */ async run (logic, data = {}, options = {}) { const { above = [] } = options @@ -176,6 +176,7 @@ class AsyncLogicEngine { // 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[i] = await this.run(logic[i], data, { above }) + // @ts-expect-error Will figure out how to type this correctly soon from direct JS return res } @@ -185,10 +186,10 @@ class AsyncLogicEngine { } /** - * + * @template T * @param {*} logic The logic to be built. * @param {{ top?: Boolean, above?: any }} options - * @returns {Promise} + * @returns {Promise<(...args: any[]) => Promise>} */ async build (logic, options = {}) { const { above = [], top = true } = options diff --git a/bench/test.js b/bench/test.js index a999111..09efe15 100644 --- a/bench/test.js +++ b/bench/test.js @@ -1,11 +1,12 @@ import { LogicEngine, AsyncLogicEngine } from '../index.js' +import jsShim from '../shim.js' import fs from 'fs' import { isDeepStrictEqual } from 'util' import jl from 'json-logic-js' -import rust from '@bestow/jsonlogic-rs' +// import rust from '@bestow/jsonlogic-rs' -const x = new LogicEngine(undefined, { compatible: true }) -const y = new AsyncLogicEngine(undefined, { compatible: true }) +const x = new LogicEngine() +const y = new AsyncLogicEngine() const compatible = [] const incompatible = [] @@ -16,7 +17,6 @@ JSON.parse(fs.readFileSync('./tests.json').toString()).forEach((test) => { try { if (!isDeepStrictEqual(x.run(test[0], test[1]), test[2])) { incompatible.push(test) - // console.log(test[0]) } else { compatible.push(test) } @@ -69,13 +69,21 @@ for (let j = 0; j < tests.length; j++) { } console.timeEnd('json-logic-js') -console.time('json-logic-rs') +console.time('json-logic-engine-shim') for (let j = 0; j < tests.length; j++) { for (let i = 0; i < 1e5; i++) { - rust.apply(tests[j][0], tests[j][1]) + jsShim.apply(tests[j][0], tests[j][1]) } } -console.timeEnd('json-logic-rs') +console.timeEnd('json-logic-engine-shim') + +// console.time('json-logic-rs') +// for (let j = 0; j < tests.length; j++) { +// for (let i = 0; i < 1e5; i++) { +// rust.apply(tests[j][0], tests[j][1]) +// } +// } +// console.timeEnd('json-logic-rs') x.disableInterpretedOptimization = true console.time('le interpreted') diff --git a/index.js b/index.js index 7a36636..8714a20 100644 --- a/index.js +++ b/index.js @@ -8,6 +8,7 @@ import Constants from './constants.js' import defaultMethods from './defaultMethods.js' import { asLogicSync, asLogicAsync } from './asLogic.js' import { splitPath, splitPathMemoized } from './utilities/splitPath.js' +import jsonLogic from './shim.js' export { splitPath, splitPathMemoized } export { LogicEngine } @@ -17,5 +18,6 @@ export { Constants } export { defaultMethods } export { asLogicSync } export { asLogicAsync } +export { jsonLogic } -export default { LogicEngine, AsyncLogicEngine, Compiler, Constants, defaultMethods, asLogicSync, asLogicAsync, splitPath, splitPathMemoized } +export default { LogicEngine, AsyncLogicEngine, Compiler, Constants, defaultMethods, asLogicSync, asLogicAsync, splitPath, splitPathMemoized, jsonLogic } diff --git a/logic.js b/logic.js index abaa61c..b642dab 100644 --- a/logic.js +++ b/logic.js @@ -120,11 +120,11 @@ class LogicEngine { * * If it detects that a bunch of dynamic objects are being passed in, and it doesn't see the same object, * it will disable the interpreted optimization. - * + * @template T * @param {*} logic The logic to be executed * @param {*} data The data being passed in to the logic to be executed against. * @param {{ above?: any }} options Options for the invocation - * @returns {*} + * @returns {T} */ run (logic, data = {}, options = {}) { const { above = [] } = options @@ -150,6 +150,7 @@ class LogicEngine { if (Array.isArray(logic)) { const res = new Array(logic.length) for (let i = 0; i < logic.length; i++) res[i] = this.run(logic[i], data, { above }) + // @ts-expect-error I'll figure out how to type this correctly soon from direct JS return res } @@ -157,6 +158,7 @@ class LogicEngine { const keys = Object.keys(logic) if (keys.length > 0) { const func = keys[0] + // @ts-expect-error I'll figure out how to type this correctly soon from direct JS return this._parse(logic, data, above, func) } } @@ -165,10 +167,10 @@ class LogicEngine { } /** - * + * @template T * @param {*} logic The logic to be built. * @param {{ top?: Boolean, above?: any }} options - * @returns {Function} + * @returns {(...args: any[]) => T} */ build (logic, options = {}) { const { above = [], top = true } = options diff --git a/shim.js b/shim.js new file mode 100644 index 0000000..8807553 --- /dev/null +++ b/shim.js @@ -0,0 +1,182 @@ +// @ts-check +'use strict' +// Old Interface for json-logic-js being mapped onto json-logic-engine +import LogicEngine from './logic.js' + +/** + * This is a to allow json-logic-engine to easily be used as a drop-in replacement for json-logic-js. + * + * This shim does not expose all of the functionality of `json-logic-engine`, so it is recommended to switch over + * to using `json-logic-engine`'s API directly. + * + * The direct API is a bit more efficient and allows for logic compilation and other advanced features. + * @deprecated Please use `json-logic-engine` directly instead. + */ +const jsonLogic = { + _init () { + this.engine = new LogicEngine() + + // We don't know if folks are modifying the AST directly, and because this is a widespread package, + // I'm going to assume some folks might be, so let's go with the safe assumption and disable the optimizer + this.engine.disableInterpretedOptimization = true + }, + /** + * Applies the data to the logic and returns the result. + * @template T + * @returns {T} + * @deprecated Please use `json-logic-engine` directly instead. + */ + apply (logic, data) { + if (!this.engine) this._init() + return this.engine.run(logic, data) + }, + /** + * Adds a new operation to the engine. + * Note that this is much less performant than adding methods to the engine the new way. + * Either consider switching to using LogicEngine directly, or accessing `jsonLogic.engine.addMethod` instead. + * + * Documentation for the new style is available at: https://json-logic.github.io/json-logic-engine/docs/methods + * + * @param {string} name + * @param {((...args: any[]) => any) | Record any> } code + * @deprecated Please use `json-logic-engine` directly instead. + * @returns + */ + add_operation (name, code) { + if (!this.engine) this._init() + if (typeof code === 'object') { + for (const key in code) { + const method = code[key] + this.engine.addMethod(name + '.' + key, (args, ctx) => method.apply(ctx, args)) + } + return + } + + this.engine.addMethod(name, (args, ctx) => code.apply(ctx, args)) + }, + /** + * Removes an operation from the engine + * @param {string} name + */ + rm_operation (name) { + if (!this.engine) this._init() + this.engine.methods[name] = undefined + }, + /** + * Gets the first key from an object + * { >name<: values } + */ + get_operator (logic) { + return Object.keys(logic)[0] + }, + /** + * Gets the values from the logic + * { name: >values< } + */ + get_values (logic) { + const func = Object.keys(jsonLogic)[0] + return logic[func] + }, + /** + * Allows you to enable the optimizer which will accelerate your logic execution, + * as long as you don't mutate the logic after it's been run once. + * + * This will allow it to cache the execution plan for the logic, speeding things up. + */ + set_optimizer (val = true) { + if (!this.engine) this._init() + this.engine.disableInterpretedOptimization = val + }, + /** + * Checks if a given object looks like a logic object. + */ + is_logic (logic) { + return typeof logic === 'object' && logic !== null && !Array.isArray(logic) && Object.keys(logic).length === 1 + }, + /** + * Checks for which variables are used in a given logic object. + */ + uses_data (logic) { + /** @type {Set} */ + const collection = new Set() + + if (!jsonLogic.is_logic(logic)) { + const op = jsonLogic.get_operator(logic) + let values = logic[op] + + if (!Array.isArray(values)) values = [values] + + if (op === 'var') collection.add(values[0]) + else if (op === 'val') collection.add(values.join('.')) + else { + // Recursion! + for (const val of values) { + const subCollection = jsonLogic.uses_data(val) + for (const item of subCollection) collection.add(item) + } + } + } + + return Array.from(collection) + }, + // Copied directly from json-logic-js + rule_like (rule, pattern) { + if (pattern === rule) { + return true + } // TODO : Deep object equivalency? + if (pattern === '@') { + return true + } // Wildcard! + if (pattern === 'number') { + return (typeof rule === 'number') + } + if (pattern === 'string') { + return (typeof rule === 'string') + } + if (pattern === 'array') { + // !logic test might be superfluous in JavaScript + return Array.isArray(rule) && !jsonLogic.is_logic(rule) + } + + if (jsonLogic.is_logic(pattern)) { + if (jsonLogic.is_logic(rule)) { + const patternOp = jsonLogic.get_operator(pattern) + const ruleOp = jsonLogic.get_operator(rule) + + if (patternOp === '@' || patternOp === ruleOp) { + // echo "\nOperators match, go deeper\n"; + return jsonLogic.rule_like( + jsonLogic.get_values(rule), + jsonLogic.get_values(pattern) + ) + } + } + return false // pattern is logic, rule isn't, can't be eq + } + + if (Array.isArray(pattern)) { + if (Array.isArray(rule)) { + if (pattern.length !== rule.length) { + return false + } + /* + Note, array order MATTERS, because we're using this array test logic to consider arguments, where order can matter. (e.g., + is commutative, but '-' or 'if' or 'var' are NOT) + */ + for (let i = 0; i < pattern.length; i += 1) { + // If any fail, we fail + if (!jsonLogic.rule_like(rule[i], pattern[i])) { + return false + } + } + return true // If they *all* passed, we pass + } else { + return false // Pattern is array, rule isn't + } + } + + // Not logic, not array, not a === match for rule. + return false + } +} + +export default jsonLogic From 71098b88e6d58e23d8865f9f8473bdc435238dde Mon Sep 17 00:00:00 2001 From: Jesse Mitchell Date: Sun, 19 Jan 2025 18:17:56 -0600 Subject: [PATCH 2/2] Exclude the shim from coverage tests, for now. --- shim.js | 1 + 1 file changed, 1 insertion(+) diff --git a/shim.js b/shim.js index 8807553..a65c833 100644 --- a/shim.js +++ b/shim.js @@ -1,4 +1,5 @@ // @ts-check +// istanbul ignore file 'use strict' // Old Interface for json-logic-js being mapped onto json-logic-engine import LogicEngine from './logic.js'