exports.Validatable = Validatable; /** * Validation encapsulated in this abstract class. * * Basically validation configurators is just class methods, which adds validations * configs to AbstractClass._validations. Each of this validations run when * `obj.isValid()` method called. * * Each configurator can accept n params (n-1 field names and one config). Config * is {Object} depends on specific validation, but all of them has one common part: * `message` member. It can be just string, when only one situation possible, * e.g. `Post.validatesPresenceOf('title', { message: 'can not be blank' });` * * In more complicated cases it can be {Hash} of messages (for each case): * `User.validatesLengthOf('password', { min: 6, max: 20, message: {min: 'too short', max: 'too long'}});` */ function Validatable() { // validatable class }; /** * Validate presence. This validation fails when validated field is blank. * * Default error message "can't be blank" * * @example presence of title * ``` * Post.validatesPresenceOf('title'); * ``` * @example with custom message * ``` * Post.validatesPresenceOf('title', {message: 'Can not be blank'}); * ``` * * @sync * * @nocode * @see helper/validatePresence */ Validatable.validatesPresenceOf = getConfigurator('presence'); /** * Validate length. Three kinds of validations: min, max, is. * * Default error messages: * * - min: too short * - max: too long * - is: length is wrong * * @example length validations * ``` * User.validatesLengthOf('password', {min: 7}); * User.validatesLengthOf('email', {max: 100}); * User.validatesLengthOf('state', {is: 2}); * User.validatesLengthOf('nick', {min: 3, max: 15}); * ``` * @example length validations with custom error messages * ``` * User.validatesLengthOf('password', {min: 7, message: {min: 'too weak'}}); * User.validatesLengthOf('state', {is: 2, message: {is: 'is not valid state name'}}); * ``` * * @sync * @nocode * @see helper/validateLength */ Validatable.validatesLengthOf = getConfigurator('length'); /** * Validate numericality. * * @example * ``` * User.validatesNumericalityOf('age', { message: { number: '...' }}); * User.validatesNumericalityOf('age', {int: true, message: { int: '...' }}); * ``` * * Default error messages: * * - number: is not a number * - int: is not an integer * * @sync * @nocode * @see helper/validateNumericality */ Validatable.validatesNumericalityOf = getConfigurator('numericality'); /** * Validate inclusion in set * * @example * ``` * User.validatesInclusionOf('gender', {in: ['male', 'female']}); * User.validatesInclusionOf('role', { * in: ['admin', 'moderator', 'user'], message: 'is not allowed' * }); * ``` * * Default error message: is not included in the list * * @sync * @nocode * @see helper/validateInclusion */ Validatable.validatesInclusionOf = getConfigurator('inclusion'); /** * Validate exclusion * * @example `Company.validatesExclusionOf('domain', {in: ['www', 'admin']});` * * Default error message: is reserved * * @nocode * @see helper/validateExclusion */ Validatable.validatesExclusionOf = getConfigurator('exclusion'); /** * Validate format * * Default error message: is invalid * * @nocode * @see helper/validateFormat */ Validatable.validatesFormatOf = getConfigurator('format'); /** * Validate using custom validator * * Default error message: is invalid * * @nocode * @see helper/validateCustom */ Validatable.validate = getConfigurator('custom'); /** * Validate using custom async validator * * Default error message: is invalid * * @async * @nocode * @see helper/validateCustom */ Validatable.validateAsync = getConfigurator('custom', {async: true}); /** * Validate uniqueness * * Default error message: is not unique * * @async * @nocode * @see helper/validateUniqueness */ Validatable.validatesUniquenessOf = getConfigurator('uniqueness', {async: true}); // implementation of validators /** * Presence validator */ function validatePresence(attr, conf, err) { if (blank(this[attr])) { err(); } } /** * Length validator */ function validateLength(attr, conf, err) { if (nullCheck.call(this, attr, conf, err)) return; var len = this[attr].length; if (conf.min && len < conf.min) { err('min'); } if (conf.max && len > conf.max) { err('max'); } if (conf.is && len !== conf.is) { err('is'); } } /** * Numericality validator */ function validateNumericality(attr, conf, err) { if (nullCheck.call(this, attr, conf, err)) return; if (typeof this[attr] !== 'number') { return err('number'); } if (conf.int && this[attr] !== Math.round(this[attr])) { return err('int'); } } /** * Inclusion validator */ function validateInclusion(attr, conf, err) { if (nullCheck.call(this, attr, conf, err)) return; if (!~conf.in.indexOf(this[attr])) { err() } } /** * Exclusion validator */ function validateExclusion(attr, conf, err) { if (nullCheck.call(this, attr, conf, err)) return; if (~conf.in.indexOf(this[attr])) { err() } } /** * Format validator */ function validateFormat(attr, conf, err) { if (nullCheck.call(this, attr, conf, err)) return; if (typeof this[attr] === 'string') { if (!this[attr].match(conf['with'])) { err(); } } else { err(); } } /** * Custom validator */ function validateCustom(attr, conf, err, done) { conf.customValidator.call(this, err, done); } /** * Uniqueness validator */ function validateUniqueness(attr, conf, err, done) { var cond = {where: {}}; cond.where[attr] = this[attr]; this.constructor.all(cond, function (error, found) { if (found.length > 1) { err(); } else if (found.length === 1 && found[0].id != this.id) { err(); } done(); }.bind(this)); } var validators = { presence: validatePresence, length: validateLength, numericality: validateNumericality, inclusion: validateInclusion, exclusion: validateExclusion, format: validateFormat, custom: validateCustom, uniqueness: validateUniqueness }; function getConfigurator(name, opts) { return function () { configure(this, name, arguments, opts); }; } /** * This method performs validation, triggers validation hooks. * Before validation `obj.errors` collection cleaned. * Each validation can add errors to `obj.errors` collection. * If collection is not blank, validation failed. * * @warning This method can be called as sync only when no async validation * configured. It's strongly recommended to run all validations as asyncronous. * * @param {Function} callback called with (valid) * @return {Boolean} true if no async validation configured and all passed * * @example ExpressJS controller: render user if valid, show flash otherwise * ``` * user.isValid(function (valid) { * if (valid) res.render({user: user}); * else res.flash('error', 'User is not valid'), console.log(user.errors), res.redirect('/users'); * }); * ``` */ Validatable.prototype.isValid = function (callback) { var valid = true, inst = this, wait = 0, async = false; // exit with success when no errors if (!this.constructor._validations) { cleanErrors(this); if (callback) { callback(valid); } return valid; } Object.defineProperty(this, 'errors', { enumerable: false, configurable: true, value: new Errors }); this.trigger('validation', function (validationsDone) { var inst = this; this.constructor._validations.forEach(function (v) { if (v[2] && v[2].async) { async = true; wait += 1; validationFailed(inst, v, done); } else { if (validationFailed(inst, v)) { valid = false; } } }); if (!async) { validationsDone(); } var asyncFail = false; function done(fail) { asyncFail = asyncFail || fail; if (--wait === 0 && callback) { validationsDone.call(inst, function () { if( valid && !asyncFail ) cleanErrors(inst); callback(valid && !asyncFail); }); } } }); if (!async) { if (valid) cleanErrors(this); if (callback) callback(valid); return valid; } else { // in case of async validation we should return undefined here, // because not all validations are finished yet return; } }; function cleanErrors(inst) { Object.defineProperty(inst, 'errors', { enumerable: false, configurable: true, value: false }); } function validationFailed(inst, v, cb) { var attr = v[0]; var conf = v[1]; var opts = v[2] || {}; if (typeof attr !== 'string') return false; // here we should check skip validation conditions (if, unless) // that can be specified in conf if (skipValidation(inst, conf, 'if')) return false; if (skipValidation(inst, conf, 'unless')) return false; var fail = false; var validator = validators[conf.validation]; var validatorArguments = []; validatorArguments.push(attr); validatorArguments.push(conf); validatorArguments.push(function onerror(kind) { var message; if (conf.message) { message = conf.message; } if (!message && defaultMessages[conf.validation]) { message = defaultMessages[conf.validation]; } if (!message) { message = 'is invalid'; } if (kind) { if (message[kind]) { // get deeper message = message[kind]; } else if (defaultMessages.common[kind]) { message = defaultMessages.common[kind]; } } inst.errors.add(attr, message); fail = true; }); if (cb) { validatorArguments.push(function () { cb(fail); }); } validator.apply(inst, validatorArguments); return fail; } function skipValidation(inst, conf, kind) { var doValidate = true; if (typeof conf[kind] === 'function') { doValidate = conf[kind].call(inst); if (kind === 'unless') doValidate = !doValidate; } else if (typeof conf[kind] === 'string') { if (inst.hasOwnProperty(conf[kind])) { doValidate = inst[conf[kind]]; if (kind === 'unless') doValidate = !doValidate; } else if (typeof inst[conf[kind]] === 'function') { doValidate = inst[conf[kind]].call(inst); if (kind === 'unless') doValidate = !doValidate; } else { doValidate = kind === 'if'; } } return !doValidate; } var defaultMessages = { presence: 'can\'t be blank', length: { min: 'too short', max: 'too long', is: 'length is wrong' }, common: { blank: 'is blank', 'null': 'is null' }, numericality: { 'int': 'is not an integer', 'number': 'is not a number' }, inclusion: 'is not included in the list', exclusion: 'is reserved', uniqueness: 'is not unique' }; function nullCheck(attr, conf, err) { var isNull = this[attr] === null || !(attr in this); if (isNull) { if (!conf.allowNull) { err('null'); } return true; } else { if (blank(this[attr])) { if (!conf.allowBlank) { err('blank'); } return true; } } return false; } /** * Return true when v is undefined, blank array, null or empty string * otherwise returns false * * @param {Mix} v * @returns {Boolean} whether `v` blank or not */ function blank(v) { if (typeof v === 'undefined') return true; if (v instanceof Array && v.length === 0) return true; if (v === null) return true; if (typeof v == 'string' && v === '') return true; return false; } function configure(cls, validation, args, opts) { if (!cls._validations) { Object.defineProperty(cls, '_validations', { writable: true, configurable: true, enumerable: false, value: [] }); } args = [].slice.call(args); var conf; if (typeof args[args.length - 1] === 'object') { conf = args.pop(); } else { conf = {}; } if (validation === 'custom' && typeof args[args.length - 1] === 'function') { conf.customValidator = args.pop(); } conf.validation = validation; args.forEach(function (attr) { cls._validations.push([attr, conf, opts]); }); } function Errors() { } Errors.prototype.add = function (field, message) { if (!this[field]) { this[field] = [message]; } else { this[field].push(message); } };