tandrewnichols/indeed

View on GitHub
lib/base.js

Summary

Maintainability
B
4 hrs
Test Coverage
var _ = require('lodash');
var utils = require('./utils');

/**
 * Base
 *
 * Underlying class for the other boolean helpers
 * @private
 * @constructor
 * @param {*} condition - The thing to be evaluated
 * @param {boolean} negate - Whether the first group is negated
 *
 */
var Base = function Base(condition, negate) {
  // Set up flags
  var self = this;
  this.flags = {
    not: false,
    deep: false,
    chain: false,
    noCase: false,
    unordered: false
  };

  // Add this condition to the current list
  this.current = [{
    val: condition,
    negate: negate,
    actual: condition
  }];

  // Set up helpers
  _.each(['And', 'But', 'Or', 'Xor'], function(joiner) {
    self.__defineGetter__(joiner, function() {
      return utils.delegate(self.test(), joiner.toLowerCase());
    });
  });
  _.each(['does', 'should', 'has', 'have', 'is', 'to', 'be', 'been'], function(getter) {
    self.__defineGetter__(getter, function() {
      return self;
    });
  });
  _.each(['deep', 'deeply'], function(getter) {
    self.__defineGetter__(getter, function() {
      self.flags.deep = true;
      return self;
    });
  });
  self.__defineGetter__('roughly', function() {
    self.flags.unordered = true;
    return self;
  });
  self.__defineGetter__('not', function() {
    self.flags.not = true;
    return self;
  });
  self.__defineGetter__('Not', function() {
    self.flags.groupNot = true;
    return self;
  });
  _.each(['noCase', 'caseless'], function(getter) {
    self.__defineGetter__(getter, function() {
      self.flags.noCase = true;
      return self;
    });
  });
};

/**
 * Base#_compare
 *
 * Handles the comparison of actual and expected
 *
 * @param {Function} tester = The function that evaluates the condition
 * @returns {(Base|boolean)}
 *
 */
Base.prototype._compare = function(tester) {
  // Evaluate the current value via the tester
  var current = this.current.pop();
  var newVal = tester(current.actual);

  // Handle negation if necessary and reset any flags
  current.val = this.flags.not ? !newVal : Boolean(newVal);
  this.flags.not = false;
  this.flags.noCase = false;

  // If chaining, return the instance;
  // otherwise, return the value
  if (!this.flags.chain && this.canChainComparisons) {
    return current.val;
  } else {
    this.current.push(current);
    return this;
  }
};

/**
 * Base#_testIllegalMethod
 *
 * Assert that a particular function can be called. If not, throw an exception;
 * otherwise, test the current chain.
 *
 * @private
 * @param {string} currentMethod - The method being invoked
 * @param {string} prevMethods - The methods called prior to this
 * @param {boolean} negate - Set "negate" on the "current" object
 * @param {*} condition - The condition to be evaluated
 * @returns {(Base|boolean)}
 *
 */
Base.prototype._testIllegalMethod = function(currentMethod, prevMethods, negate, condition) {
  // If two methods have already been called, throw an exception
  if (this.current.length === 2) {
    throw new Error('IllegalMethodException: "' + currentMethod + '" cannot be called with "' + prevMethods + '"');
  } else {
    // Otherwise, add to the list of current objects
    var obj = {
      val: condition,
      actual: condition
    };

    if (negate) {
      obj.negate = negate;
    }

    this.current.push(obj);

    // If we're not chaining, test
    if (this.current.length === 2 && !this.flags.chain) {
      return this.test();
    } else {
      return this;
    }
  }
};

/**
 * Base#equal, Base#equal, Base#eql
 * 
 * Assert equality
 *
 * @param {*} expected - The expected value of the current condition
 * @returns {boolean}
 *
 */
Base.prototype.equals = Base.prototype.equal = Base.prototype.eql = function(expected) {
  var self = this;
  var cond;
  var v;

  return this._compare(function(val) {
    // If noCase was set, lowercase all the things
    if (self.flags.noCase && _.isString(val)) {
      cond = expected.toLowerCase();
      v = val.toLowerCase();
    }

    var actual = v || val;
    expected = cond || expected;
    
    // If this is a deep comparison, delegate to _.isEqual;
    // otherwise, use type/value comparison
    if (_.isArray(expected) && self.flags.unordered) {
      return expected.length === actual.length && _.every(expected, function(e) {
        return actual.indexOf(e) > -1;
      });
    } else if (self.flags.deep) {
      self.flags.deep = false;
      return _.isEqual(actual, expected);
    } else {
      return actual === expected;
    }
  });
};

