How to parse and run conditions contained in strings such as "condition1 and condition2"?

Viewed 74

I have a JSON file from an external source containing a bunch of conditions I'd like to test. Either in realtime, or by somehow converting everything.

Let's say I have an instance of my class Person, containing {age: 13, country: "Norway"}, and that I have an external JSON file containing the following "helpers":

{
    "is_child": "age < 16",
    "is_adult": "age >= 16 and age < 67",
    "is_senior": "age > 67",
    "is_scandinavian": "country == 'Norway' or country == 'Sweden' or country == 'Denmark'",
}

and another file containing, for example, tickets I'd like to present, for example, "NorwegianTickets.json"

{
    "childTicket": "is_child and is_scandinavian",
    "flexTicket": "is_scandinavian and (is_adult or is_senior)"
}

How can I apply this logic to my code? If I want to run the condition "flexTicket" on my "Person", how should I map all the logic? How do I translate the "stringed" conditions, such as "and"/"or", and "()"?

2 Answers

You can easily achieve this using the eval function that execute a string as javascript.

So the logic will be:

  1. Get the different conditions as a javascript string (is_child, is_adult, ...)

This function replace all variables (written as a string) by there value. For that you will need to create a dictionary to list them all with the corresponding value:

const varsToReplace = {
  country: 'Norway',
  age: 12
}

Then you replace this variable in a given condition using the replace method. The only trick here is that you need to search for ‎‎‏‏‎ ‎‎country‏‏‎ ‎ and not country (if you not add the extra space before and after, a variable like user_country could be replaced by ‏‏‎user_Norway). Also keep in mind that if you replace by a string you should wrapp the value in '':

const getConditionString = (condition) => {
  let replacedConditon = ` ${conditions[condition]} `

  Object.keys(varsToReplace).forEach((variable) => {
    const re = new RegExp(` ${variable} `, 'g');

    let replaceValue = ` ${varsToReplace[variable]} `

    // If the value is a string we should add ''
    if (typeof varsToReplace[variable] === 'string') {
      replaceValue = ` '${varsToReplace[variable]}' `
    }

    replacedConditon = replacedConditon.replace(re, replaceValue)
  })

  return replacedConditon
}

  1. Get the test as a javascript string (is_child and is_scandinavian, ...)

This function getTestString will replace all conditions key by the javascript string using the previous function:

const getTestString = (test) => {
  let replacedTest = ` ${tests[test]} `

  Object.keys(conditions).forEach((condition) => {
    const re = new RegExp(` ${condition} `, 'g');

    replacedTest = replacedTest.replace(re, ` ( ${getConditionString(condition)} ) `)
  })

  return replacedTest
}

  1. Replace the different operators to be 'js valid':
const replaceOperators = (string) => {
  const operators = {
    or: '||',
    and: '&&'
  }

  Object.keys(operators).forEach((operator) => {
    const re = new RegExp(` ${operator} `, 'g');

    string = string.replace(re, ` ${operators[operator]} `)
  })

  return string
}

  1. Execute the js string using eval:
const evalTest = (test) => {
  let testAsString = replaceOperators(getTestString(test))

  return eval(testAsString)
}

Here is the full example:

const country = 'Norway'
const age = 12

const varsToReplace = {
  country,
  age
}

const conditions = {
  "is_child": "age < 16",
  "is_adult": "age >= 16 and age < 67",
  "is_senior": "age > 67",
  "is_scandinavian": "country == 'Norway' or country == 'Sweden' or country == 'Denmark'"
}

const tests = {
  "childTicket": "is_child and is_scandinavian",
  "flexTicket": "is_scandinavian and ( is_adult or is_senior )"
}


