Source: ODataFilterBuilder.js

const AND = 'and';
const OR = 'or';

/**
 * The comparison field lambda expression
 * @example
 * f()
 *  .eq(x => x.toLower('Name'), 'a')
 *  .toString(); 
 * // tolower(Name) eq 'a'
 * @callback ODataFilterBuilder~fieldLambdaExpression
 * @param {ODataFilterBuilder.functions} - Filter canonical functions (toLower, substring, ...)
 * @returns  {string}
 */

/**
 * The comparison rule lambda expression
 * @example
 * f()
 *  .and(x => x.eq('Type/Id', 1))
 *  .and(x => x.contains('Name', 'a'))
 *  .toString();
 * // (Type/Id eq 1) and (contains(Name, 'a'))
 * @callback ODataFilterBuilder~ruleLambdaExpression
 * @param {ODataFilterBuilder} - A new instance of {@link ODataFilterBuilder}
 * @returns  {ODataFilterBuilder}
 */

/**
 * The comparison input rule
 * @typedef {string|ODataFilterBuilder|ODataFilterBuilder~ruleLambdaExpression} ODataFilterBuilder~InputRule
 * @example
 * f().and(inputRule)
 * // string
 * f().and('Id eq 1');
 * // ODataFilterBuilder
 * f().and(f().eq('Id', 1));
 * // ruleLambdaExpression
 * f().and(x => x.eq('Id', 1));
 */

/**
 * The comparison input field
 * @typedef {string|ODataFilterBuilder~fieldLambdaExpression} ODataFilterBuilder~InputField
 * @example
 * f().contains(inputField, 'a')
 *
 * // string
 * f().contains('tolower(Name)', 'a');
 * // fieldLambdaExpression
 * f().contains(x => x.toLower('Name', ''), 'a');
 *
 * // returns 'contains(tolower(Name), 'a')
 */

/**
 * The {@link ODataFilterBuilder} source rule object.
 * @typedef {Object} ODataFilterBuilder~Source
 * @property {string} condition - A base condition ('and' OR 'or').
 * @property {Array.<ODataFilterBuilder~Source|string>} rules - Child rules related to base condition
 */

/**
 * Reduce source with new rule and/or condition
 * @param {ODataFilterBuilder~Source} source - Source rule
 * @param {Object|string} rule - Rule to add
 * @param {string} [condition] - Condition for rule to add(and/or)
 * @returns {Object} updated rule
 * @private
 */
function _add(source, rule, condition) {
  if (rule) {
    if (condition && source.condition !== condition) {
      // if source rules condition different from rule condition
      // update source condition

      source = {
        condition: condition,
        // if has more then one rules
        // regroup source rules tree
        rules: source.rules.length > 1 ? [source] : source.rules
      };
    }

    // add new rule
    source.rules.push(rule);
  }
  return source;
}

function _normilise(value) {
  return typeof value === 'string' ? `'${value}'` : value;
}

/**
 * Negate rule
 * @param {ODataFilterBuilder~InputRule} rule - Rule to negate
 * @private
 * @returns {string} negated rule
 */
function _not(rule) {
  const ruleString = _inputRuleToString(rule);
  if (ruleString) {
    return `not (${ruleString})`;
  }
}

/**
 * @param {string} functionName - Function name
 * @param {ODataFilterBuilder~InputField} field - Field to handle by function
 * @param {Array|string|number} [values] - Zero or more function values
 * @param {boolean} [normaliseValues=true] - Convert string "value" to "'value'" or not. (Convert by default)
 * @returns {string} function string
 * @private
 */
function _function(functionName, field, values, normaliseValues = true) {
  // make sure that field is string
  field = _inputFieldToString(field);

  if (typeof values === 'undefined') {
    values = [];
  } else if (!Array.isArray(values)) {
    values = [values];
  }

  if (values.length === 0) {
    return `${functionName}(${field})`;
  }

  if (normaliseValues) {
    values = values.map(_normilise);
  }

  return `${functionName}(${field}, ${values.join(', ')})`;
}

