data/validation.js

/*
 * Your installation or use of this SugarCRM file is subject to the applicable
 * terms available at
 * http://support.sugarcrm.com/Resources/Master_Subscription_Agreements/.
 * If you do not agree to all of the applicable terms or do not have the
 * authority to bind the entity as an authorized representative, then do not
 * install or use this SugarCRM file.
 *
 * Copyright (C) SugarCRM Inc. All rights reserved.
 */

const DateUtils = require('../utils/date');
const User = require('../core/user');
const Utils = require('../utils/utils');
const Language = require('../core/language');

/**
 * Validation module.
 *
 * The validation module is used by {@link Data/Bean#doValidate}.
 * Each bean field is validated by each of the validators specified in the
 * {@link Data.Validation.validators} hash.
 *
 * The bean is also checked for required fields by
 * {@link Data.Validation#requiredValidator}.
 *
 * @module Data/Validation
 */

function makeValidators() {
    /**
     * Validates whether the given value meets a max/min value requirement.
     *
     * @param {Object} field Bean field metadata.
     * @param {string} value Bean field value (a number).
     * @param {string} type The type of requirement that must be met. Possible
     *   choices are 'max', 'min', 'greaterthan', and 'lessthan'.
     * @return {number|undefined} The numerical value of the limit if the
     *   requirement is not met and `undefined` if it is.
     * @private
     */
    var _minMaxValue = function(field, value, type) {
        var limit = _.isUndefined(field[type]) ?
            (field.validation ? field.validation[type] : null) : field[type];
        if (_.contains(['int', 'float', 'decimal', 'currency'], field.type) && _.isFinite(limit)) {
            if (field.type == 'int') {
                value = parseInt(value);
            } else {
                value = parseFloat(value);
            }
            if (type == 'max') {
                if (value > limit) return limit;
            } else if (type == 'min') {
                if (value < limit) return limit;
            } else if (type == 'greaterthan') {
                if (value <= limit) return limit;
            } else if (type == 'lessthan') {
                if (value >= limit) return limit;
            }
        }
    };

    // Helper that validates the given date is before/after the date of another field
    var _isBeforeAfter = function(field, value, type, model) {
        var validatableTypes = ['date', 'datetimecombo'];
        if(field.validation && field.validation.type === type &&
            ((field.validation.datatype && _.contains(validatableTypes, field.validation.datatype)) ||
                (_.contains(validatableTypes, field.type)))) {
            var compareTo = model.fields[field.validation.compareto];
            if(!_.isUndefined(compareTo) &&
                ((field.validation.datatype && _.contains(validatableTypes, field.validation.datatype)) ||
                    (_.contains(validatableTypes, compareTo.type)))) {
                var compareToValue = Date.parse(model.get(compareTo.name));
                value = Date.parse(value.toString());
                if(!_.isNaN(compareToValue) && !_.isNaN(value)) {
                    var compareToLabel = Language.get(compareTo.label || compareTo.vname || compareTo.name,
                        model.module);
                    if(type == "isbefore") {
                        return compareToValue < value ? compareToLabel : undefined;
                    }
                    if(type == "isafter") {
                        return compareToValue > value ? compareToLabel : undefined;
                    }
                }
            }
        }
    };

    /**
     * A hash of validators. Each validator function must return an error
     * definition if validation fails and `undefined` if it succeeds.
     *
     * Error definitions can be primitives value such as max length or an
     * array, such as a range's lower and upper limits.
     * Validator functions accept field metadata and the value to be validated.
     *
     * @class
     * @name Data/Validation.Validators
     */
    return {
        /**
         * Validates the maximum length of a given value.
         *
         * @param {string} field Bean field metadata.
         * @param {string|number} value Bean field value.
         * @return {number|undefined} Maximum length or `undefined` if the
         *   field is valid.
         * @memberOf Data/Validation.Validators
         */
        maxLength: function(field, value) {
            if(_.isNumber(value)){
                value = value.toString();
            }
            if (_.isNumber(field.len)  && _.isString(value)) {
                var maxLength = field.len;
                value = value || "";
                value = value.toString();
                if (value.length > maxLength) {
                    return maxLength;
                }
            }
        },

        /**
         * Validates the minimum length of a given value.
         *
         * @param {Object} field Bean field metadata.
         * @param {string} value Bean field value.
         * @return {number|undefined} Minimum length or `undefined` if the
         *   field is valid.
         * @memberOf Data/Validation.Validators
         */
        minLength: function(field, value) {
            if (_.isNumber(field.minlen)) { // TODO: Not sure what the proper property is if there is one
                var minLength = field.minlen;
                value = value || "";
                value = value.toString();

                if (value.length < minLength) {
                    return minLength;
                }
            }
        },

        /**
         * Validates that a given value is a valid URL.
         * Note that is impossible to do full validation of URLs in JavaScript.
         *
         * **This function has been a no-op since 6.7. Do NOT use it.**
         *
         * @param {Object} field Bean field metadata.
         * @param {string} value Bean field value.
         * @deprecated Since 7.10
         * @memberOf Data/Validation.Validators
         */
        url: function(field, value) {
            SUGAR.App.logger.warn('The function `app.validation.validators.url()` is deprecated since 7.10 ' +
                'because it has no effect.');
        },

        /**
         * Validates that a given value contains only valid email address.
         * Note that it is impossible to do full validation of email addresses
         * in JavaScript.
         *
         * @param {Object} field Bean field metadata.
         * @param {Object[]} emails Bean field value which is an array of email
         *   objects.
         * @return {string[]|undefined} Array of invalid email addresses or
         *   `undefined` if the addresses are all valid.
         * @memberOf Data/Validation.Validators
         */
        email: function(field, emails) {
            var results;
            if (field.type == 'email' || field.type === 'email-text') {
                if (emails.length > 0) {
                    _.each(emails, function(email) {
                        // if email is blank but not required, let it go
                        if (email.email_address === '' && (_.isUndefined(field.required) || !field.required)) {
                            return;
                        }
                        if (!Utils.isValidEmailAddress(email.email_address)) {
                            if (!results) results = [];
                            results.push(email.email_address);
                        }
                    });
                }
                if (results && results.length > 0) {
                    return results;
                }
            }
        },

        /**
         * Validates that a given email array has at least one email set as the
         * primary email.
         *
         * @param {Object} field Bean field metadata.
         * @param {Object[]} emails Bean field value which is an array of email
         *   objects.
         * @return {boolean|undefined} `true` if there is no primary email set
         *   or `undefined` if at least one of the emails is the primary
         *   email.
         * @memberOf Data/Validation.Validators
         */
        primaryEmail: function(field, emails) {
            if (field.type == "email") {
                if (emails.length > 0 &&
                    !_.find(emails, function(email) { return email.primary_address == "1"; })) {
                    return true;
                }
            }
        },

        /**
         * Validates that a given email array has no duplicate email addresses.
         *
         * @param {Object} field Bean field metadata.
         * @param {object[]} emails Bean field value which is an array of email
         *   objects.
         * @return {string[]|undefined} Array of duplicated email addresses or
         *   `undefined` if there are no duplicates.
         * @memberOf Data/Validation.Validators
         */
        duplicateEmail: function(field, emails) {
            if (field.type == "email") {
                var values = _.pluck(emails, "email_address"),
                    duplicates = [],
                    n = values.length,
                    i, j;
                // to ensure the fewest possible comparisons
                for (i = 0; i < n; i++) {                      // outer loop uses each item i at 0 through n
                    for (j = i + 1; j < n; j++) {              // inner loop only compares items j at i+1 to n
                        if (values[i] == values[j]) duplicates.push(values[i]);
                    }
                }
                if (duplicates && duplicates.length > 0) {
                    return duplicates;
                }
            }
        },

        /**
         * Validates that a given value is a real date or datetime.
         *
         * @param {Object} field Bean field metadata.
         * @param {string} value Date or datetime value as string.
         * @return {string|undefined} The invalid date/datetime or `undefined`
         *   if it is a valid date.
         * @memberOf Data/Validation.Validators
         */
        datetime: function(field, value){
            var val, invalidNumberOfDigits, format, sep, formatParts, parts, i, len;

            function inRange(val, min, max) {
                var value = parseInt(val, 10);
                return (!isNaN(value) && value >= min && value <= max);
            }

            if(field.type === "date" || field.type === "datetimecombo") {
                // First check will short circuit (falsy) if the value is a valid server ISO date string.
                // For datepicker values, however, we need the second check since Safari chokes on '.', '-'
                if(_.isNaN(Date.parse(value)) && _.isNaN(Date.parse(value.replace(/[\.\-]/g, '/')))) {
                    return value;
                } else {

                    // Check for valid date parts for non ISO dates as IE and FF both successfully parse
                    // 2014/13/22 simply wrapping extra months around to following year (so previous example
                    // becomes 2015/01/22).
                    if (!DateUtils.isIso(value)) {
                        // The first set of Date.parse conditionals will negate three digit days or months
                        // but 3 digit years are valid for JavaScript dates so they'll slip through. The reason
                        // we explicitly invalidate 3 digit years is datepicker auto corrects 1 and 2 digit years
                        // in yyyy but cannot do anything sensible with 3 digit years. Moreover, it was decided
                        // that it's much more likely a 3 digit years is a user entry error; they don't really
                        // intend to enter a date year (e.g. 100-999 A.D.). Also any part > 4 digits is considered
                        // invalid as well since we only support:
                        // 2010-12-23, Y-m-d
                        // 12-23-2010, m-d-Y
                        // 23-12-2010, d-m-Y
                        // 2010/12/23, Y/m/d
                        // 12/23/2010, m/d/Y
                        // 23/12/2010, d/m/Y
                        // 2010.12.23, Y.m.d
                        // 23.12.2010, d.m.Y
                        // 12.23.2010, m.d.Y
                        parts = value.replace(/[\.\-]/g, '/').split('/');
                        invalidNumberOfDigits = _.filter(parts,
                            (part) => part.length === 3 || part.length > 4
                        );

                        if (invalidNumberOfDigits.length) {
                            return value;
                        }

                        // Invalidate consecutive separators e.g. 12--23--2013
                        if (/([\.\/\-])\1/.test(value) === true) {
                            return value;
                        }

                        // Lastly, validate month and day ranges
                        format = User.getPreference('datepref');
                        sep = format.match(/[.\/\-\s].*?/);
                        formatParts = format.split(sep);
                        for(i=0, len=formatParts.length; i<len; i++) {
                            val = parts[i];
                            switch(formatParts[i].toLowerCase().charAt(0)) {
                                case 'm':
                                    if (!inRange(val, 1, 12)) {
                                        return value;
                                    }
                                    break;
                                case 'd':
                                    if (!inRange(val, 1, 31)) {
                                        return value;
                                    }
                                    break;
                            }
                        }
                    } else {
                        // The datepicker plugin will leave 3 digit years and this validation is supposed to
                        // invalidate; but to iso date will turn that to something like: 0201-01-31T08:00:00.000Z
                        // We have to reject 100-999 to be consistent with the rest of our date year validation.
                        if (value.charAt(0) === '0') return value;
                    }
                }
            }
        },

        /**
         * Validates minimum integer values.
         *
         * @param {Object} field Bean field metadata.
         * @param {string} value Field value which is a number.
         * @return {number|undefined} Value of the actual min if the limit is
         *   not met and `undefined` if it is.
         * @memberOf Data/Validation.Validators
         */
        minValue: function(field, value) {
            return _minMaxValue(field, value, 'min');
        },

        /**
         * Validates maximum integer values.
         *
         * @param {Object} field Bean field metadata.
         * @param {string} value Field value which is a number.
         * @return {number|undefined} Value of the actual max if the limit is
         *   not met and `undefined` if it is.
         * @memberOf Data/Validation.Validators
         */
        maxValue: function(field, value) {
            return _minMaxValue(field, value, 'max');
        },

        /**
         * Validates a value to make sure it's larger than a given value.
         *
         * @param {Object} field Bean field metadata.
         * @param {string} value Field value which is a number.
         * @return {number|undefined} Value that must be exceeded if the limit
         *   is not met and `undefined` if it is.
         * @memberOf Data/Validation.Validators
         */
        greaterThan: function(field, value) {
            return _minMaxValue(field, value, 'greaterthan');
        },

        /**
         * Validates a value to make sure it's less than a given value.
         *
         * @param {Object} field Bean field metadata.
         * @param {string} value Field value which is a number.
         * @return {number|undefined} Value that `value` must be less than if
         *   the limit is not met and `undefined` if it is.
         * @memberOf Data/Validation.Validators
         */
        lessThan: function(field, value) {
            return _minMaxValue(field, value, 'lessthan');
        },

        /**
         * Validates numeric values.
         *
         * @param {Object} field Bean field metadata.
         * @param {string} value field value which is an integer
         * @return {boolean|undefined} `true` if `value` is invalid,
         *   `undefined` otherwise.
         * @memberOf Data/Validation.Validators
         */
        number: function(field, value) {
            if (_.indexOf(['int', 'float', 'decimal', 'currency'], field.type) != -1) {
                return (_.isBoolean(value) || (_.isString(value) && !value.trim().length) ||
                isNaN(parseFloat(value)) || !_.isFinite(value)) ?
                    true : undefined;
            }
        },

        /**
         * Validates that the given date is before the date of another field.
         *
         * @param {Object} field Bean field metadata.
         * @param {string} value Field value which is an integer.
         * @param {Object} model Model.
         * @return {string|undefined} Compare field label if it is invalid
         *   and `undefined` otherwise.
         * @memberOf Data/Validation.Validators
         */
        isBefore: function(field, value, model) {
            return _isBeforeAfter(field, value, 'isbefore', model);
        },

        /**
         * Validates that the given date is after the date of another field.
         *
         * @param {Object} field Bean field metadata.
         * @param {string} value Field value which is an integer.
         * @param {Object} model Model.
         * @return {string} Compare field label if is invalid, `undefined`
         *   otherwise.
         * @memberOf Data/Validation.Validators
         */
        isAfter: function(field, value, model) {
            return _isBeforeAfter(field, value, 'isafter', model);
        }
    };
}