/**
 * Base#matches, Base#match
 *
 * Assert that condition matches a pattern
 *
 * @param {string|RegExp} regex - The pattern to test against
 * @returns {(Base|boolean)}
 *
 */
Base.prototype.matches = Base.prototype.match = function(regex) {
  // If the regex is a string, convert it to a RegExp
  if (typeof regex === 'string') {
    regex = new RegExp(regex);
  }

  return this._compare(function(val) {
    return regex.test(val);
  });
};

/**
 * Base#a, Base#an
 *
 * Assert that the condition is of a particular type
 *
 * @param {string} type - The type expected
 * @returns {(Base|boolean)}
 *
 */
Base.prototype.a = Base.prototype.an = function(type) {
  return this._compare(function(val) {
    try {
      // Try comparing the constructor name first
      return val.constructor.name.toLowerCase() === type.toLowerCase();
    } catch (e) {
      // If that fails, try typeof
      return typeof val === type && val !== null && typeof val !== 'undefined';
    }
  });
};

/**
 * Base#contains, Base#contain, Base#indexOf
 *
 * Assert that the condition contains a substring
 *
 * @param {string} substr - The substring to check for
 * @returns {(Base|boolean)}
 *
 */
Base.prototype.contains = Base.prototype.contain = Base.prototype.indexOf = function(substring) {
  var self = this;
  var cond;
  var v;

  return this._compare(function(val) {
    // If noCase was set, lowercase all the things
    if (self.flags.noCase && _.isString(val)) {
      cond = substring.toLowerCase();
      v = val.toLowerCase();
    }

    // If this val is a string or array, call indexOf on it
    // otherwise, return false
    if (val.indexOf) {
      return (v || val).indexOf(( cond || substring )) > -1;
    } else {
      return false;
    }
  });
};

/**
 * Base#containsKey, Base#containKey, Base#key, Base#property
 *
 * Assert that an object contains a particular key
 *
 * @param {string} key - The key to look for
 * @returns {(Base|boolean)}
 *
 */
Base.prototype.containsKey = Base.prototype.containKey = Base.prototype.key = Base.prototype.property = function(key) {
  var self = this;
  return this._compare(function(val) {
    if (_.isObject(val)) {
      // If noCase is set, we have to iterate over the keys
      // and check the lowercase of each; otherwise, things
      // are much simpler. We just need to no if the keys is
      // in the object.
      if (self.flags.noCase) {
        var c = key.toLowerCase();
        return _(val).keys().any(function(k) {
          return k.toLowerCase() === c;
        });
      } else {
        return key in val;
      }
    } else {
      return false;
    }
  });
};

/**
 * Base#containsKeys, Base#containKeys, Base#keys, Base#properties
 *
 * Assert that an object contains all of a set of keys
 *
 * @param {string[]|strings} keys - Any number of keys to check for
 * @returns {(Base|boolean)}
 *
 */
Base.prototype.containsKeys = Base.prototype.containKeys = Base.prototype.keys = Base.prototype.properties = function() {
  // Get the list of keys, either the first argument, or ALL the arguments
  var keys = _.isArray(arguments[0]) ? arguments[0] : [].slice.call(arguments);
  var self = this;
  return this._compare(function(val) {
    if (_.isObject(val)) {
      // Similar to containsKey, but we have to iterate over every key
      if (self.flags.noCase) {
        return _.every(keys, function(condition) {
          var c = condition.toLowerCase();
          return _(val).keys().any(function(k) {
            return k.toLowerCase() === c;
          });
        });
      } else {
        return _.every(keys, function(condition) {
          return condition in val;
        });
      }
    } else {
      return false;
    }
  });
};

/**
 * Base#containsValue, Base#containValue, Base#value
 *
 * Assert that an object contains a particular value
 *
 * @param {*} value - The value to check for
 * @returns {(Base|boolean)}
 *
 */
Base.prototype.containsValue = Base.prototype.containValue = Base.prototype.value = function(value) {
  return this._compare(function(val) {
    if (_.isObject(val)) {
      // Check that the list of values contains the value in question
      return _(val).values().contains(value);
    } else {
      return false;
    }
  });
};