const getConditionString = (condition) => {
  let replacedConditon = ` ${conditions[condition]} `

  Object.keys(varsToReplace).forEach((variable) => {
    const re = new RegExp(` ${variable} `, 'g');

    let replaceValue = ` ${varsToReplace[variable]} `

    // If the value is a string we should add ''
    if (typeof varsToReplace[variable] === 'string') {
      replaceValue = ` '${varsToReplace[variable]}' `
    }

    replacedConditon = replacedConditon.replace(re, replaceValue)
  })

  return replacedConditon
}

const getTestString = (test) => {
  let replacedTest = ` ${tests[test]} `

  Object.keys(conditions).forEach((condition) => {
    const re = new RegExp(` ${condition} `, 'g');

    replacedTest = replacedTest.replace(re, ` ( ${getConditionString(condition)} ) `)
  })

  return replacedTest
}

const replaceOperators = (string) => {
  const operators = {
    or: '||',
    and: '&&'
  }

  Object.keys(operators).forEach((operator) => {
    const re = new RegExp(` ${operator} `, 'g');

    string = string.replace(re, ` ${operators[operator]} `)
  })

  return string
}

const evalTest = (test) => {
  let testAsString = replaceOperators(getTestString(test))

  console.log(testAsString)

  return eval(testAsString)
}

console.log(evalTest('childTicket'))
console.log(evalTest('flexTicket'))

I would go for creating a DSL for that purpose. It's fun. I've written one to just give you some idea about it. Beware, its not fully tested, lacks basic functionality such as array access. I believe you may find better examples in the internet.

class Node_ {
    children: Node_[];

    constructor() {
        this.children = [];
    }

    addChild = (node: Node_) =>
        this.children.push(node);

    evaluate = (context: any): boolean | number | string => {
        throw new Error('Missing implementation');
    }
}

enum ExprType {
    Eq = 'eq',
    Gt = 'gt',
    Lt = 'lt',
    Gte = 'gte',
    Lte = 'lte',
    Get = 'get',
}

class ExprNode extends Node_ {
    expr: string;

    constructor(expr: string) {
        super();
        this.throwIfInvalidExpr(expr);
        this.expr = expr.toLowerCase();
    }

    throwIfInvalidExpr(expr: string) {
        switch (expr.toLowerCase()) {
            case ExprType.Eq:
            case ExprType.Gt:
            case ExprType.Lt:
            case ExprType.Gte:
            case ExprType.Lte:
            case ExprType.Get:
                break;
            default:
                throw new Error(`Unexpected expression: ${this.expr}`);
        }
    }

    evaluate = (context: any) => {
        switch (this.expr) {
            case ExprType.Get:
                return this.evaluateAccess(context);
            default:
                return this.evaluateCmp(context);
        }
    }

    evaluateAccess = (context: any) => {
        this.throwIfInvalidAccessOperands();

        const prop = this.children[0].evaluate(context) as string;
        const newContext = context[prop];
        const child = this.children[1];
        if (child) {
            return child.evaluate(newContext);
        } else {
            return newContext;
        }
    }

    evaluateCmp = (context: any) => {
        this.throwIfInvalidOperands();
        const left = this.children[0].evaluate(context);
        const right = this.children[1].evaluate(context);

        switch(this.expr) {
            case ExprType.Eq:
                return left === right;
            case ExprType.Gt:
                return left > right;
            case ExprType.Gte:
                return left >= right;
            case ExprType.Lt:
                return left < right;
            case ExprType.Lte:
                return left <= right;
        }
    }

    throwIfInvalidOperands = () => {
        if (this.children.length !== 2) {
            throw new Error(`Invalid operand count ${this.children.length}`);
        }
    }

    throwIfInvalidAccessOperands = () => {
        if (this.children.length === 0 ||
            this.children.length > 2) {

            throw new Error(`Invalid access operand count ${this.children.length}`);
        }
    }
}

class ValueNode extends Node_ {
    value: string | number;

    constructor(value: string, str?: boolean) {
        super();

        if (str) {
            this.value = value as string;
        } else {
            const num = parseInt(value);
            if (Number.isNaN(num)) {
                throw new Error(`Invalid number: ${value}`);
            }
            this.value = num;
        }
    }