/**
 * Checks if the given array contains only empty values.
 *
 * @param {Array} value Array to check.
 * @return {boolean} `true` if all of the array's elements are empty
 *   and `false` otherwise.
 * @private
 */
function isArrayEmpty(value) {
    return _.every(value, _.isEmpty);
}

/**
 * @alias module:Data/Validation
 */
const Validation = {
    /**
     * @type {Data/Validation.Validators}
     */
    validators: makeValidators(),

    /**
     * Validates if the required field is set on a bean or about to be set.
     *
     * @param {Object} field Bean field metadata.
     * @param {string} fieldName Bean field name.
     * @param {Data/Bean} model Bean instance.
     * @param {string} value Value to be set.
     * @return {boolean} `true` if the validation fails, `undefined` otherwise.
     */
    requiredValidator: function(field, fieldName, model, value) {
        // Image type fields have their own requiredValidator
        if ((field.required === true) && (fieldName !== 'id') &&
            (field.type !== 'image') &&
            _.isUndefined(field.auto_increment)
        ) {
            var currentValue = model.get(fieldName);
            var currentUndefined = _.isUndefined(currentValue);
            var valueEmpty = _.isNull(value) ||
                value === '' ||
                value === false ||
                (_.isArray(value) && isArrayEmpty(value)) ||
                (value instanceof Backbone.Collection && !value.length);

            // Remove validation for relate/flex relate if name is erased
            if (field.id_name && model.get(field.id_name)) {
                return;
            }

            if ((currentUndefined && _.isUndefined(value)) || valueEmpty) {
                return true;
            }
        }
    },

    _isArrayEmpty: function(value) {
        if (!SUGAR.App.config.sidecarCompatMode) {
            SUGAR.App.logger.error('Data.Validation#_isArrayEmpty is a private method that you are not allowed ' +
                'to access. Please use only the public API.');
            return;
        }

        SUGAR.App.logger.warn('Data.Validation#_isArrayEmpty is a private method that you should not access. ' +
            'You will NOT be allowed to access it in the next release. Please update your code to rely on the public ' +
            'API only.');

        return isArrayEmpty(value);
    }
};

module.exports = Validation;