/**
 * @param {ODataFilterBuilder~InputField} field - Field to compare
 * @param {string} operator - Comparison operator
 * @param {string|number|*} value - Value to compare with field
 * @param {boolean} [normaliseValue=true] - Convert string "value" to "'value'" or not. (Convert by default)
 * @returns {string} The comparison string
 * @private
 */
function _compare(field, operator, value, normaliseValue = true) {
  // make sure that field is string
  field = _inputFieldToString(field);

  if (normaliseValue) {
    value = _normilise(value);
  }

  return `${field} ${operator} ${value}`;
}

/**
 * @param {ODataFilterBuilder~InputField} field - Field to compare
 * @param {string} operator - Comparison operator
 * @param {Array} values - Values to compare with field
 * @param {boolean} [normaliseValues=true] - Convert string "value" to "'value'" or not. (Convert by default)
 * @returns {string[]} An array of comparison strings
 * @private
 */
function _compareMap(field, operator, values, normaliseValues = true) {
  if (!values) {
    return [];
  }

  // make sure that field is string
  field = _inputFieldToString(field);

  if (!Array.isArray(values)) {
    return [_compare(field, operator, values, normaliseValues)];
  }

  return values.map(value => _compare(field, operator, value, normaliseValues));
}

function _joinRules(rules, condition) {
  return rules.join(` ${condition} `);
}

/**
 * Convert source rule to string
 * @param {ODataFilterBuilder~Source|string} rule - A source rule
 * @param {boolean} [wrapInParenthesis=false] - Wrap result string in Parenthesis or not.
 * @returns {string} - A source string
 * @private
 */
function _sourceRuleToString(rule, wrapInParenthesis = false) {
  if (typeof rule !== 'string') {
    // if child rules more then one join child rules by condition
    // and wrap in brackets every child rule
    rule = (
        rule.rules.length === 1
            ? _sourceRuleToString(rule.rules[0])
            : _joinRules(rule.rules.map(r => _sourceRuleToString(r, true)), rule.condition)
    );
  }

  return wrapInParenthesis ? `(${rule})` : rule;
}

/**
 * Convert input rule to string
 * @param {ODataFilterBuilder~InputRule} rule - An input rule
 * @returns {string} - An input rule string
 * @private
 */
function _inputRuleToString(rule) {
  if (typeof rule === 'function') {
    rule = rule(new ODataFilterBuilder());
  }

  return rule && rule.toString();
}

/**
 * Convert input field to string if field is lambda expression
 * @example
 * _inputFieldToString(x => x.toLower('Name'));
 * _inputFieldToString('tolower(Name)');
 * // returns 'tolower(Name)'
 * @param {ODataFilterBuilder~InputField} field - An input field
 * @returns {string} An input field string
 * @private
 */
function _inputFieldToString(field) {
  return typeof field === 'function' ? field(ODataFilterBuilder.functions) : field;
}

/**
 * Creates a new {@link ODataFilterBuilder} instance.
 * Can be used without "new" operator.
 * @class
 * @classDesc ODataFilterBuilder is util to build
 * {@link http://docs.oasis-open.org/odata/odata/v4.0/errata02/os/complete/part2-url-conventions/odata-v4.0-errata02-os-part2-url-conventions-complete.html#_Toc406398094|$filter part}
 * for OData query options.
 *
 * @see {@link http://docs.oasis-open.org/odata/odata/v4.0/errata02/os/complete/part2-url-conventions/odata-v4.0-errata02-os-part2-url-conventions-complete.html|OData URL Conventions}
 * for further information.
 *
 * @example
 * // use short name as alias
 * const f = ODataFilterBuilder;
 *
 * @example
 * // can be used without "new" operator
 * // default base condition is 'and'
 * f()
 *  .eq('TypeId', '1')
 *  .eq('SubType/Id', '1')
 *  .toString();
 * // returns `(TypeId eq '1') and (SubType/Id eq 1)`
 *
 * @example
 * // 'or' condition as base condition
 * f('or')
 *  .eq('TypeId', '1')
 *  .eq('SubType/Id', '1')
 *  .toString();
 * // returns `(TypeId eq '1') or (SubType/Id eq 1)`
 *
 * @param {string} [condition='and'] - base condition ('and' OR 'or').
 * @returns {ODataFilterBuilder} The {@link ODataFilterBuilder} instance.
 */
