Code Review Stack Exchange is a question and answer site for peer programmer code reviews. Join them; it only takes a minute:

Sign up
Here's how it works:
  1. Anybody can ask a question
  2. Anybody can answer
  3. The best answers are voted up and rise to the top

This is a pretty simple Node package designed to accept a "boolean expression" and evaluate it asynchronously. The full project is on GitHub.

Essentially, an expression like cond1 && (cond2 || cond3) can be surprisingly difficult to evaluate when the results of cond1, cond2, and cond3 are not immediately known. I wanted to build an approach that could take each condition (which I called an operand) and pass it into an iterator function that would determine its "truthiness" asynchronously.

The requirements were to:

  • Support "and", "or", and "not" operators
  • Short-circuit (e.g. if cond1 was false in the example above, cond2 and cond3 would not need to be tested)
  • Support both Promise-style and callback-style, depending on the developer's preference and the libraries the developer is using
  • Allow some flexibility in the type of operands
  • Ability to accept functions as operands without needing any iterator
  • Use of an iterator result cache to speed up evaluating a complex expression or subsequent expression that contains the same operand more than once (e.g. cond1 || (!cond1 && cond2) would not invoke the iterator twice for cond1)
  • Ability to limit the number of concurrent iterator tests

Much of the complexity is actually part of an existing NPM called Async.

I created this to support a larger project, but was hoping for any criticism and room for improvement on it as a standalone module.

I'm not a huge fan of large documentation comment blocks, but in this case it was helpful for generating an API documentation file. It is written for Node.js 4.0.0 (I think), so some ES6 features (notably the list parameter) were not used.

'use strict';

const async = require('async');
const DEFAULT_PARALLEL_LIMIT = 1;

/**
 * A parser for evaluating boolean expressions, passing each operand to an asynchronous iterator
 */
class AsyncBooleanExpressionEvaluator {
    /**
     * Creates a new evaluator for boolean expressions, given a function to perform an async test against an operand
     * @param {Function} [iterator] See #set iterator for details. Default to a no-op, which only is applicable when all
     * operands are functions
     */
    constructor (iterator) {
        if (typeof iterator === 'undefined') {
            iterator = noop;
        }
        this._setIterator(iterator);
        this.parallelLimit = DEFAULT_PARALLEL_LIMIT;
    }

    /**
     * Validates then executes the given boolean expression
     * @param {object|string} expression
     * @param {Function} [callback]
     * @returns {Promise} The result of evaluating the expression
     */
    execute (expression, callback) {
        this.validateExpression(expression);
        return this.evaluateExpression(expression, callback);
    }

    /**
     * Gets the iterator
     * @returns {Function}
     */
    get iterator () {
        return this._iterator;
    }

    /**
     * Sets the iterator
     * @param {Function} iterator A function that accepts an operand and returns a Promise that resolves with whether the
     * given operand is considered true or false
     */
    set iterator (iterator) {
        this._setIterator(iterator);
    }

    /**
     * Tests that the iterator is a function, then sets it and resets the operand result cache
     * @param {Function} iterator
     * @private
     * @throws {TypeError}
     */
    _setIterator (iterator) {
        if (typeof iterator !== 'function') {
            throw new TypeError('The iterator must be a function');
        }

        this._iterator = iterator;
        this.clearCache();
    }

    /**
     * Sets the parallelLimit
     * @param {Number} parallelLimit The maximum number of asynchronous iterators that can be run in parallel
     * @throws {TypeError}
     */
    set parallelLimit (parallelLimit) {
        if (typeof parallelLimit !== 'number' || parallelLimit < 1 || parallelLimit % 1 !== 0) {
            throw new TypeError('The parallelLimit must be a positive integer');
        }

        this._parallelLimit = parallelLimit;
    }

    /**
     * Gets the parallelLimit
     * @returns {Number}
     */
    get parallelLimit () {
        return this._parallelLimit;
    }

    /**
     * Clears the iterator result cache
     */
    clearCache () {
        this._cache = new Map();
    }

