type stringOrNumber = string | number; type trutyValue = [] | object; export class JsonLogic { operations: { [key: string]: Function }; constructor() { let jsonLogicContext = this; this.operations = { "==": (a: stringOrNumber, b: stringOrNumber): boolean => { return a == b; }, "===": (a: stringOrNumber, b: stringOrNumber): boolean => { return a === b; }, "!=": (a: stringOrNumber, b: stringOrNumber): boolean => { return a != b; }, "!==": (a: stringOrNumber, b: stringOrNumber): boolean => { return a !== b; }, ">": (a: number, b: number): boolean => { return a > b; }, ">=": (a: number, b: number): boolean => { return a >= b; }, "<": (a: number, b: number, c: number | undefined): boolean => { return c === undefined ? a < b : a < b && b < c; }, "<=": (a: number, b: number, c: number | undefined): boolean => { return c === undefined ? a <= b : a <= b && b <= c; }, "!!": (a: trutyValue): boolean => { return jsonLogicContext.truthy(a); }, "!": (a: trutyValue): boolean => { return !jsonLogicContext.truthy(a); }, "%": (a: number, b: number): number => { return a % b; }, log: (a: object): object => { console.log(a); return a; }, in: (a: string, b: string): boolean => { if (!b || typeof b.indexOf === "undefined") return false; return b.includes(a); }, cat: (...args: any[]): string => { return args.join(""); }, substr: (source: string, start: number, end: number): string => { if (end < 0) { const temp = source.substr(start); return temp.substr(0, temp.length + end); } return source.substr(start, end); }, "+": (...args: any[]): number => { return args.reduce((a: any, b: any) => { return parseFloat(a) + parseFloat(b); }, 0); }, "*": (...args: any): number => { return args.reduce((a: any, b: any) => { return parseFloat(a) * parseFloat(b); }, 1); }, "-": (a: number, b: number | undefined): number => { if (b === undefined) { return -a; } else { return a - b; } }, "/": (a: number, b: number): number => { return a / b; }, min: (...args: any[]): number => { return Math.min(...args); }, max: (...args: any[]): number => { return Math.max(...args); }, merge: (...args: any): string => { return args.reduce((a: any, b: any) => { return a.concat(b); }, []); }, var: function(a: string | null, b: undefined): object | null | undefined { const not_found = b === undefined ? null : b; let data: any = this; if (typeof a === "undefined" || a === "" || a === null) { return data; } const sub_props = String(a).split("."); for (let i = 0; i < sub_props.length; i++) { if (data === null) { return not_found; } // Descending into data data = data[sub_props[i]]; if (data === undefined) { return not_found; } } return data; }, missing: function(...args: any): string[] { const missing = []; const keys = Array.isArray(args[0]) ? args[0] : args; for (let i = 0; i < keys.length; i++) { const key = keys[i]; const value = jsonLogicContext.apply({ var: key }, this); if (value === null || value === "") { missing.push(key); } } return missing; }, missing_some: function(need_count: number, options: []): [] { const are_missing = jsonLogicContext.apply({ missing: options }, this); if (options.length - are_missing.length >= need_count) { return []; } else { return are_missing; } }, method: ( obj: { [x: string]: JsonLogic }, method: string | number, args: any ) => { return obj[method].apply(obj, args); } }; } public enhancedApply(logic: any, data?: object): any { let debug = []; let ret = this.apply(logic, data, debug); return { data: ret, debug: debug }; } public apply(logic: any, data?: object, debug = []): any { if (Array.isArray(logic)) { return logic.map(l => { return this.apply(l, data, debug); }); } if (!this.is_logic(logic)) { return logic; } data = data || {}; const op = this.get_operator(logic); let values = logic[op]; let i; let current; let scopedLogic: any; let scopedData, filtered, initial; if (!Array.isArray(values)) { values = [values]; } if (op === "if" || op == "?:") { for (i = 0; i < values.length - 1; i += 2) { if (this.truthy(this.apply(values[i], data, debug))) { const result = this.apply(values[i + 1], data, debug); return result; } } if (values.length === i + 1) { return this.apply(values[i], data, debug); } return null; } else if (op === "and") { // Return first falsy, or last for (i = 0; i < values.length; i += 1) { current = this.apply(values[i], data, debug); if (!this.truthy(current)) { return current; } } return current; // Last } else if (op === "or") { // Return first truthy, or last for (i = 0; i < values.length; i += 1) { current = this.apply(values[i], data, debug); if (this.truthy(current)) { return current; } } return current; // Last } else if (op === "filter") { scopedData = this.apply(values[0], data, debug); scopedLogic = values[1]; if (!Array.isArray(scopedData)) { return []; } // Return only the elements from the array in the first argument, // that return truthy when passed to the logic in the second argument. // For parity with JavaScript, reindex the returned array return scopedData.filter(datum => { return this.truthy(this.apply(scopedLogic, datum, debug)); }); } else if (op === "map") { scopedData = this.apply(values[0], data, debug); scopedLogic = values[1]; if (!Array.isArray(scopedData)) { return []; } return scopedData.map(datum => { return this.apply(scopedLogic, datum, debug); }); } else if (op === "reduce") { scopedData = this.apply(values[0], data, debug); scopedLogic = values[1]; initial = typeof values[2] !== "undefined" ? values[2] : null; if (!Array.isArray(scopedData)) { return initial; } return scopedData.reduce((accumulator, current) => { return this.apply( scopedLogic, { current: current, accumulator: accumulator }, debug ); }, initial); } else if (op === "all") { scopedData = this.apply(values[0], data, debug); scopedLogic = values[1]; // All of an empty set is false. Note, some and none have correct fallback after the for loop if (!scopedData.length) { return false; } for (i = 0; i < scopedData.length; i += 1) { if (!this.truthy(this.apply(scopedLogic, scopedData[i], debug))) { return false; // First falsy, short circuit } } return true; // All were truthy } else if (op === "none") { filtered = this.apply({ filter: values }, data, debug); return filtered.length === 0; } else if (op === "some") { filtered = this.apply({ filter: values }, data, debug); return filtered.length > 0; } else if (op == "evaluate") { const currentValues = values[0]; for (let current in currentValues) { if (typeof currentValues[current] == "object") { if (currentValues[current].method) { currentValues[current] = this.operations[ currentValues[current].method ].apply(data, currentValues[current].params); } } } return currentValues; } // Everyone else gets immediate depth-first recursion values = values.map((val: any) => { return this.apply(val, data, debug); }); // The operation is called with "data" bound to its "this" and "values" passed as arguments. // Structured commands like % or > can name formal arguments while flexible commands (like missing or merge) can operate on the pseudo-array arguments // https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Functions/arguments if (typeof this.operations[op] === "function") { const result = this.operations[op].apply(data, values); debug.push( `operation '${op}' with values '${JSON.stringify( values )}' passed : ${result}` ); return result; } else if (op.indexOf(".") > 0) { // Contains a dot, and not in the 0th position const sub_ops = String(op).split("."); let operation = this.operations; for (i = 0; i < sub_ops.length; i++) { // Descending into operations operation = operation[sub_ops[i]] as any; if (operation === undefined) { throw new Error( "Unrecognized operation " + op + " (failed at " + sub_ops.slice(0, i + 1).join(".") + ")" ); } } const result = operation.apply(data, values); debug.push( `operation '${op}' with values '${JSON.stringify( values )}' passed : ${result}` ); return result; } throw new Error("Unrecognized operation " + op); } public uses_data(logic: { [x: string]: any }): any[] { const collection: any[] = []; if (this.is_logic(logic)) { const op = this.get_operator(logic); let values = logic[op]; if (!Array.isArray(values)) { values = [values]; } if (op === "var") { // This doesn't cover the case where the arg to var is itself a rule. collection.push(values[0]); } else { // Recursion! values.map((val: any) => { collection.push.apply(collection, this.uses_data(val)); }); } } return this.arrayUnique(collection); } public add_operation(name: string, code: any): void { this.operations[name] = code; } public rm_operation(name: string | number): void { delete this.operations[name]; } public rule_like(rule: any[], pattern: string | object | any[]): boolean { if (pattern === rule) { return true; } if (pattern === "@") { return true; } if (pattern === "number") { return typeof rule === "number"; } if (pattern === "string") { return typeof rule === "string"; } if (pattern === "array") { return Array.isArray(rule) && !this.is_logic(rule); } if (this.is_logic(pattern)) { if (this.is_logic(rule)) { const pattern_op = this.get_operator(pattern as object); const rule_op = this.get_operator(rule); if (pattern_op === "@" || pattern_op === rule_op) { return this.rule_like( this.get_values(rule), this.get_values(pattern) ); } } return false; } if (Array.isArray(pattern)) { if (Array.isArray(rule)) { if (pattern.length !== rule.length) { return false; } for (let i = 0; i < pattern.length; i += 1) { if (!this.rule_like(rule[i], pattern[i])) { return false; } } return true; // If they *all* passed, we pass } else { return false; // Pattern is array, rule isn't } } // Not logic, not array, not a === match for rule. return false; } private truthy(value: [] | object): boolean { if (Array.isArray(value) && value.length === 0) { return false; } return !!value; } public get_operator(logic: object): string { return Object.keys(logic)[0]; } public get_values(logic: any): any { return logic[this.get_operator(logic)]; } private is_logic(logic: {} | null): boolean { return ( typeof logic === "object" && // An object logic !== null && // but not null !Array.isArray(logic) && // and not an array Object.keys(logic).length === 1 // with exactly one key ); } private arrayUnique(array: any[]): any[] { const a = []; for (let i = 0, l = array.length; i < l; i++) { if (a.indexOf(array[i]) === -1) { a.push(array[i]); } } return a; } }