function ODataFilterBuilder(condition = AND) {
  if (!(this instanceof ODataFilterBuilder)) {
    return new ODataFilterBuilder(condition);
  }

  this.condition = condition;
  this.source = {
    condition: condition,
    rules: []
  };
}

/**
 * Creates new {@link ODataFilterBuilder} instance with 'and' as base condition
 * @example
 * f.and()
 *  .eq('a', 1)
 *  .eq('b', 2)
 *  .toString();
 * // (a eq 1) and (b eq 2)
 * @returns {ODataFilterBuilder} The {@link ODataFilterBuilder} instance with 'and' as base condition
 */
ODataFilterBuilder.and = () => new ODataFilterBuilder(AND);

/**
 * Create new {@link ODataFilterBuilder} with 'or' as base condition
 * @example
 * f.or()
 *  .eq('a', 1)
 *  .eq('b', 2)
 *  .toString();
 * // (a eq 1) or (b eq 2)
 * @returns {ODataFilterBuilder} The {@link ODataFilterBuilder} instance with 'or' as base condition
 */
ODataFilterBuilder.or = () => new ODataFilterBuilder(OR);

/**
 * Canonical Functions
 * @memberof ODataFilterBuilder
 * @namespace ODataFilterBuilder.functions
 * @type {ODataFilterBuilder.functions}
 */
ODataFilterBuilder.functions = {
  /**
   * The length function returns the number of characters in the parameter value.
   * @example
   * f().eq(x => x.length('CompanyName'), 19)
   * // length(CompanyName) eq 19
   * @param {ODataFilterBuilder~InputField} field - Field
   * @returns {string} A function string
   */
  length(field) {
    return _function('length', field);
  },

  /**
   * The tolower function returns the input parameter string value with all uppercase characters converted to lowercase.
   * @example
   * f().eq(x => x.toLower('CompanyName'), 'alfreds futterkiste')
   * // tolower(CompanyName) eq 'alfreds futterkiste'
   * @param {ODataFilterBuilder~InputField} field - Field
   * @returns {string} A function string
   */
  toLower(field) {
    return _function('tolower', field);
  },

  /**
   * The toupper function returns the input parameter string value with all lowercase characters converted to uppercase.
   * @example
   * f().eq(x => x.toUpper('CompanyName'), 'ALFREDS FUTTERKISTE')
   * // toupper(CompanyName) eq 'ALFREDS FUTTERKISTE'
   * @param {ODataFilterBuilder~InputField} field - Field
   * @returns {string} A function string
   */
  toUpper(field) {
    return _function('toupper', field);
  },

  /**
   * The trim function returns the input parameter string value with all leading and trailing whitespace characters, removed.
   * @example
   * f().eq(x => x.trim('CompanyName'), 'CompanyName')
   * // trim(CompanyName) eq CompanyName
   * @param {ODataFilterBuilder~InputField} field - Field
   * @returns {string} A function string
   */
  trim(field) {
    return _function('trim', field);
  },

  /**
   * The indexof function returns the zero-based character position of the first occurrence of the second parameter value in the first parameter value.
   * @example
   * f().eq(f.functions.indexOf('CompanyName', 'lfreds'), 1)
   * f().eq(x => x.indexOf('CompanyName', 'lfreds'), 1)
   * // indexof(CompanyName,'lfreds') eq 1
   *
   * @param {ODataFilterBuilder~InputField} field - The first function parameter
   * @param {string} value - The second function parameter
   *
   * @returns {string} A function string
   */
  indexOf(field, value) {
    return _function('indexof', field, [value]);
  },

  /**
   * @example
   * f().eq(f.functions.substring('CompanyName', 1), 'lfreds Futterkiste');
   * f().eq(x => x.substring('CompanyName', 1), 'lfreds Futterkiste');
   * // substring(CompanyName, 1) eq 'lfreds Futterkiste'
   *
   * @example
   * f().eq(x => x.substring('CompanyName', 1, 2), 'lf').toString();
   * f().eq(f.functions.substring('CompanyName', 1, 2), 'lf')
   * // substring(CompanyName, 1, 2) eq 'lf'
   *
   * @param {ODataFilterBuilder~InputField} field - The first function parameter
   * @param {...number} values - Second or second and third function parameters
   *
   * @returns {string} A function string
   */
  substring(field, ...values) {
    return _function('substring', field, values);
  },

  /**
   * @param {ODataFilterBuilder~InputField} field - The first function parameter
   * @param {string} value - The second function parameter
   * @param {boolean} [normaliseValue=true] - Convert string "value" to "'value'" or not. (Convert by default)
   * @example
   * f().eq(x => x.concat(y => y.concat('City',', '), 'Country', false), 'Berlin, Germany');
   * // concat(concat(City, ', '), 'Country') eq 'Berlin, Germany'
   * @returns {string} A function string
   */
  concat(field, value, normaliseValue) {
    return _function('concat', field, [value], normaliseValue);
  }
};