/**
 * Base#containsValues, Base#containValues, Base#values
 *
 * Assert that an object contains all of a set of values
 *
 * @param {*[]} values - The list of values to check for
 * @returns {(Base|boolean)}
 *
 */
Base.prototype.containsValues = Base.prototype.containValues = Base.prototype.values = function() {
  var values = _.isArray(arguments[0]) ? arguments[0] : [].slice.call(arguments);
  return this._compare(function(val) {
    if (_.isObject(val)) {
      // Check that the list of values contains all the values in question
      return _.every(values, function(condition) {
        return _.values(val).indexOf(condition) > -1;
      });
    } else {
      return false;
    }
  });
};

/**
 * Base#defined
 *
 * Assert on the definedness of the condition
 *
 * @returns {(Base|boolean)}
 *
 */
Base.prototype.defined = function() {
  return this._compare(function(val) {
    return typeof val !== 'undefined';
  });
};

/**
 * Base#null
 *
 * Assert that the condition is null
 *
 * @returns {(Base|boolean)}
 *
 */
Base.prototype['null'] = function() {
  return this._compare(function(val) {
    return val === null;
  });
};

/**
 * Base#true
 *
 * Assert that the condition is true
 *
 * @returns {(Base|boolean)}
 *
 */
Base.prototype['true'] = function() {
  return this._compare(function(val) {
    return val === true;
  });
};

/**
 * Base#false
 *
 * Assert that the condition is false
 *
 * @returns {(Base|boolean)}
 *
 */
Base.prototype['false'] = function() {
  return this._compare(function(val) {
    return val === false;
  });
};

/**
 * Base#truthy
 *
 * Assert that the condition is truthy
 *
 * @returns {(Base|boolean)}
 *
 */
Base.prototype.truthy = function() {
  return this._compare(function(val) {
    return val ? true : false;
  });
};

/**
 * Base#falsy
 *
 * Assert that the condition is falsy
 *
 * @returns {(Base|boolean)}
 *
 */
Base.prototype.falsy = function() {
  return this._compare(function(val) {
    return val ? false : true;
  });
};

/**
 * ~_evalRelation
 *
 * Generic wrapper for evaluating lessThan, greaterThan, lessThanOrEqualTo, and greaterThanOrEqualTo
 *
 * @param {string} op - The operator, one of 'gt', 'lt', 'gte', and 'lte'
 * @returns {function}
 *
 */
var _evalRelation = function (op) {
  return function(condition) {
    return this._compare(function(val) {
      var isNum = _.isNumber(val);
      switch (op) {
        case 'gt':
          return isNum && val > condition;
        case 'lt':
          return isNum && val < condition;
        case 'gte':
          return isNum && val >= condition;
        default:
          return isNum && val <= condition;
      }
    });
  };
};

/**
 * Base#greaterThan, Base#gt, Base#above
 *
 * Assert that the condition is greater than a particular value
 *
 * @param {number} value - The value to check against
 * @returns {(Base|boolean)}
 *
 */
Base.prototype.greaterThan = Base.prototype.gt = Base.prototype.above = _evalRelation('gt');

/**
 * Base#lessThan, Base#lt, Base#below
 *
 * Assert that the condition is less than a particular value
 *
 * @param {number} value - The value to check against
 * @returns {(Base|boolean)}
 *
 */
Base.prototype.lessThan = Base.prototype.lt = Base.prototype.below = _evalRelation('lt');

/**
 * Base#greaterThanOrEqualTo, Base#gte
 *
 * Assert that the condition is greater than or equal to a particular value
 *
 * @param {number} value - The value to check against
 * @returns {(Base|boolean)}
 *
 */
Base.prototype.greaterThanOrEqualTo = Base.prototype.gte = _evalRelation('gte');

/**
 * Base#lessThanOrEqualTo, Base#lte
 *
 * Assert that the condition is less than or equal to a particular value
 *
 * @param {number} value - The value to check against
 * @returns {(Base|boolean)}
 *
 */
Base.prototype.lessThanOrEqualTo = Base.prototype.lte = _evalRelation('lte');

/**
 * Base#tap
 *
 * Call a function with the current instance object
 *
 * @param {function} fn - The function to call
 * @returns {Base}
 *
 */
Base.prototype.tap = function(fn) {
  fn(this);
  return this;
};

// export Base
module.exports = Base;