TotalTechGeek / json-logic-engine

Construct complex rules with JSON & process them.
MIT License
43 stars 9 forks source link

[Feature Request] Get Function From Logic #3

Closed enchorb closed 1 year ago

enchorb commented 1 year ago

Hi,

Loving this library, been very helpful for our use case.

Am wondering though if it is possible to get the string output of a function to be run from the JSON logic?

Example:

const engine = new LogicEngine();
engine.addModule('', Math, { deterministic: true, sync: true });

    const logic = {
      abs: {
        '/': [
          {
            '+': [{ '*': [-1, { var: 'test' }] }, 2000]
          },
          20
        ]
      }
    }

// Maybe Something Like, Which Would Print --> abs(-test+2000 / 20)
console.log(engine.getFunction(logic));
TotalTechGeek commented 1 year ago

Hey @enchorb, I might take a look into this,

The main reason that it doesn't give you the raw JavaScript that it compiles down into is that in certain cases,

Could you potentially elaborate on your use case?

I might be able to offer a simpler solution based on your requirements.

enchorb commented 1 year ago

Hey @TotalTechGeek,

Thanks for the quick response. I'll give a brief overview of the use case:

1) Users from client send can create custom formulas (saved in a formulas table in a jsonb column) for converting raw data in DB tables

2) In the DB before the call to fetch the raw data was retrieve the users formula and then replace it in the select statement

This is why having the function representation would be useful, especially for arithmetic operations

Example (pseudocode)

const engine = new LogicEngine();
engine.addModule('', Math, { deterministic: true, sync: true });

const logic = db.from('formulas').select(query to get formula which is saved logic from earlier comment)

const formula = engine.getFunction(logic, { test: '(raw->'jsonProprtyName')::numeric' })

db.from('tableName').select(`${formula} AS value`)
TotalTechGeek commented 1 year ago

Hey, so I don't know when I'll get around to reworking the compiler, but it sounds like you're mostly just aiming for a expression representation, which is a bit simpler.

Would something like this satisfy your use case?

/**
 * This method takes JSON Logic and converts it to a reasonable expression that
 * represents the logic.
 * @param {*} json
 * @test { '*': [1, {'+': [5,3]}]} is '1 * (5 + 3)'
 * @test { '+': [1, 2, { '*': [5, 3] }] } is '1 + 2 + 5 * 3'
 * @test { '-': 1 } is '-1'
 * @test { '-': [1,2] } is '1 - 2'
 * @test { abs: { '/': [{ '+': [{ '*': [-1, { var: 'test' }] }, 2000] }, 20] } }
 *  is 'abs((-1 * test + 2000) / 20)'
 */
export function logicToExpression (json) {
  const levels = {
    '+': 1,
    '-': 1,
    '*': 2,
    '%': 2,
    '/': 2
  }
  if (!json) return JSON.stringify(json)
  if (typeof json === 'object') {
    const key = Object.keys(json)[0]
    const level = levels[key]
    const values = (Array.isArray(json[key]) ? json[key] : [json[key]]).map(i => {
      const result = logicToExpression(i)
      if (i && typeof i === 'object' && !Array.isArray(i)) {
        if (level > levels[Object.keys(i)[0]]) return `(${result})`
      }
      return result
    })
    if (values.length === 1 && key === '-') return `-${values[0]}`
    if (/^[+-/*^%]$/.test(key)) return `${values.join(` ${key} `)}`
    if (key === 'var') return values[0].substring(1, values[0].length - 1)
    return `${key}(${values.map(i => {
            if (i.startsWith('(') && i.endsWith(')')) return i.substring(1, i.length - 1)
            return i
        }).join(', ')})`
  }

  return JSON.stringify(json)
}
enchorb commented 1 year ago

Thanks a ton, this saved me so much time from having to write a custom traverser to extract a function! Would it be possible to extend this function about to replace variables with optional passed in values (as seen below). It wouldn't be too hard for me to write a secondary function that could do this but wondering if it non-trivial for you to add?

const logic = { abs: { '/': [{ '+': [{ '*': [-1, { var: 'test' }] }, 2000] }, 20] } }

console.log(logicToExpression(logic)) // abs((-1 * test + 2000) / 20)
console.log(logicToExpression(logic, { test: 20 } })) // abs((-1 * 20 + 2000) / 20)
console.log(logicToExpression(logic, { test: '(raw->'property')::numeric' } })) // abs((-1 * (raw->'property')::numeric + 2000) / 20)

Thanks again for this btw, satisfies our use case, myself (and my team) are excited to see the future of this library!

TotalTechGeek commented 1 year ago

You can modify the if block that overrides how variables are serialized to the expression.

/**
 * This method takes JSON Logic and converts it to a reasonable expression that
 * represents the logic.
 * @param {*} json
 * @test { '*': [1, {'+': [5,3]}]} is '1 * (5 + 3)'
 * @test { '+': [1, 2, { '*': [5, 3] }] } is '1 + 2 + 5 * 3'
 * @test { '-': 1 } is '-1'
 * @test { '-': [1,2] } is '1 - 2'
 * @test { abs: { '/': [{ '+': [{ '*': [-1, { var: 'test' }] }, 2000] }, 20] } }
 *  is 'abs((-1 * test + 2000) / 20)'
 * @test { abs: { '/': [{ '+': [{ '*': [-1, { var: 'test' }] }, 2000] }, 20] } }, { test: 50 }
 *  is 'abs((-1 * 50 + 2000) / 20)'
 */
export function logicToExpression (json, data) {
  const levels = {
    '+': 1,
    '-': 1,
    '*': 2,
    '%': 2,
    '/': 2
  }
  if (!json) return JSON.stringify(json)

  if (typeof json === 'object') {
    const key = Object.keys(json)[0]
    const level = levels[key]
    const values = (Array.isArray(json[key]) ? json[key] : [json[key]]).map(i => {
      const result = logicToExpression(i, data)
      if (i && typeof i === 'object' && !Array.isArray(i)) {
        if (level > levels[Object.keys(i)[0]]) return `(${result})`
      }
      return result
    })

    if (values.length === 1 && key === '-') return `-${values[0]}`

    if (/^[+-/*^%]$/.test(key)) return `${values.join(` ${key} `)}`

    if (key === 'var') {
      const variable = values[0].substring(1, values[0].length - 1)
      if (data && variable in data) return data[variable]
      return variable
    }

    return `${key}(${values.map(i => {
            if (i.startsWith('(') && i.endsWith(')')) return i.substring(1, i.length - 1)
            return i
        }).join(', ')})`
  }

  return JSON.stringify(json)
}
enchorb commented 1 year ago

This works perfectly, thanks once again!