Skip to content

Commit 06d0af9

Browse files
Merge pull request #37 from TotalTechGeek/fix/strictly-honor-sugaring
This set of changes allows JSON Logic engine to more strictly honor sugaring
2 parents 42b1643 + aa41707 commit 06d0af9

18 files changed

+147
-46
lines changed

asLogic.js

+1-1
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ function pick (keep, obj) {
2121
export function asLogicSync (functions, keep = ['var'], engine = new LogicEngine()) {
2222
engine.methods = pick(keep, engine.methods)
2323
engine.addMethod('list', i => [].concat(i))
24-
Object.keys(functions).forEach(i => engine.addMethod(i, data => Array.isArray(data) ? functions[i](...data) : functions[i](data === null ? undefined : data)))
24+
Object.keys(functions).forEach(i => engine.addMethod(i, data => functions[i](...data)))
2525
return engine.build.bind(engine)
2626
}
2727

asLogic.test.js

+1-1
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { asLogicSync, asLogicAsync } from './asLogic.js'
22

33
const module = {
4-
hello: (name = 'World', last = '') => `Hello, ${name}${last.length ? ' ' : ''}${last}!`
4+
hello: (name = 'World', last = '') => `Hello, ${name || 'World'}${last.length ? ' ' : ''}${last}!`
55
}
66

77
describe('asLogicSync', () => {

async.test.js

+3-3
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ const modes = [
66
]
77

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

1212
modes.forEach((logic) => {
@@ -676,7 +676,7 @@ modes.forEach((logic) => {
676676
length: ['hello']
677677
})
678678

679-
expect(answer).toStrictEqual(1)
679+
expect(answer).toStrictEqual(5)
680680
})
681681

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

782782
describe('addMethod', () => {
783783
test('adding a method works', async () => {
784-
logic.addMethod('+1', (item) => item + 1, { sync: true })
784+
logic.addMethod('+1', ([item]) => item + 1, { sync: true })
785785
expect(
786786
await logic.run({
787787
'+1': 7

asyncLogic.js

+9-7
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import { buildAsync } from './compiler.js'
99
import omitUndefined from './utilities/omitUndefined.js'
1010
import { optimize } from './async_optimizer.js'
1111
import { applyPatches } from './compatibility.js'
12+
import { coerceArray } from './utilities/coerceArray.js'
1213

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

7778
if (typeof this.methods[func] === 'function') {
78-
const input = await this.run(data, context, { above })
79-
const result = await this.methods[func](input, context, above, this)
79+
const input = (!data || typeof data !== 'object') ? [data] : await this.run(data, context, { above })
80+
const result = await this.methods[func](coerceArray(input), context, above, this)
8081
return Array.isArray(result) ? Promise.all(result) : result
8182
}
8283

8384
if (typeof this.methods[func] === 'object') {
8485
const { asyncMethod, method, traverse } = this.methods[func]
8586
const shouldTraverse = typeof traverse === 'undefined' ? true : traverse
86-
const parsedData = shouldTraverse ? await this.run(data, context, { above }) : data
87-
87+
const parsedData = shouldTraverse ? ((!data || typeof data !== 'object') ? [data] : coerceArray(await this.run(data, context, { above }))) : data
8888
const result = await (asyncMethod || method)(parsedData, context, above, this)
8989
return Array.isArray(result) ? Promise.all(result) : result
9090
}
@@ -96,12 +96,12 @@ class AsyncLogicEngine {
9696
*
9797
* @param {String} name The name of the method being added.
9898
* @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
99-
* @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.
99+
* @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.
100100
*/
101101
addMethod (
102102
name,
103103
method,
104-
{ deterministic, async, sync } = {}
104+
{ deterministic, async, sync, optimizeUnary } = {}
105105
) {
106106
if (typeof async === 'undefined' && typeof sync === 'undefined') sync = false
107107
if (typeof sync !== 'undefined') async = !sync
@@ -112,7 +112,7 @@ class AsyncLogicEngine {
112112
else method = { method, traverse: true }
113113
} else method = { ...method }
114114

115-
Object.assign(method, omitUndefined({ deterministic }))
115+
Object.assign(method, omitUndefined({ deterministic, optimizeUnary }))
116116
// @ts-ignore
117117
this.fallback.addMethod(name, method, { deterministic })
118118
this.methods[name] = declareSync(method, sync)
@@ -188,6 +188,8 @@ class AsyncLogicEngine {
188188
async build (logic, options = {}) {
189189
const { above = [], top = true } = options
190190
this.fallback.truthy = this.truthy
191+
// @ts-ignore
192+
this.fallback.allowFunctions = this.allowFunctions
191193
if (top) {
192194
const constructedFunction = await buildAsync(logic, { engine: this, above, async: true, state: {} })
193195

async_optimizer.js

+6-3
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { isDeterministic } from './compiler.js'
33
import { map } from './async_iterators.js'
44
import { isSync, Sync } from './constants.js'
55
import declareSync from './utilities/declareSync.js'
6+
import { coerceArray } from './utilities/coerceArray.js'
67

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

29-
const args = logic[methodName]
30+
let args = logic[methodName]
31+
if (!args || typeof args !== 'object') args = [args]
3032

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

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

5456
return async (data, abv) => {
55-
return called(typeof optimizedArgs === 'function' ? await optimizedArgs(data, abv) : optimizedArgs, data, abv || above, engine)
57+
return called(coerceArray(typeof optimizedArgs === 'function' ? await optimizedArgs(data, abv) : optimizedArgs, method.optimizeUnary), data, abv || above, engine)
5658
}
5759
}
5860
}
@@ -65,6 +67,7 @@ function getMethod (logic, engine, methodName, above) {
6567
* @returns A function that optimizes the logic for the engine in advance.
6668
*/
6769
export function optimize (logic, engine, above = []) {
70+
engine.fallback.allowFunctions = engine.allowFunctions
6871
if (Array.isArray(logic)) {
6972
const arr = logic.map(l => optimize(l, engine, above))
7073
if (isSync(arr)) return declareSync((data, abv) => arr.map(l => typeof l === 'function' ? l(data, abv) : l), true)

bench/incompatible.json

-1
This file was deleted.

build.test.js

+6-6
Original file line numberDiff line numberDiff line change
@@ -231,11 +231,11 @@ function timeout (n, x) {
231231
expect(await f({ x: 1, y: 2 })).toStrictEqual({ a: 1, b: 2 })
232232
})
233233

234-
test('Invalid eachKey', async () => {
235-
expect(async () => await logic.build({ eachKey: 5 })).rejects.toThrow(
236-
InvalidControlInput
237-
)
238-
})
234+
// test('Invalid eachKey', async () => {
235+
// expect(async () => await logic.build({ eachKey: 5 })).rejects.toThrow(
236+
// InvalidControlInput
237+
// )
238+
// })
239239

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

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

251251
describe('Testing async build with full async', () => {
252252
test('Async +1', async () => {

compatible.test.js

+7-1
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,12 @@
11
import fs from 'fs'
22
import { LogicEngine, AsyncLogicEngine } from './index.js'
3-
const tests = JSON.parse(fs.readFileSync('./bench/compatible.json').toString())
3+
const tests = []
4+
5+
// get all json files from "suites" directory
6+
const files = fs.readdirSync('./suites')
7+
for (const file of files) {
8+
if (file.endsWith('.json')) tests.push(...JSON.parse(fs.readFileSync(`./suites/${file}`).toString()).filter(i => typeof i !== 'string'))
9+
}
410

511
// eslint-disable-next-line no-labels
612
inline: {

compiler.js

+14-6
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import declareSync from './utilities/declareSync.js'
1010

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

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

195+
let lower = method[func]
196+
if (!lower || typeof lower !== 'object') lower = [lower]
197+
194198
if (engine.methods[func] && engine.methods[func].compile) {
195-
let str = engine.methods[func].compile(method[func], buildState)
199+
let str = engine.methods[func].compile(lower, buildState)
196200
if (str[Compiled]) str = str[Compiled]
197201

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

200204
if (str !== false) return str
201205
}
202206

207+
let coerce = engine.methods[func].optimizeUnary ? '' : 'coerceArray'
208+
if (!coerce && Array.isArray(lower) && lower.length === 1) lower = lower[0]
209+
else if (coerce && Array.isArray(lower)) coerce = ''
210+
203211
if (typeof engine.methods[func] === 'function') {
204212
asyncDetected = !isSync(engine.methods[func])
205-
return makeAsync(`engine.methods["${func}"](` + buildString(method[func], buildState) + ', context, above, engine)')
213+
return makeAsync(`engine.methods["${func}"](${coerce}(` + buildString(lower, buildState) + '), context, above, engine)')
206214
} else {
207215
if (engine.methods[func] && (typeof engine.methods[func].traverse === 'undefined' ? true : engine.methods[func].traverse)) {
208216
asyncDetected = Boolean(async && engine.methods[func] && engine.methods[func].asyncMethod)
209-
return makeAsync(`engine.methods["${func}"]${asyncDetected ? '.asyncMethod' : '.method'}(` + buildString(method[func], buildState) + ', context, above, engine)')
217+
return makeAsync(`engine.methods["${func}"]${asyncDetected ? '.asyncMethod' : '.method'}(${coerce}(` + buildString(lower, buildState) + '), context, above, engine)')
210218
} else {
211219
asyncDetected = Boolean(async && engine.methods[func] && engine.methods[func].asyncMethod)
212-
notTraversed.push(method[func])
220+
notTraversed.push(lower)
213221
return makeAsync(`engine.methods["${func}"]${asyncDetected ? '.asyncMethod' : '.method'}(` + `notTraversed[${notTraversed.length - 1}]` + ', context, above, engine)')
214222
}
215223
}
@@ -294,12 +302,12 @@ function processBuiltString (method, str, buildState) {
294302
str = str.replace(`__%%%${x}%%%__`, item)
295303
})
296304

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

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

305313
export { build }

defaultMethods.js

+7-4
Original file line numberDiff line numberDiff line change
@@ -178,7 +178,7 @@ const defaultMethods = {
178178
}
179179
return string.substr(from, end)
180180
},
181-
length: (i) => {
181+
length: ([i]) => {
182182
if (typeof i === 'string' || Array.isArray(i)) return i.length
183183
if (i && typeof i === 'object') return Object.keys(i).length
184184
return 0
@@ -398,7 +398,7 @@ const defaultMethods = {
398398
for (let i = 0; i < arr.length; i++) res += arr[i]
399399
return res
400400
},
401-
keys: (obj) => typeof obj === 'object' ? Object.keys(obj) : [],
401+
keys: ([obj]) => typeof obj === 'object' ? Object.keys(obj) : [],
402402
pipe: {
403403
traverse: false,
404404
[Sync]: (data, buildState) => isSyncDeep(data, buildState.engine, buildState),
@@ -799,13 +799,13 @@ defaultMethods.var.compile = function (data, buildState) {
799799
typeof data === 'number' ||
800800
(Array.isArray(data) && data.length <= 2)
801801
) {
802-
if (data === '../index' && buildState.iteratorCompile) return 'index'
803-
804802
if (Array.isArray(data)) {
805803
key = data[0]
806804
defaultValue = typeof data[1] === 'undefined' ? null : data[1]
807805
}
808806

807+
if (key === '../index' && buildState.iteratorCompile) return 'index'
808+
809809
// this counts the number of var accesses to determine if they're all just using this override.
810810
// this allows for a small optimization :)
811811
if (typeof key === 'undefined' || key === null || key === '') return 'context'
@@ -835,6 +835,9 @@ defaultMethods.var.compile = function (data, buildState) {
835835
return false
836836
}
837837

838+
// @ts-ignore Allowing a optimizeUnary attribute that can be used for performance optimizations
839+
defaultMethods['+'].optimizeUnary = defaultMethods['-'].optimizeUnary = defaultMethods.var.optimizeUnary = defaultMethods['!'].optimizeUnary = defaultMethods['!!'].optimizeUnary = defaultMethods.cat.optimizeUnary = true
840+
838841
export default {
839842
...defaultMethods
840843
}

general.test.js

+7-3
Original file line numberDiff line numberDiff line change
@@ -138,8 +138,8 @@ describe('Various Test Cases', () => {
138138
try {
139139
for (const engine of [...normalEngines, ...permissiveEngines]) {
140140
engine.allowFunctions = true
141-
engine.addMethod('typeof', (value) => typeof value)
142-
await testEngine(engine, { typeof: { var: 'toString' } }, 'hello', 'function')
141+
engine.addMethod('typeof', ([value]) => typeof value)
142+
await testEngine(engine, { typeof: { var: 'toString' } }, {}, 'function')
143143
}
144144
} finally {
145145
for (const engine of [...normalEngines, ...permissiveEngines]) {
@@ -149,6 +149,10 @@ describe('Various Test Cases', () => {
149149
}
150150
})
151151

152+
it('is able to handle max with 1 element', async () => {
153+
for (const engine of [...normalEngines, ...permissiveEngines]) await testEngine(engine, { max: 5 }, {}, 5)
154+
})
155+
152156
it('is able to handle path escaping in a var call', async () => {
153157
for (const engine of [...normalEngines, ...permissiveEngines]) await testEngine(engine, { var: 'hello\\.world' }, { 'hello.world': 2 }, 2)
154158
})
@@ -179,7 +183,7 @@ describe('Various Test Cases', () => {
179183
for (const engine of [...normalEngines, ...permissiveEngines]) await testEngine(engine, { pipe: [{ var: 'name' }, { cat: ['Hello, ', { var: '' }, '!'] }] }, { name: 'Austin' }, 'Hello, Austin!')
180184

181185
for (const engine of [normalEngines[1], normalEngines[3], permissiveEngines[1], permissiveEngines[3]]) {
182-
engine.addMethod('as1', async (n) => n + 1, { async: true, deterministic: true })
186+
engine.addMethod('as1', async ([n]) => n + 1, { async: true, deterministic: true })
183187
await testEngine(engine, {
184188
pipe: [
185189
'Austin',

logic.js

+6-5
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import declareSync from './utilities/declareSync.js'
88
import omitUndefined from './utilities/omitUndefined.js'
99
import { optimize } from './optimizer.js'
1010
import { applyPatches } from './compatibility.js'
11+
import { coerceArray } from './utilities/coerceArray.js'
1112

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

7374
if (typeof this.methods[func] === 'function') {
74-
const input = this.run(data, context, { above })
75+
const input = (!data || typeof data !== 'object') ? [data] : coerceArray(this.run(data, context, { above }))
7576
return this.methods[func](input, context, above, this)
7677
}
7778

7879
if (typeof this.methods[func] === 'object') {
7980
const { method, traverse } = this.methods[func]
8081
const shouldTraverse = typeof traverse === 'undefined' ? true : traverse
81-
const parsedData = shouldTraverse ? this.run(data, context, { above }) : data
82+
const parsedData = shouldTraverse ? ((!data || typeof data !== 'object') ? [data] : coerceArray(this.run(data, context, { above }))) : data
8283
return method(parsedData, context, above, this)
8384
}
8485

@@ -89,12 +90,12 @@ class LogicEngine {
8990
*
9091
* @param {String} name The name of the method being added.
9192
* @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
92-
* @param {{ deterministic?: Boolean }} annotations This is used by the compiler to help determine if it can optimize the function being generated.
93+
* @param {{ deterministic?: Boolean, optimizeUnary?: Boolean }} annotations This is used by the compiler to help determine if it can optimize the function being generated.
9394
*/
94-
addMethod (name, method, { deterministic } = {}) {
95+
addMethod (name, method, { deterministic, optimizeUnary } = {}) {
9596
if (typeof method === 'function') method = { method, traverse: true }
9697
else method = { ...method }
97-
Object.assign(method, omitUndefined({ deterministic }))
98+
Object.assign(method, omitUndefined({ deterministic, optimizeUnary }))
9899
this.methods[name] = declareSync(method)
99100
}
100101

optimizer.js

+4-2
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
// This is the synchronous version of the optimizer; which the Async one should be based on.
22
import { isDeterministic } from './compiler.js'
3+
import { coerceArray } from './utilities/coerceArray.js'
34

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

21-
const args = logic[methodName]
22+
let args = logic[methodName]
23+
if (!args || typeof args !== 'object') args = [args]
2224

2325
if (Array.isArray(args)) {
2426
const optimizedArgs = args.map(l => optimize(l, engine, above))
@@ -29,7 +31,7 @@ function getMethod (logic, engine, methodName, above) {
2931
} else {
3032
const optimizedArgs = optimize(args, engine, above)
3133
return (data, abv) => {
32-
return called(typeof optimizedArgs === 'function' ? optimizedArgs(data, abv) : optimizedArgs, data, abv || above, engine)
34+
return called(coerceArray(typeof optimizedArgs === 'function' ? optimizedArgs(data, abv) : optimizedArgs, method.optimizeUnary), data, abv || above, engine)
3335
}
3436
}
3537
}

package.json

+1-1
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "json-logic-engine",
3-
"version": "3.0.5",
3+
"version": "4.0.0",
44
"description": "Construct complex rules with JSON & process them.",
55
"main": "./dist/cjs/index.js",
66
"module": "./dist/esm/index.js",

0 commit comments

Comments
 (0)