ODataFilterBuilder.prototype = {
  constructor: ODataFilterBuilder,

  /**
   * The 'add' method adds new filter rule with AND or OR condition
   * if condition not provided. Source condition is used (AND by default)
   * @this {ODataFilterBuilder}
   * @param {ODataFilterBuilder~InputRule} rule - Rule to add
   * @param {string} [condition] - Condition for rule to add(and/or)
   * @returns {ODataFilterBuilder} The {@link ODataFilterBuilder} instance
   * @private
   */
  _add(rule, condition = this.condition) {
    // NOTE: if condition not provider, source condition uses
    this.source = _add(this.source, _inputRuleToString(rule), condition);
    return this;
  },

  /*
   * Logical Operators
   */

  /**
   * Logical And
   * @param {ODataFilterBuilder~InputRule} rule - Rule to add
   * @returns {ODataFilterBuilder} The {@link ODataFilterBuilder} instance
   */
  and(rule) {
    return this._add(rule, AND);
  },

  /**
   * Logical Or
   * @param {ODataFilterBuilder~InputRule} rule - Rule to add
   * @returns {ODataFilterBuilder} The {@link ODataFilterBuilder} instance
   */
  or(rule) {
    return this._add(rule, OR);
  },

  /**
   * Logical Negation
   * @param {ODataFilterBuilder~InputRule} rule - Rule to add
   * @returns {ODataFilterBuilder} The {@link ODataFilterBuilder} instance
   */
  not(rule) {
    return this._add(_not(rule));
  },

  /**
   * Logical compare field and value by operator
   * @param {ODataFilterBuilder~InputField} field - Field to compare
   * @param {string} operator - Comparison operator
   * @param {string|number|*} value - A value to compare with
   * @param {boolean} [normaliseValue=true] - Convert string "value" to "'value'" or not. (Convert by default)
   * @private
   * @returns {ODataFilterBuilder} The {@link ODataFilterBuilder} instance
   */
  _compare(field, operator, value, normaliseValue) {
    return this._add(_compare(field, operator, value, normaliseValue));
  },

  /**
   * Equal
   * @param {ODataFilterBuilder~InputField} field - Field to compare
   * @param {string|number|*} value - A value to compare with
   * @param {boolean} [normaliseValue=true] - Convert string "value" to "'value'" or not. (Convert by default)
   * @returns {ODataFilterBuilder} The {@link ODataFilterBuilder} instance
   */
  eq(field, value, normaliseValue) {
    return this._compare(field, 'eq', value, normaliseValue);
  },

  /**
   * Not Equal
   * @param {ODataFilterBuilder~InputField} field - Field to compare
   * @param {string|number|*} value - A value to compare with
   * @param {boolean} [normaliseValue=true] - Convert string "value" to "'value'" or not. (Convert by default)
   * @returns {ODataFilterBuilder} The {@link ODataFilterBuilder} instance
   */
  ne(field, value, normaliseValue) {
    return this._compare(field, 'ne', value, normaliseValue);
  },

  /**
   * Greater Than
   * @param {ODataFilterBuilder~InputField} field - Field to compare
   * @param {string|number|*} value - A value to compare with
   * @param {boolean} [normaliseValue=true] - Convert string "value" to "'value'" or not. (Convert by default)
   * @returns {ODataFilterBuilder} The {@link ODataFilterBuilder} instance
   */
  gt(field, value, normaliseValue) {
    return this._compare(field, 'gt', value, normaliseValue);
  },

  /**
   * Greater than or Equal
   * @param {ODataFilterBuilder~InputField} field - Field to compare
   * @param {string|number|*} value - A value to compare with
   * @param {boolean} [normaliseValue=true] - Convert string "value" to "'value'" or not. (Convert by default)
   * @returns {ODataFilterBuilder} The {@link ODataFilterBuilder} instance
   */
  ge(field, value, normaliseValue) {
    return this._compare(field, 'ge', value, normaliseValue);
  },

  /**
   * Less Than
   * @param {ODataFilterBuilder~InputField} field - Field to compare
   * @param {string|number|*} value - A value to compare with
   * @param {boolean} [normaliseValue=true] - Convert string "value" to "'value'" or not. (Convert by default)
   * @returns {ODataFilterBuilder} The {@link ODataFilterBuilder} instance
   */
  lt(field, value, normaliseValue) {
    return this._compare(field, 'lt', value, normaliseValue);
  },

  /**
   * Less than or Equal
   * @param {ODataFilterBuilder~InputField} field - Field to compare
   * @param {string|number|*} value - A value to compare with
   * @param {boolean} [normaliseValue=true] - Convert string "value" to "'value'" or not. (Convert by default)
   * @returns {ODataFilterBuilder} The {@link ODataFilterBuilder} instance
   */
  le(field, value, normaliseValue) {
    return this._compare(field, 'le', value, normaliseValue);
  },

  /**
   * @param {ODataFilterBuilder~InputField} field - Field to compare
   * @param {string[]|string} values - Values to compare with
   * @param {boolean} [normaliseValues=true] - Convert string "value" to "'value'" or not. (Convert by default)
   * @returns {ODataFilterBuilder} The {@link ODataFilterBuilder} instance
   */
  in(field, values, normaliseValues) {
    return this._add(_joinRules(_compareMap(field, 'eq', values, normaliseValues), OR));
  },

  /**
   * @param {ODataFilterBuilder~InputField} field - Field to compare
   * @param {Array} values - Values to compare with
   * @param {boolean} [normaliseValues=true] - Convert string "value" to "'value'" or not. (Convert by default)
   * @returns {ODataFilterBuilder} The {@link ODataFilterBuilder} instance
   */
  notIn(field, values, normaliseValues) {
    return this.not(rule => rule.in(field, values, normaliseValues));
  },

  // Canonical Functions

  /**
   * The contains function returns true if the second parameter string value is a substring of the first parameter string value.
   * @example
   * // return contains(CompanyName, 'Alfreds')
   * @param {ODataFilterBuilder~InputField} field - Field to compare
   * @param {string} value - Value to compare
   * @returns {ODataFilterBuilder} The {@link ODataFilterBuilder} instance
   */
  contains(field, value) {
    return this._add(_function('contains', field, value));
  },

  /**
   * The startswith function returns true if the first parameter string value starts with the second parameter string value.
   * @example
   * // return startswith(CompanyName,'Alfr')
   * @param {ODataFilterBuilder~InputField} field - Field to compare
   * @param {string} value - Value to compare
   * @returns {ODataFilterBuilder} The {@link ODataFilterBuilder} instance
   */
  startsWith(field, value) {
    return this._add(_function('startswith', field, value));
  },

  /**
   * The endswith function returns true if the first parameter string value ends with the second parameter string value.
   * @example
   * // return endswith(CompanyName,'Futterkiste')
   * @param {ODataFilterBuilder~InputField} field - Field to compare
   * @param {string} value - Value to compare
   * @returns {ODataFilterBuilder} The {@link ODataFilterBuilder} instance
   */
  endsWith(field, value) {
    return this._add(_function('endswith', field, value));
  },

  /**
   * Convert filter builder instance to string
   * @this {ODataFilterBuilder}
   * @returns {string} A source string representation
   */
  toString() {
    //return source;
    return _sourceRuleToString(this.source);
  }
};

export default ODataFilterBuilder;