Skip to content

This set of changes allows JSON Logic engine to more strictly honor s… #37

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 2 commits into from
Dec 5, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion asLogic.js
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ function pick (keep, obj) {
export function asLogicSync (functions, keep = ['var'], engine = new LogicEngine()) {
engine.methods = pick(keep, engine.methods)
engine.addMethod('list', i => [].concat(i))
Object.keys(functions).forEach(i => engine.addMethod(i, data => Array.isArray(data) ? functions[i](...data) : functions[i](data === null ? undefined : data)))
Object.keys(functions).forEach(i => engine.addMethod(i, data => functions[i](...data)))
return engine.build.bind(engine)
}

Expand Down
2 changes: 1 addition & 1 deletion asLogic.test.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { asLogicSync, asLogicAsync } from './asLogic.js'

const module = {
hello: (name = 'World', last = '') => `Hello, ${name}${last.length ? ' ' : ''}${last}!`
hello: (name = 'World', last = '') => `Hello, ${name || 'World'}${last.length ? ' ' : ''}${last}!`
}

describe('asLogicSync', () => {
Expand Down
6 changes: 3 additions & 3 deletions async.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ const modes = [
]

for (const engine of modes) {
engine.addMethod('as1', async (n) => n + 1, { async: true, deterministic: true })
engine.addMethod('as1', async ([n]) => n + 1, { async: true, deterministic: true })
}

modes.forEach((logic) => {
Expand Down Expand Up @@ -676,7 +676,7 @@ modes.forEach((logic) => {
length: ['hello']
})

expect(answer).toStrictEqual(1)
expect(answer).toStrictEqual(5)
})

test('length object (2 keys)', async () => {
Expand Down Expand Up @@ -781,7 +781,7 @@ modes.forEach((logic) => {

describe('addMethod', () => {
test('adding a method works', async () => {
logic.addMethod('+1', (item) => item + 1, { sync: true })
logic.addMethod('+1', ([item]) => item + 1, { sync: true })
expect(
await logic.run({
'+1': 7
Expand Down
16 changes: 9 additions & 7 deletions asyncLogic.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { buildAsync } from './compiler.js'
import omitUndefined from './utilities/omitUndefined.js'
import { optimize } from './async_optimizer.js'
import { applyPatches } from './compatibility.js'
import { coerceArray } from './utilities/coerceArray.js'

/**
* An engine capable of running asynchronous JSON Logic.
Expand Down Expand Up @@ -75,16 +76,15 @@ class AsyncLogicEngine {
if (!this.methods[func]) throw new Error(`Method '${func}' was not found in the Logic Engine.`)

if (typeof this.methods[func] === 'function') {
const input = await this.run(data, context, { above })
const result = await this.methods[func](input, context, above, this)
const input = (!data || typeof data !== 'object') ? [data] : await this.run(data, context, { above })
const result = await this.methods[func](coerceArray(input), context, above, this)
return Array.isArray(result) ? Promise.all(result) : result
}

if (typeof this.methods[func] === 'object') {
const { asyncMethod, method, traverse } = this.methods[func]
const shouldTraverse = typeof traverse === 'undefined' ? true : traverse
const parsedData = shouldTraverse ? await this.run(data, context, { above }) : data

const parsedData = shouldTraverse ? ((!data || typeof data !== 'object') ? [data] : coerceArray(await this.run(data, context, { above }))) : data
const result = await (asyncMethod || method)(parsedData, context, above, this)
return Array.isArray(result) ? Promise.all(result) : result
}
Expand All @@ -96,12 +96,12 @@ class AsyncLogicEngine {
*
* @param {String} name The name of the method being added.
* @param {((args: any, context: any, above: any[], engine: AsyncLogicEngine) => any) | { traverse?: Boolean, method?: (args: any, context: any, above: any[], engine: AsyncLogicEngine) => any, asyncMethod?: (args: any, context: any, above: any[], engine: AsyncLogicEngine) => Promise<any>, deterministic?: Function | Boolean }} method
* @param {{ deterministic?: Boolean, async?: Boolean, sync?: Boolean }} annotations This is used by the compiler to help determine if it can optimize the function being generated.
* @param {{ deterministic?: Boolean, async?: Boolean, sync?: Boolean, optimizeUnary?: boolean }} annotations This is used by the compiler to help determine if it can optimize the function being generated.
*/
addMethod (
name,
method,
{ deterministic, async, sync } = {}
{ deterministic, async, sync, optimizeUnary } = {}
) {
if (typeof async === 'undefined' && typeof sync === 'undefined') sync = false
if (typeof sync !== 'undefined') async = !sync
Expand All @@ -112,7 +112,7 @@ class AsyncLogicEngine {
else method = { method, traverse: true }
} else method = { ...method }

Object.assign(method, omitUndefined({ deterministic }))
Object.assign(method, omitUndefined({ deterministic, optimizeUnary }))
// @ts-ignore
this.fallback.addMethod(name, method, { deterministic })
this.methods[name] = declareSync(method, sync)
Expand Down Expand Up @@ -188,6 +188,8 @@ class AsyncLogicEngine {
async build (logic, options = {}) {
const { above = [], top = true } = options
this.fallback.truthy = this.truthy
// @ts-ignore
this.fallback.allowFunctions = this.allowFunctions
if (top) {
const constructedFunction = await buildAsync(logic, { engine: this, above, async: true, state: {} })

Expand Down
9 changes: 6 additions & 3 deletions async_optimizer.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { isDeterministic } from './compiler.js'
import { map } from './async_iterators.js'
import { isSync, Sync } from './constants.js'
import declareSync from './utilities/declareSync.js'
import { coerceArray } from './utilities/coerceArray.js'

/**
* Turns an expression like { '+': [1, 2] } into a function that can be called with data.
Expand All @@ -26,7 +27,8 @@ function getMethod (logic, engine, methodName, above) {
return (data, abv) => called(args, data, abv || above, engine)
}

const args = logic[methodName]
let args = logic[methodName]
if (!args || typeof args !== 'object') args = [args]

if (Array.isArray(args)) {
const optimizedArgs = args.map(l => optimize(l, engine, above))
Expand All @@ -48,11 +50,11 @@ function getMethod (logic, engine, methodName, above) {

if (isSync(optimizedArgs) && (method.method || method[Sync])) {
const called = method.method ? method.method : method
return declareSync((data, abv) => called(typeof optimizedArgs === 'function' ? optimizedArgs(data, abv) : optimizedArgs, data, abv || above, engine), true)
return declareSync((data, abv) => called(coerceArray(typeof optimizedArgs === 'function' ? optimizedArgs(data, abv) : optimizedArgs, method.optimizeUnary), data, abv || above, engine), true)
}

return async (data, abv) => {
return called(typeof optimizedArgs === 'function' ? await optimizedArgs(data, abv) : optimizedArgs, data, abv || above, engine)
return called(coerceArray(typeof optimizedArgs === 'function' ? await optimizedArgs(data, abv) : optimizedArgs, method.optimizeUnary), data, abv || above, engine)
}
}
}
Expand All @@ -65,6 +67,7 @@ function getMethod (logic, engine, methodName, above) {
* @returns A function that optimizes the logic for the engine in advance.
*/
export function optimize (logic, engine, above = []) {
engine.fallback.allowFunctions = engine.allowFunctions
if (Array.isArray(logic)) {
const arr = logic.map(l => optimize(l, engine, above))
if (isSync(arr)) return declareSync((data, abv) => arr.map(l => typeof l === 'function' ? l(data, abv) : l), true)
Expand Down
1 change: 0 additions & 1 deletion bench/incompatible.json

This file was deleted.

12 changes: 6 additions & 6 deletions build.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -231,11 +231,11 @@ function timeout (n, x) {
expect(await f({ x: 1, y: 2 })).toStrictEqual({ a: 1, b: 2 })
})

test('Invalid eachKey', async () => {
expect(async () => await logic.build({ eachKey: 5 })).rejects.toThrow(
InvalidControlInput
)
})
// test('Invalid eachKey', async () => {
// expect(async () => await logic.build({ eachKey: 5 })).rejects.toThrow(
// InvalidControlInput
// )
// })

test('Simple deterministic eachKey', async () => {
const f = await logic.build({ eachKey: { a: 1, b: { '+': [1, 1] } } })
Expand All @@ -246,7 +246,7 @@ function timeout (n, x) {
})

const logic = new AsyncLogicEngine()
logic.addMethod('as1', async (n) => timeout(100, n + 1), { async: true })
logic.addMethod('as1', async ([n]) => timeout(100, n + 1), { async: true })

describe('Testing async build with full async', () => {
test('Async +1', async () => {
Expand Down
8 changes: 7 additions & 1 deletion compatible.test.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,12 @@
import fs from 'fs'
import { LogicEngine, AsyncLogicEngine } from './index.js'
const tests = JSON.parse(fs.readFileSync('./bench/compatible.json').toString())
const tests = []

// get all json files from "suites" directory
const files = fs.readdirSync('./suites')
for (const file of files) {
if (file.endsWith('.json')) tests.push(...JSON.parse(fs.readFileSync(`./suites/${file}`).toString()).filter(i => typeof i !== 'string'))
}

// eslint-disable-next-line no-labels
inline: {
Expand Down
20 changes: 14 additions & 6 deletions compiler.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import declareSync from './utilities/declareSync.js'

// asyncIterators is required for the compiler to operate as intended.
import asyncIterators from './async_iterators.js'
import { coerceArray } from './utilities/coerceArray.js'

/**
* Provides a simple way to compile logic into a function that can be run.
Expand Down Expand Up @@ -191,25 +192,32 @@ function buildString (method, buildState = {}) {
}
}

let lower = method[func]
if (!lower || typeof lower !== 'object') lower = [lower]

if (engine.methods[func] && engine.methods[func].compile) {
let str = engine.methods[func].compile(method[func], buildState)
let str = engine.methods[func].compile(lower, buildState)
if (str[Compiled]) str = str[Compiled]

if ((str || '').startsWith('await')) buildState.asyncDetected = true

if (str !== false) return str
}

let coerce = engine.methods[func].optimizeUnary ? '' : 'coerceArray'
if (!coerce && Array.isArray(lower) && lower.length === 1) lower = lower[0]
else if (coerce && Array.isArray(lower)) coerce = ''

if (typeof engine.methods[func] === 'function') {
asyncDetected = !isSync(engine.methods[func])
return makeAsync(`engine.methods["${func}"](` + buildString(method[func], buildState) + ', context, above, engine)')
return makeAsync(`engine.methods["${func}"](${coerce}(` + buildString(lower, buildState) + '), context, above, engine)')
} else {
if (engine.methods[func] && (typeof engine.methods[func].traverse === 'undefined' ? true : engine.methods[func].traverse)) {
asyncDetected = Boolean(async && engine.methods[func] && engine.methods[func].asyncMethod)
return makeAsync(`engine.methods["${func}"]${asyncDetected ? '.asyncMethod' : '.method'}(` + buildString(method[func], buildState) + ', context, above, engine)')
return makeAsync(`engine.methods["${func}"]${asyncDetected ? '.asyncMethod' : '.method'}(${coerce}(` + buildString(lower, buildState) + '), context, above, engine)')
} else {
asyncDetected = Boolean(async && engine.methods[func] && engine.methods[func].asyncMethod)
notTraversed.push(method[func])
notTraversed.push(lower)
return makeAsync(`engine.methods["${func}"]${asyncDetected ? '.asyncMethod' : '.method'}(` + `notTraversed[${notTraversed.length - 1}]` + ', context, above, engine)')
}
}
Expand Down Expand Up @@ -294,12 +302,12 @@ function processBuiltString (method, str, buildState) {
str = str.replace(`__%%%${x}%%%__`, item)
})

const final = `(values, methods, notTraversed, asyncIterators, engine, above) => ${buildState.asyncDetected ? 'async' : ''} (context ${buildState.extraArguments ? ',' + buildState.extraArguments : ''}) => { const result = ${str}; return result }`
const final = `(values, methods, notTraversed, asyncIterators, engine, above, coerceArray) => ${buildState.asyncDetected ? 'async' : ''} (context ${buildState.extraArguments ? ',' + buildState.extraArguments : ''}) => { const result = ${str}; return result }`

// console.log(str)
// console.log(final)
// eslint-disable-next-line no-eval
return declareSync((typeof globalThis !== 'undefined' ? globalThis : global).eval(final)(values, methods, notTraversed, asyncIterators, engine, above), !buildState.asyncDetected)
return declareSync((typeof globalThis !== 'undefined' ? globalThis : global).eval(final)(values, methods, notTraversed, asyncIterators, engine, above, coerceArray), !buildState.asyncDetected)
}

export { build }
Expand Down
11 changes: 7 additions & 4 deletions defaultMethods.js
Original file line number Diff line number Diff line change
Expand Up @@ -178,7 +178,7 @@ const defaultMethods = {
}
return string.substr(from, end)
},
length: (i) => {
length: ([i]) => {
if (typeof i === 'string' || Array.isArray(i)) return i.length
if (i && typeof i === 'object') return Object.keys(i).length
return 0
Expand Down Expand Up @@ -398,7 +398,7 @@ const defaultMethods = {
for (let i = 0; i < arr.length; i++) res += arr[i]
return res
},
keys: (obj) => typeof obj === 'object' ? Object.keys(obj) : [],
keys: ([obj]) => typeof obj === 'object' ? Object.keys(obj) : [],
pipe: {
traverse: false,
[Sync]: (data, buildState) => isSyncDeep(data, buildState.engine, buildState),
Expand Down Expand Up @@ -799,13 +799,13 @@ defaultMethods.var.compile = function (data, buildState) {
typeof data === 'number' ||
(Array.isArray(data) && data.length <= 2)
) {
if (data === '../index' && buildState.iteratorCompile) return 'index'

if (Array.isArray(data)) {
key = data[0]
defaultValue = typeof data[1] === 'undefined' ? null : data[1]
}

if (key === '../index' && buildState.iteratorCompile) return 'index'

// this counts the number of var accesses to determine if they're all just using this override.
// this allows for a small optimization :)
if (typeof key === 'undefined' || key === null || key === '') return 'context'
Expand Down Expand Up @@ -835,6 +835,9 @@ defaultMethods.var.compile = function (data, buildState) {
return false
}

// @ts-ignore Allowing a optimizeUnary attribute that can be used for performance optimizations
defaultMethods['+'].optimizeUnary = defaultMethods['-'].optimizeUnary = defaultMethods.var.optimizeUnary = defaultMethods['!'].optimizeUnary = defaultMethods['!!'].optimizeUnary = defaultMethods.cat.optimizeUnary = true

export default {
...defaultMethods
}
10 changes: 7 additions & 3 deletions general.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -138,8 +138,8 @@ describe('Various Test Cases', () => {
try {
for (const engine of [...normalEngines, ...permissiveEngines]) {
engine.allowFunctions = true
engine.addMethod('typeof', (value) => typeof value)
await testEngine(engine, { typeof: { var: 'toString' } }, 'hello', 'function')
engine.addMethod('typeof', ([value]) => typeof value)
await testEngine(engine, { typeof: { var: 'toString' } }, {}, 'function')
}
} finally {
for (const engine of [...normalEngines, ...permissiveEngines]) {
Expand All @@ -149,6 +149,10 @@ describe('Various Test Cases', () => {
}
})

it('is able to handle max with 1 element', async () => {
for (const engine of [...normalEngines, ...permissiveEngines]) await testEngine(engine, { max: 5 }, {}, 5)
})

it('is able to handle path escaping in a var call', async () => {
for (const engine of [...normalEngines, ...permissiveEngines]) await testEngine(engine, { var: 'hello\\.world' }, { 'hello.world': 2 }, 2)
})
Expand Down Expand Up @@ -179,7 +183,7 @@ describe('Various Test Cases', () => {
for (const engine of [...normalEngines, ...permissiveEngines]) await testEngine(engine, { pipe: [{ var: 'name' }, { cat: ['Hello, ', { var: '' }, '!'] }] }, { name: 'Austin' }, 'Hello, Austin!')

for (const engine of [normalEngines[1], normalEngines[3], permissiveEngines[1], permissiveEngines[3]]) {
engine.addMethod('as1', async (n) => n + 1, { async: true, deterministic: true })
engine.addMethod('as1', async ([n]) => n + 1, { async: true, deterministic: true })
await testEngine(engine, {
pipe: [
'Austin',
Expand Down
11 changes: 6 additions & 5 deletions logic.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import declareSync from './utilities/declareSync.js'
import omitUndefined from './utilities/omitUndefined.js'
import { optimize } from './optimizer.js'
import { applyPatches } from './compatibility.js'
import { coerceArray } from './utilities/coerceArray.js'

/**
* An engine capable of running synchronous JSON Logic.
Expand Down Expand Up @@ -71,14 +72,14 @@ class LogicEngine {
if (!this.methods[func]) throw new Error(`Method '${func}' was not found in the Logic Engine.`)

if (typeof this.methods[func] === 'function') {
const input = this.run(data, context, { above })
const input = (!data || typeof data !== 'object') ? [data] : coerceArray(this.run(data, context, { above }))
return this.methods[func](input, context, above, this)
}

if (typeof this.methods[func] === 'object') {
const { method, traverse } = this.methods[func]
const shouldTraverse = typeof traverse === 'undefined' ? true : traverse
const parsedData = shouldTraverse ? this.run(data, context, { above }) : data
const parsedData = shouldTraverse ? ((!data || typeof data !== 'object') ? [data] : coerceArray(this.run(data, context, { above }))) : data
return method(parsedData, context, above, this)
}

Expand All @@ -89,12 +90,12 @@ class LogicEngine {
*
* @param {String} name The name of the method being added.
* @param {((args: any, context: any, above: any[], engine: LogicEngine) => any) |{ traverse?: Boolean, method: (args: any, context: any, above: any[], engine: LogicEngine) => any, deterministic?: Function | Boolean }} method
* @param {{ deterministic?: Boolean }} annotations This is used by the compiler to help determine if it can optimize the function being generated.
* @param {{ deterministic?: Boolean, optimizeUnary?: Boolean }} annotations This is used by the compiler to help determine if it can optimize the function being generated.
*/
addMethod (name, method, { deterministic } = {}) {
addMethod (name, method, { deterministic, optimizeUnary } = {}) {
if (typeof method === 'function') method = { method, traverse: true }
else method = { ...method }
Object.assign(method, omitUndefined({ deterministic }))
Object.assign(method, omitUndefined({ deterministic, optimizeUnary }))
this.methods[name] = declareSync(method)
}

Expand Down
6 changes: 4 additions & 2 deletions optimizer.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
// This is the synchronous version of the optimizer; which the Async one should be based on.
import { isDeterministic } from './compiler.js'
import { coerceArray } from './utilities/coerceArray.js'

/**
* Turns an expression like { '+': [1, 2] } into a function that can be called with data.
Expand All @@ -18,7 +19,8 @@ function getMethod (logic, engine, methodName, above) {
return (data, abv) => called(args, data, abv || above, engine)
}

const args = logic[methodName]
let args = logic[methodName]
if (!args || typeof args !== 'object') args = [args]

if (Array.isArray(args)) {
const optimizedArgs = args.map(l => optimize(l, engine, above))
Expand All @@ -29,7 +31,7 @@ function getMethod (logic, engine, methodName, above) {
} else {
const optimizedArgs = optimize(args, engine, above)
return (data, abv) => {
return called(typeof optimizedArgs === 'function' ? optimizedArgs(data, abv) : optimizedArgs, data, abv || above, engine)
return called(coerceArray(typeof optimizedArgs === 'function' ? optimizedArgs(data, abv) : optimizedArgs, method.optimizeUnary), data, abv || above, engine)
}
}
}
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "json-logic-engine",
"version": "3.0.5",
"version": "4.0.0",
"description": "Construct complex rules with JSON & process them.",
"main": "./dist/cjs/index.js",
"module": "./dist/esm/index.js",
Expand Down
Loading
Loading