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.