jugglingdb
Version:
Node.js ORM for every database: redis, mysql, mongodb, postgres, sqlite, ...
692 lines (614 loc) • 17.1 kB
JavaScript
'use strict';
/**
* Module exports
*/
exports.ValidationError = ValidationError;
/**
* Validation mixins for model.js
*
* 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'}});`
*/
const Validatable = require('./model.js');
/**
* 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
*
* Example:
*
* User.validate('name', customValidator, {message: 'Bad name'});
* function customValidator(err) {
* if (this.name === 'bad') err();
* });
* var user = new User({name: 'Peter'});
* user.isValid(); // true
* user.name = 'bad';
* user.isValid(); // false
*
* @nocode
* @see helper/validateCustom
*/
Validatable.validate = getConfigurator('custom');
/**
* Validate using custom async validator
*
* Default error message: is invalid
*
* Example:
*
* User.validateAsync('name', customValidator, {message: 'Bad name'});
* function customValidator(err, done) {
* process.nextTick(function() {
* if (this.name === 'bad') err();
* done();
* });
* });
* var user = new User({name: 'Peter'});
* user.isValid(); // false (because async validation setup)
* user.isValid(function(isValid) {
* isValid; // true
* })
* user.name = 'bad';
* user.isValid(); // false
* user.isValid(function(isValid) {
* isValid; // false
* })
*
* @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;
}
const 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 (isNaN(this[attr])) {
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) {
if (nullCheck.call(this, attr, conf, err)) {
return done();
}
const cond = { where: {} };
cond.where[attr] = this[attr];
this.constructor.all(cond, (error, found) => {
if (error) {
return err();
}
if (found.length > 1) {
err();
} else if (found.length === 1 && (!this.id || !found[0].id || found[0].id.toString() !== this.id.toString())) {
err();
}
done();
});
}
const 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');
* });
*
* // when the callback needs two arguments,
* // the first argument is the validation errors,
* // or error from `beforeValidate/afterValidate` hook
* user.isValid(function(error, isValid) {
* error; // error from `beforeValidate`, mostly `null`
* })
* ```
*/
Validatable.prototype.isValid = function(callback, data) {
const inst = this;
let valid = true, wait = 0, async = false;
callback = callback || NOOP;
// callback only needs one arguments, then the signature is `callback(valid)`
// convert it to standard node.js callback style
if (callback.length < 2) {
const _callback = callback;
callback = function(err, valid) {
_callback(valid);
};
}
// exit with success when no errors
if (!this.constructor._validations) {
cleanErrors(this);
if (callback) {
this.trigger('validate', validationsDone => {
validationsDone.call(inst, () => callback(null, valid));
}, data, err => callback(err, false));
}
return valid;
}
Object.defineProperty(this, 'errors', {
enumerable: false,
configurable: true,
value: new Errors(this)
});
this.trigger('validate', function(validationsDone) {
const inst = this;
let asyncFail = false;
this.constructor._validations.forEach(v => {
if (v[2] && v[2].async) {
async = true;
wait += 1;
process.nextTick(() => validationFailed(inst, v, done));
} else if (validationFailed(inst, v)) {
valid = false;
}
});
if (!async) {
validationsDone.call(inst, () => {
if (valid) {
cleanErrors(inst);
}
if (callback) {
callback(valid ? null : new ValidationError(inst), valid);
}
});
}
function done(fail) {
asyncFail = asyncFail || fail;
wait += -1;
if (wait === 0) {
validationsDone.call(inst, () => {
if (valid && !asyncFail) {
cleanErrors(inst);
}
if (callback) {
callback(valid ? null : new ValidationError(inst), valid && !asyncFail);
}
});
}
}
}, data, err => callback(err, false));
if (async) {
// in case of async validation we should return undefined here,
// because not all validations are finished yet
return;
}
return valid;
};
const 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 cleanErrors(inst) {
Object.defineProperty(inst, 'errors', {
enumerable: false,
configurable: true,
value: null
});
}
function validationFailed(inst, v, cb) {
const attr = v[0];
const conf = v[1];
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;
}
let fail = false;
const validator = validators[conf.validation];
const validatorArguments = [];
validatorArguments.push(attr);
validatorArguments.push(conf);
validatorArguments.push(function onerror(kind) {
let message, code = conf.validation;
if (conf.message) {
message = conf.message;
}
if (!message && defaultMessages[conf.validation]) {
message = defaultMessages[conf.validation];
}
if (!message) {
message = 'is invalid';
}
if (kind) {
code += '.' + kind;
if (message[kind]) {
// get deeper
message = message[kind];
} else if (defaultMessages.common[kind]) {
message = defaultMessages.common[kind];
} else {
message = 'is invalid';
}
}
inst.errors.add(attr, message, code);
fail = true;
});
if (cb) {
validatorArguments.push(() => cb(fail));
}
validator.apply(inst, validatorArguments);
return fail;
}
function skipValidation(inst, conf, kind) {
let doValidate = true;
if (typeof conf[kind] === 'function') {
doValidate = conf[kind].call(inst);
if (kind === 'unless') {
doValidate = !doValidate;
}
} else if (typeof conf[kind] === 'string') {
if (typeof inst[conf[kind]] === 'function') {
doValidate = inst[conf[kind]].call(inst);
if (kind === 'unless') {
doValidate = !doValidate;
}
} else if (inst.__data.hasOwnProperty(conf[kind])) {
doValidate = inst[conf[kind]];
if (kind === 'unless') {
doValidate = !doValidate;
}
} else {
doValidate = kind === 'if';
}
}
return !doValidate;
}
function nullCheck(attr, conf, err) {
const isNull = this[attr] === null || !(attr in this);
if (isNull) {
if (!conf.allowNull) {
err('null');
}
return true;
}
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);
let 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(attr => cls._validations.push([attr, conf, opts]));
}
function Errors(obj) {
Object.defineProperty(this, '__codes', {
enumerable: false,
configurable: true,
value: {}
});
Object.defineProperty(this, '__obj', {
enumerable: false,
configurable: true,
value: obj
});
}
Errors.prototype.add = function(field, message, code) {
code = code || 'invalid';
if (!this[field]) {
this[field] = [];
this.__codes[field] = [];
}
this[field].push(message);
this.__codes[field].push(code);
};
Errors.prototype.__localize = function localize(locale) {
const codes = this.__codes;
const errors = this;
const result = {};
const i18n = this.__obj.constructor.i18n;
const v = i18n && i18n[locale] && i18n[locale].validation;
Object.keys(codes).forEach(prop => {
result[prop] = codes[prop].map((code, i) => {
return v && v[prop] && v[prop][code] || errors[prop][i];
});
});
return result;
};
function ValidationError(obj) {
if (!(this instanceof ValidationError)) {
return new ValidationError(obj);
}
this.name = 'ValidationError';
this.message = 'Validation error';
this.statusCode = 400;
this.codes = obj.errors && obj.errors.__codes;
this.context = obj && obj.constructor && obj.constructor.modelName;
Error.call(this);
Error.captureStackTrace(this);
}
function NOOP() {}
ValidationError.prototype.__proto__ = Error.prototype;