    /**
     * Validates the given boolean expression conforms to the correct format
     * @param {object|*} expression The boolean expression to validate
     * @returns {boolean} True if the expression is valid
     * @throws {TypeError}
     */
    validateExpression (expression) {
        if (typeof expression !== 'object') {
            return true;
        }

        const hasAnd = 'and' in expression;
        const hasOr = 'or' in expression;
        const hasNot = 'not' in expression;

        if (!hasAnd && !hasOr && !hasNot) {
            // Argument is not an expression object, just an operand
            return true;
        }

        if (hasNot) {
            if (hasAnd || hasOr) {
                throw new TypeError('A `not` operator must not also have an `and` or `or` operator');
            }

            return this.validateExpression(expression.not);
        }

        if (hasAnd && hasOr) {
            throw new TypeError('The expression must not contain both an `and` and an `or` operator');
        }

        const operator = hasAnd ? 'and' : 'or';
        const operands = expression[operator];

        if (!Array.isArray(operands)) {
            throw new TypeError('The operands must be an array');
        }

        if (operands.length < 2) {
            throw new TypeError('There must be at least two operands in the operands array');
        }

        operands.forEach((operand) => this.validateExpression(operand));

        return true;
    }

    /**
     * Tests each operand in the expression against the asynchronous iterator. Will short-circuit whenever possible, and
     * caches the results of operands against the iterator
     * @param {object|*} expression The expression to evaluate
     * @param {Function} [callback] An optional callback to invoke when the promise is resolved or rejected
     * @returns {Promise} A promise that resolves with the result of the expression or rejects if the iterator rejects
     * @throws {TypeError}
     */
    evaluateExpression (expression, callback) {
        if (typeof expression === 'object' && 'not' in expression) {
            return this.evaluateExpression(expression.not).then((result) => !result);
        }

        if (typeof expression !== 'object' || !('not' in expression || 'and' in expression || 'or' in expression)) {
            return this.getIteratorResult(expression);
        }

        const asyncMethod = 'and' in expression ? async.everyLimit : async.someLimit;
        const operands = expression['and' in expression ? 'and' : 'or'];

        const promise = new Promise((resolve, reject) => {
            asyncMethod(
                operands,
                this._parallelLimit,
                (operand, done) => {
                    this.evaluateExpression(operand)
                        .then((result) => done(result))
                        .catch((err) => reject(err));
                },
                (result) => resolve(result)
            );
        });

        if (typeof callback !== 'undefined') {
            if (typeof callback !== 'function') {
                throw new TypeError('Callback must be a function');
            }
            promise.then((result) => callback(null, result)).catch((err) => callback(err, null));
        }

        return promise;
    }

    /**
     * Invokes the iterator for the given operand, or returns its cached result
     * @param {*} operand The operand to pass into the iterator
     * @returns {Promise} The promise returned from the iterator
     */
    getIteratorResult (operand) {
        if (typeof operand === 'function') {
            return this._invoke(operand);
        }

        if (!this._cache.has(operand)) {
            this._cache.set(operand, this._invoke(this._iterator, operand));
        }
        return this._cache.get(operand);
    }

    /**
     * Invokes the asynchronous iterator function. Based on the function, guesses whether the function returns a promise
     * or receives a callback and normalizes this behavior
     * @param {Function} iterator The iterator function to invoke
     * @param {...*} [parameters] The parameter(s) to pass into the iterator
     * @returns {Promise} A promise resolving to the result of the iterator's promise or callback
     * @throws {TypeError}
     * @private
     */
    _invoke (iterator) {
        const parameters = Array.prototype.slice.call(arguments, 1);
        if (iterator.length === parameters.length) {
            return iterator.apply(this, parameters);
        } else if (iterator.length === parameters.length + 1) {
            return new Promise((resolve, reject) => {
                iterator.apply(this, parameters.concat((err, result) => {
                    err ? reject(err) : resolve(result);
                }));
            });
        } else {
            throw new TypeError('Expected iterator to accept N arguments (Promise-style) or N+1 arguments (callback-style)');
        }
    }
}