    evaluate = (_: any) => {
        return this.value;
    }
}

function tokenize(value: string): Node_ {

    let index = 0;
    const nodeStack = [];
    let token = '';
    while (index < value.length) {

        switch(value[index]) {
            case '(':
                {
                    const node = new ExprNode(token);
                    nodeStack.push(node);
                    token = '';
                }
                break;
            case ')':
                {
                    if (token) {
                        const node = new ValueNode(token);
                        nodeStack.push(node);
                        addToParent(nodeStack);
                        token = '';
                    }
                    addToParent(nodeStack);
                }
                break;
            case "'":
            case '"':
                const str = consumeString(value, index);
                index += str.length + 1;
                token += str;
                {
                    const node = new ValueNode(token, true);
                    nodeStack.push(node);
                    addToParent(nodeStack);
                }
                token = '';
                break;
            case ',':
                if (token) {
                    const node = new ValueNode(token);
                    nodeStack.push(node);
                    addToParent(nodeStack);
                    token = '';
                }
                break;
            case ' ':
                break
            default:
                token += value[index];
        }
        index++;
    }

    return nodeStack[0];
}

function consumeString(value: string, index: number) {
    const delimiter = value[index++];
    let ret = '';
    while (value[index] !== delimiter) {
        ret += value[index];
        index++;
    }

    return ret;
}

function addToParent(nodeStack: Node_[]) {
    console.assert(nodeStack.length > 0);

    const last = nodeStack.pop();
    if (nodeStack.length > 0) {
        const parent = nodeStack.pop();
        parent.addChild(last);
        nodeStack.push(parent);
    } else {
        nodeStack.push(last);
    }
}

{
    const ast = tokenize('EQ("origami", GET("name"))');
    const context = { name: 'origami' };
    const context2 = { };
    console.assert(ast.evaluate(context) === true);
    console.assert(ast.evaluate(context2) === false);
}

{
    const ast = tokenize('EQ(5, 5)');
    console.assert(ast.evaluate({}) === true);

    const ast1 = tokenize('EQ("foo", "foo")');
    console.assert(ast1.evaluate({}) === true);

    const ast2 = tokenize('EQ("foo", "bar")');
    console.assert(ast2.evaluate({}) === false);

    const ast3 = tokenize('GTE(15, 10)');
    console.assert(ast3.evaluate({}) === true);
}

{
    const ast = tokenize('GET("info", GET("person", GET("age")))');
    const context = { info: { person: { age: 21 } } };
    console.assert(ast.evaluate(context) === 21);
}

{
    const ast = tokenize('LTE(21, GET("info", GET("person", GET("age"))))');
    const context = { info: { person: { age: 21 } } };
    console.assert(ast.evaluate(context) === true);

    const context2 = { info: { person: { age: 15 } } };
    console.assert(ast.evaluate(context2) === false);
}

{
    const ast = tokenize('EQ(GET("info", GET("person", GET("planet"))), "earth")');
    const context = { info: { person: { planet: "mars" } } };
    console.assert(ast.evaluate(context) === false);
}

{
    const ast = tokenize('GT(GET("person1", GET("age")), GET("person2", GET("age")))');
    const context = { person1: { age: 56 }, person2: { age: 21 } };
    console.assert(ast.evaluate(context) === true);

    const context2 = { person1: { age: 25 }, person2: { age: 44 } };
    console.assert(ast.evaluate(context2) === false);
}

I omitted AND & OR expressions, but it should be clear how to add them.

In this scenario, the client should submit the data along with the constraints. For example:

{
    "context": {
        "person": {
            "age": 44,
            "planet": "saturn"
        }
    },
    "constraints": {
        "shouldFrom": "EQ('mars', GET('person', GET('planet')))",
        "minimumAge": "GTE(40, GET('person', GET('planet')))"
    }
}

And the receiver part takes the each constraints, tokenize them and evaluate them with given context.

Related