module.exports = AsyncBooleanExpressionEvaluator;

function noop () {}

Here are the Mocha/Should.js tests:

'use strict';

const should = require('should');
const sinon = require('sinon');

const AsyncBooleanExpressionEvaluator = require('../lib/async-boolean-expression-evaluator');

describe('AsyncBooleanExpressionEvaluator', () => {
    describe('#constructor', () => {
        it('sets the iterator to the passed-in parameter', () => {
            function someIterator () {}
            new AsyncBooleanExpressionEvaluator(someIterator).iterator.should.equal(someIterator);
        });
    });

    describe('#set iterator', () => {
        it('sets the iterator member', () => {
            function someIterator () {}
            function someOtherIterator () {}
            const asyncBooleanExpressionEvaluator = new AsyncBooleanExpressionEvaluator(someIterator);
            asyncBooleanExpressionEvaluator.iterator = someOtherIterator;
            asyncBooleanExpressionEvaluator.iterator.should.equal(someOtherIterator);
        });

        it('throws a TypeError if not given a function', () => {
            (() => new AsyncBooleanExpressionEvaluator(null)).should.throw(TypeError);
        });

        it('resets the cache object', () => {
            function someIterator () {}
            function someOtherIterator () {}
            const asyncBooleanExpressionEvaluator = new AsyncBooleanExpressionEvaluator(someIterator);
            const oldCache = asyncBooleanExpressionEvaluator._cache;
            asyncBooleanExpressionEvaluator.iterator = someOtherIterator;
            const newCache = asyncBooleanExpressionEvaluator._cache;
            oldCache.should.not.equal(newCache);
        });
    });

    describe('#set parallelLimit', () => {
        it('sets the parallelLimit member', () => {
            function someIterator () {}
            const asyncBooleanExpressionEvaluator = new AsyncBooleanExpressionEvaluator(someIterator);
            asyncBooleanExpressionEvaluator.parallelLimit = 5;
            asyncBooleanExpressionEvaluator.parallelLimit.should.equal(5);
        });

        it('throws a TypeError if not given a positive integer', () => {
            function someIterator () {}
            const asyncBooleanExpressionEvaluator = new AsyncBooleanExpressionEvaluator(someIterator);
            (() => asyncBooleanExpressionEvaluator.parallelLimit = '').should.throw(TypeError);
            (() => asyncBooleanExpressionEvaluator.parallelLimit = 0).should.throw(TypeError);
            (() => asyncBooleanExpressionEvaluator.parallelLimit = -1).should.throw(TypeError);
            (() => asyncBooleanExpressionEvaluator.parallelLimit = 1.5).should.throw(TypeError);
        });
    });

    describe('#clearCache', () => {
        it('clears the cache object', () => {
            const asyncBooleanExpressionEvaluator = new AsyncBooleanExpressionEvaluator(function test () {});
            const oldCache = asyncBooleanExpressionEvaluator._cache;
            asyncBooleanExpressionEvaluator.clearCache();
            const newCache = asyncBooleanExpressionEvaluator._cache;
            oldCache.should.not.equal(newCache);
        })
    });

    const asyncBooleanExpressionEvaluator = new AsyncBooleanExpressionEvaluator(function test (value) {});

    describe('#validateExpression', () => {
        it('returns true if passed a single operand', () => {
            asyncBooleanExpressionEvaluator.validateExpression('single operand').should.be.ok();
        });

        it('returns true if passed a valid `and` expression', () => {
            asyncBooleanExpressionEvaluator.validateExpression({and: ['a', 'b']}).should.be.ok();
        });

        it('returns true if passed a valid `or` expression', () => {
            asyncBooleanExpressionEvaluator.validateExpression({or: ['a', 'b']}).should.be.ok();
        });

        it('returns true if passed a valid `not` expression', () => {
            asyncBooleanExpressionEvaluator.validateExpression({not: 'single operand'}).should.be.ok();
        });

        it('returns true if passed a valid nested expression', () => {
            asyncBooleanExpressionEvaluator.validateExpression({or: ['a', {and: ['b', 'c', {or: ['d', 'e']}]}]}).should.be.ok();
        });

        it('returns true if passed a valid nested `not` expression', () => {
            asyncBooleanExpressionEvaluator.validateExpression({not: {and: ['a', 'b']}}).should.be.ok();
            asyncBooleanExpressionEvaluator.validateExpression({and: ['a', {not: 'b'}]}).should.be.ok();
        });

        it('throws a TypeError if the operands is not an array', () => {
            (() => asyncBooleanExpressionEvaluator.validateExpression({and: 42})).should.throw(TypeError);
        });

        it('throws a TypeError if the operands is empty', () => {
            (() => asyncBooleanExpressionEvaluator.validateExpression({and: []})).should.throw(TypeError);
        });

        it('throws a TypeError if there is only one operand', () => {
            (() => asyncBooleanExpressionEvaluator.validateExpression({and: ['a']})).should.throw(TypeError);
        });

        it('throws a TypeError if the passed-in object has both an `and` and an `or` key', () => {
            (() => asyncBooleanExpressionEvaluator.validateExpression({and: ['a', 'b'], or: ['c', 'd']})).should.throw(TypeError);
        });

        it('throws a TypeError if the passed-in object has both another operator with the `not` key', () => {
            (() => asyncBooleanExpressionEvaluator.validateExpression({not: 'a', or: ['c', 'd']})).should.throw(TypeError);
        });

        it('throws a TypeError if a nested expression is invalid', () => {
            (() => asyncBooleanExpressionEvaluator.validateExpression({and: ['a', {or: ['b']}]})).should.throw(TypeError);
        });

        it('throws a TypeError if a nested `not` expression is invalid', () => {
            (() => asyncBooleanExpressionEvaluator.validateExpression({and: ['a', {or: ['b', {not: {and: []}}]}]})).should.throw(TypeError);
        });
    });

    describe('#execute', () => {
        var asyncBooleanExpressionEvaluator;
        beforeEach(() => {
            asyncBooleanExpressionEvaluator = new AsyncBooleanExpressionEvaluator(function test (value) {
                return new Promise((resolve, reject) => {
                    setImmediate(() => {
                        if (typeof value === 'number' && !isNaN(value)) {
                            resolve(value % 2 === 0);
                        } else {
                            reject(new TypeError('Input must be castable to Number'));
                        }
                    });
                });
            });
        });

        it('evaluates a true single-operand expression', () => {
            return asyncBooleanExpressionEvaluator.execute(2).then((result) => {
                result.should.be.ok();
            });
        });

        it('evaluates a false single-operand expression', () => {
            return asyncBooleanExpressionEvaluator.execute(1).then((result) => {
                result.should.not.be.ok();
            });
        });

        it('evaluates a `not` expression with a single-operand', () => {
            return asyncBooleanExpressionEvaluator.execute({not: 1}).then((result) => {
                result.should.be.ok();
            });
        });

        it('evaluates an `or` expression with one true operand', () => {
            return asyncBooleanExpressionEvaluator.execute({or: [1, 2, 3]}).then((result) => {
                result.should.be.ok();
            });
        });

        it('evaluates an `or` expression with no true operands', () => {
            return asyncBooleanExpressionEvaluator.execute({or: [1, 3, 5]}).then((result) => {
                result.should.not.be.ok();
            });
        });

        it('short-circuits an `or` expression with a true operand', () => {
            const spy = sinon.spy(asyncBooleanExpressionEvaluator, '_iterator');
            spy.withArgs('3');
            return asyncBooleanExpressionEvaluator.execute({or: [1, 2, 3]}).then((result) => {
                result.should.be.ok();
                spy.withArgs('3').called.should.not.be.ok();
            });
        });

        it('evaluates an `and` expression with all true operands', () => {
            return asyncBooleanExpressionEvaluator.execute({and: [2, 4, 6]}).then((result) => {
                result.should.be.ok();
            });
        });

        it('evaluates an `and` expression with one false operand', () => {
            return asyncBooleanExpressionEvaluator.execute({and: [2, 3, 6]}).then((result) => {
                result.should.not.be.ok();
            });
        });

        it('short-circuits an `and` expression with a false operand', () => {
            const spy = sinon.spy(asyncBooleanExpressionEvaluator, '_iterator');
            spy.withArgs('6');
            return asyncBooleanExpressionEvaluator.execute({and: [2, 3, 6]}).then((result) => {
                result.should.not.be.ok();
                spy.withArgs('6').called.should.not.be.ok();
            });
        });

        it('evaluates a true nested operation', () => {
            return asyncBooleanExpressionEvaluator.execute({and: [2, {or: [3, 4]}]}).then((result) => {
                result.should.be.ok();
            });
        });

        it('evaluates a false nested operation', () => {
            return asyncBooleanExpressionEvaluator.execute({and: [2, {or: [3, 5]}]}).then((result) => {
                result.should.not.be.ok();
            });
        });

        it('evaluates a true nested expression containing a `not` operator', () => {
            return asyncBooleanExpressionEvaluator.execute({and: [2, {not: {or: [3, 5]}}]}).then((result) => {
                result.should.be.ok();
            });
        });

        it('evaluates a false nested expression containing a `not` operator', () => {
            return asyncBooleanExpressionEvaluator.execute({and: [2, {not: {and: [2, 4]}}]}).then((result) => {
                result.should.not.be.ok();
            });
        });

        it('uses cache when the same operand is present more than once in the expression', () => {
            const spy = sinon.spy(asyncBooleanExpressionEvaluator, '_iterator');
            spy.withArgs(2);
            return asyncBooleanExpressionEvaluator.execute({and: [2, {not: {and: [2, 4]}}]}).then((result) => {
                result.should.not.be.ok();
                spy.withArgs(2).calledOnce.should.be.ok();
            });
        });

        it('uses cache when the same operand is present more than once but negated in the expression', () => {
            const spy = sinon.spy(asyncBooleanExpressionEvaluator, '_iterator');
            spy.withArgs(2);
            return asyncBooleanExpressionEvaluator.execute({and: [2, {or: [3, 2]}]}).then((result) => {
                result.should.be.ok();
                spy.withArgs(2).calledOnce.should.be.ok();
            });
        });

        it('uses cache when evaluating other expressions', () => {
            const spy = sinon.spy(asyncBooleanExpressionEvaluator, '_iterator');
            spy.withArgs(2);

            Promise.all([
                asyncBooleanExpressionEvaluator.execute({and: [2, {not: {and: [2, 4]}}]}),
                asyncBooleanExpressionEvaluator.execute({and: [2, {or: [3, 2]}]})
            ]).then((results) => {
                results[0].should.be.ok();
                results[1].should.not.be.ok();
                spy.withArgs(2).calledOnce.should.be.ok();
            });
        });

        it('rejects the promise if any operand is rejected by the iterator', () => {
            return new Promise((resolve, reject) => {
                asyncBooleanExpressionEvaluator.execute({and: [2, 'a']})
                    .then(reject)
                    .catch((err) => {
                        err.should.be.instanceof(TypeError);
                        resolve();
                    });
            });
        });

        it('allows operands to be objects', () => {
            const asyncBooleanExpressionEvaluator = new AsyncBooleanExpressionEvaluator(function test (value) {
                return new Promise((resolve) => {
                    setImmediate(() => resolve(value.object === 'one'));
                });
            });

            const spy = sinon.spy(asyncBooleanExpressionEvaluator, '_iterator');
            const obj1 = {object: 'one'};
            spy.withArgs(obj1);

            return Promise.all([
                asyncBooleanExpressionEvaluator.execute({and: [obj1, {object: 'two'}]}),
                asyncBooleanExpressionEvaluator.execute({or: [obj1, {object: 'two'}]})
            ]).then((results) => {
                results[0].should.not.be.ok();
                results[1].should.be.ok();
                spy.withArgs(obj1).calledOnce.should.be.ok();
            });
        });

        it('does not allow operand objects that contain expression operators', () => {
            const asyncBooleanExpressionEvaluator = new AsyncBooleanExpressionEvaluator(function test (value) {
                return new Promise((resolve) => {
                    setImmediate(() => resolve(value.object === 'one'));
                });
            });

            (() => {
                asyncBooleanExpressionEvaluator.execute({object: 'one', or: 'something'});
            }).should.throw(TypeError);
        });

        it('evaluates function operands instead of passing them into the iterator', () => {
            function fn () {
                return new Promise((resolve) => {
                    setImmediate(() => resolve(true));
                });
            }
            return asyncBooleanExpressionEvaluator.execute({or: [1, fn]}).then((result) => {
                result.should.be.ok();
            });
        });

        it('does not cache the results of function operands', () => {
            const spy = sinon.spy(function fn () {
                return new Promise((resolve) => {
                    setImmediate(() => resolve(true));
                });
            });
            return asyncBooleanExpressionEvaluator.execute({and: [spy, {or: [1, spy]}]}).then((result) => {
                result.should.be.ok();
                spy.callCount.should.equal(2);
            });
        });

        it('supports successful callback-style function operands', () => {
            function fn (done) {
                setImmediate(() => done(null, true));
            }

            return asyncBooleanExpressionEvaluator.execute({or: [1, fn]}).then((result) => {
                result.should.be.ok();
            });
        });

        it('supports failure callback-style function operands', () => {
            class SpecialError extends Error {}
            function fn (done) {
                setImmediate(() => done(new SpecialError('Something went wrong'), null));
            }

            return new Promise((resolve, reject) => {
                asyncBooleanExpressionEvaluator.execute({or: [1, fn]})
                    .then((result) => reject(result))
                    .catch((err) => {
                        err.should.be.instanceof(SpecialError);
                        resolve();
                    });
            });
        });

        it('supports successful callback-style iterators', () => {
            asyncBooleanExpressionEvaluator.iterator = function test (value, done) {
                setImmediate(() => done(null, value % 2 === 0));
            };

            return Promise.all([
                asyncBooleanExpressionEvaluator.execute({and: [1, 2]}),
                asyncBooleanExpressionEvaluator.execute({or: [1, 2]})
            ]).then((results) => {
                results[0].should.not.be.ok();
                results[1].should.be.ok();
            });
        });

        it('supports failure callback-style iterators', () => {
            class SpecialError extends Error {}
            asyncBooleanExpressionEvaluator.iterator = function test (value, done) {
                setImmediate(() => done(new SpecialError('Something went wrong'), null));
            };

            return new Promise((resolve, reject) => {
                asyncBooleanExpressionEvaluator.execute({and: [1, 2]})
                    .then((result) => reject(result))
                    .catch((err) => {
                        err.should.be.instanceof(SpecialError);
                        resolve();
                    });
            });
        })

        it('supports successful callback-style evaluation', (done) => {
            asyncBooleanExpressionEvaluator.execute({or: [1, 2]}, (err, result) => {
                should(err).be.null();
                result.should.be.ok();
                done();
            });
        });

        it('supports failure callback-style evaluation', (done) => {
            asyncBooleanExpressionEvaluator.execute({or: [1, 'a']}, (err, result) => {
                err.should.be.instanceof(TypeError);
                should(result).be.null();
                done();
            });
        });
    });
});

Example usage would be something like this:

const evaluator = new AsyncBooleanExpressionEvaluator(function test (operand) {
  return new Promise((resolve, reject) => {
    db.lookupSomething(operand, (err, result) => {
      if (err) return reject(err);
      resolve(result.ok);
    });
  });
});

evaluator.execute({and: ['resource1', {or: ['resource2', 'resource3']}]})
  .then((result) => { /* ... */ })
  .catch((err) => log.error(err));
share|improve this question

Your Answer

 
discard

By posting your answer, you agree to the privacy policy and terms of service.

Browse other questions tagged or ask your own question.