diff --git a/README.md b/README.md index d79d35c88..faf65f6cf 100644 --- a/README.md +++ b/README.md @@ -385,6 +385,13 @@ when `false` the validations will stack. e.g. `max` is an exclusive validation, whereas the string `matches` is not. This is helpful for "toggling" validations on and off. - `useCallback`: boolean (default `false`), use the callback interface for asynchrony instead of promises +In the case of mixing exclusive and non-exclusive tests the following logic is used. +If a non-exclusive test is added to a schema with an exclusive test of the same name +the exclusive test is removed and further tests of the same name will be stacked. + +If an exclusive test is added to a schema with non-exclusive tests of the same name +the previous tests are removed and further tests of the same name will replace each other. + ```javascript var schema = yup.mixed().test({ name: 'max', diff --git a/src/array.js b/src/array.js index 01853e8fe..50c614adf 100644 --- a/src/array.js +++ b/src/array.js @@ -1,13 +1,16 @@ 'use strict'; var MixedSchema = require('./mixed') , Promise = require('promise/lib/es6-extensions') + , isAbsent = require('./util/isAbsent') , { mixed, array: locale } = require('./locale.js') , { inherits, collectErrors } = require('./util/_'); let scopeError = value => err => { - err.value = value - throw err - } + err.value = value + throw err +} + +let hasLength = value => !isAbsent(value) && value.length > 0; module.exports = ArraySchema @@ -49,20 +52,19 @@ inherits(ArraySchema, MixedSchema, { endEarly = schema._option('abortEarly', _opts) recursive = schema._option('recursive', _opts) - return MixedSchema.prototype._validate.call(this, _value, _opts, _state) .catch(endEarly ? null : err => { errors = err return err.value }) .then(function(value){ - if ( !recursive || !subType || !schema._typeCheck(value) ) { - if ( errors.length ) throw errors[0] + if (!recursive || !subType || !schema._typeCheck(value) ) { + if (errors.length) throw errors[0] return value } let result = value.map((item, key) => { - var path = (_state.path || '') + '['+ key + ']' + var path = (_state.path || '') + '[' + key + ']' , state = { ..._state, path, key, parent: value}; return subType._validate(item, _opts, state) @@ -85,7 +87,11 @@ inherits(ArraySchema, MixedSchema, { required(msg) { var next = MixedSchema.prototype.required.call(this, msg || mixed.required); - return next.min(1, msg || mixed.required); + return next.test( + 'required' + , msg || mixed.required + , hasLength + ) }, min(min, message){ @@ -96,7 +102,7 @@ inherits(ArraySchema, MixedSchema, { name: 'min', exclusive: true, params: { min }, - test: value => value && value.length >= min + test: value => isAbsent(value) || value.length >= min }) }, @@ -107,7 +113,7 @@ inherits(ArraySchema, MixedSchema, { name: 'max', exclusive: true, params: { max }, - test: value => value && value.length <= max + test: value => isAbsent(value) || value.length <= max }) }, @@ -118,4 +124,4 @@ inherits(ArraySchema, MixedSchema, { return this.transform(values => values != null ? values.filter(reject) : values) } -}) \ No newline at end of file +}) diff --git a/src/date.js b/src/date.js index 030f61271..5af95a213 100644 --- a/src/date.js +++ b/src/date.js @@ -2,6 +2,7 @@ var MixedSchema = require('./mixed') , isoParse = require('./util/isodate') , locale = require('./locale.js').date + , isAbsent = require('./util/isAbsent') , { isDate, inherits } = require('./util/_'); let invalidDate = new Date('') @@ -34,12 +35,12 @@ inherits(DateSchema, MixedSchema, { if(!this._typeCheck(limit)) throw new TypeError('`min` must be a Date or a value that can be `cast()` to a Date') - return this.test({ - name: 'min', - exclusive: true, - message: msg || locale.min, + return this.test({ + name: 'min', + exclusive: true, + message: msg || locale.min, params: { min: min }, - test: value => value && (value >= limit) + test: value => isAbsent(value) || (value >= limit) }) }, @@ -49,13 +50,13 @@ inherits(DateSchema, MixedSchema, { if(!this._typeCheck(limit)) throw new TypeError('`max` must be a Date or a value that can be `cast()` to a Date') - return this.test({ + return this.test({ name: 'max', - exclusive: true, - message: msg || locale.max, + exclusive: true, + message: msg || locale.max, params: { max: max }, - test: value => !value || (value <= limit) + test: value => isAbsent(value) || (value <= limit) }) } -}) \ No newline at end of file +}) diff --git a/src/mixed.js b/src/mixed.js index 2906a636e..f079ef03c 100644 --- a/src/mixed.js +++ b/src/mixed.js @@ -5,12 +5,15 @@ var Promise = require('promise/lib/es6-extensions') , ValidationError = require('./util/validation-error') , locale = require('./locale.js').mixed , _ = require('./util/_') + , isAbsent = require('./util/isAbsent') , cloneDeep = require('./util/clone') , createValidation = require('./util/createValidation') , BadSet = require('./util/set'); let formatError = ValidationError.formatError +let notEmpty = value => !isAbsent(value); + module.exports = SchemaType function SchemaType(options = {}){ @@ -58,16 +61,21 @@ SchemaType.prototype = { if (schema._type !== this._type && this._type !== 'mixed') throw new TypeError(`You cannot \`concat()\` schema's of different types: ${this._type} and ${schema._type}`) - + var cloned = this.clone() var next = _.merge(this.clone(), schema.clone()) // undefined isn't merged over, but is a valid value for default if (schema._default === undefined && _.has(this, '_default')) next._default = schema._default - // trim exclusive tests, take the most recent ones - next.tests = _.uniq(next.tests.reverse(), - (fn, idx) => next[fn.VALIDATION_KEY] ? fn.VALIDATION_KEY : idx).reverse() + next.tests = cloned.tests; + next._exclusive = cloned._exclusive; + + // manually add the new tests to ensure + // the deduping logic is consistent + schema.tests.forEach((fn) => { + next = next.test(fn.TEST) + }); next._type = schema._type; @@ -197,12 +205,11 @@ SchemaType.prototype = { }, required(msg) { - return this.test({ - name: 'required', - exclusive: true, - message: msg || locale.required, - test: value => value != null - }) + return this.test( + 'required', + msg || locale.required, + notEmpty + ) }, typeError(msg){ @@ -223,10 +230,22 @@ SchemaType.prototype = { return next }, + /** + * Adds a test function to the schema's queue of tests. + * tests can be exclusive or non-exclusive. + * + * - exclusive tests, will replace any existing tests of the same name. + * - non-exclusive: can be stacked + * + * If a non-exclusive test is added to a schema with an exclusive test of the same name + * the exclusive test is removed and further tests of the same name will be stacked. + * + * If an exclusive test is added to a schema with non-exclusive tests of the same name + * the previous tests are removed and further tests of the same name will replace each other. + */ test(name, message, test, useCallback) { var opts = name - , next = this.clone() - , isExclusive; + , next = this.clone(); if (typeof name === 'string') { if (typeof message === 'function') @@ -241,20 +260,27 @@ SchemaType.prototype = { if (next._whitelist.length) throw new Error('Cannot add tests when specific valid values are specified') - var validate = createValidation(opts) - - isExclusive = opts.name && next._exclusive[opts.name] === true + var validate = createValidation(opts); - if (opts.exclusive || isExclusive) { - if (!opts.name) - throw new TypeError('You cannot have an exclusive validation without a `name`') + var isExclusive = ( + opts.exclusive || + (opts.name && next._exclusive[opts.name] === true) + ) - next._exclusive[opts.name] = true - validate.VALIDATION_KEY = opts.name + if (opts.exclusive && !opts.name) { + throw new TypeError('You cannot have an exclusive validation without a `name`') } - if (isExclusive) - next.tests = next.tests.filter(fn => fn.VALIDATION_KEY !== opts.name) + next._exclusive[opts.name] = !!opts.exclusive + + next.tests = next.tests + .filter(fn => { + if (fn.TEST_NAME === opts.name) { + if (isExclusive) return false + if (fn.TEST.test === validate.TEST.test) return false + } + return true + }) next.tests.push(validate) @@ -314,7 +340,7 @@ var aliases = { } -for( var method in aliases ) if ( _.has(aliases, method) ) +for (var method in aliases) if ( _.has(aliases, method) ) aliases[method].forEach( alias => SchemaType.prototype[alias] = SchemaType.prototype[method]) //eslint-disable-line no-loop-func diff --git a/src/number.js b/src/number.js index 355076070..68e0477e8 100644 --- a/src/number.js +++ b/src/number.js @@ -1,12 +1,15 @@ 'use strict'; var SchemaObject = require('./mixed') , locale = require('./locale.js').number + , isAbsent = require('./util/isAbsent') , { isDate, inherits } = require('./util/_'); module.exports = NumberSchema +let isInteger = val => isAbsent(val) || val === (val | 0) + function NumberSchema(){ - if ( !(this instanceof NumberSchema)) + if ( !(this instanceof NumberSchema)) return new NumberSchema() SchemaObject.call(this, { type: 'number' }) @@ -29,22 +32,22 @@ inherits(NumberSchema, SchemaObject, { }, min(min, msg) { - return this.test({ - name: 'min', - exclusive: true, - params: { min }, + return this.test({ + name: 'min', + exclusive: true, + params: { min }, message: msg || locale.min, - test: value => value == null || value >= min + test: value => isAbsent(value) || value >= min }) }, max(max, msg) { - return this.test({ - name: 'max', - exclusive: true, - params: { max }, + return this.test({ + name: 'max', + exclusive: true, + params: { max }, message: msg || locale.max, - test: value => value == null || value <= max + test: value => isAbsent(value) || value <= max }) }, @@ -60,8 +63,8 @@ inherits(NumberSchema, SchemaObject, { msg = msg || locale.integer return this - .transform( v => v != null ? (v | 0) : v) - .test('integer', msg, val => val == null || val === (val | 0)) + .transform(value => !isAbsent(value) ? (value | 0) : value) + .test('integer', msg, isInteger) }, round(method) { @@ -71,6 +74,6 @@ inherits(NumberSchema, SchemaObject, { if( avail.indexOf(method.toLowerCase()) === -1 ) throw new TypeError('Only valid options for round() are: ' + avail.join(', ')) - return this.transform(v => v != null ? Math[method](v) : v) + return this.transform(value => !isAbsent(value) ? Math[method](value) : value) } }) diff --git a/src/string.js b/src/string.js index f122e41dd..965bffe84 100644 --- a/src/string.js +++ b/src/string.js @@ -1,11 +1,15 @@ 'use strict'; var MixedSchema = require('./mixed') , { mixed, string: locale } = require('./locale.js') + , isAbsent = require('./util/isAbsent') , inherits = require('./util/_').inherits; var rEmail = /^((([a-z]|\d|[!#\$%&'\*\+\-\/=\?\^_`{\|}~]|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])+(\.([a-z]|\d|[!#\$%&'\*\+\-\/=\?\^_`{\|}~]|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])+)*)|((\x22)((((\x20|\x09)*(\x0d\x0a))?(\x20|\x09)+)?(([\x01-\x08\x0b\x0c\x0e-\x1f\x7f]|\x21|[\x23-\x5b]|[\x5d-\x7e]|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])|(\\([\x01-\x09\x0b\x0c\x0d-\x7f]|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF]))))*(((\x20|\x09)*(\x0d\x0a))?(\x20|\x09)+)?(\x22)))@((([a-z]|\d|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])|(([a-z]|\d|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])([a-z]|\d|-|\.|_|~|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])*([a-z]|\d|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])))\.)+(([a-z]|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])|(([a-z]|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])([a-z]|\d|-|\.|_|~|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])*([a-z]|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])))$/i; var rUrl = /^(https?|ftp):\/\/(((([a-z]|\d|-|\.|_|~|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])|(%[\da-f]{2})|[!\$&'\(\)\*\+,;=]|:)*@)?(((\d|[1-9]\d|1\d\d|2[0-4]\d|25[0-5])\.(\d|[1-9]\d|1\d\d|2[0-4]\d|25[0-5])\.(\d|[1-9]\d|1\d\d|2[0-4]\d|25[0-5])\.(\d|[1-9]\d|1\d\d|2[0-4]\d|25[0-5]))|((([a-z]|\d|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])|(([a-z]|\d|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])([a-z]|\d|-|\.|_|~|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])*([a-z]|\d|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])))\.)+(([a-z]|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])|(([a-z]|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])([a-z]|\d|-|\.|_|~|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])*([a-z]|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])))\.?)(:\d*)?)(\/((([a-z]|\d|-|\.|_|~|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])|(%[\da-f]{2})|[!\$&'\(\)\*\+,;=]|:|@)+(\/(([a-z]|\d|-|\.|_|~|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])|(%[\da-f]{2})|[!\$&'\(\)\*\+,;=]|:|@)*)*)?)?(\?((([a-z]|\d|-|\.|_|~|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])|(%[\da-f]{2})|[!\$&'\(\)\*\+,;=]|:|@)|[\uE000-\uF8FF]|\/|\?)*)?(\#((([a-z]|\d|-|\.|_|~|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])|(%[\da-f]{2})|[!\$&'\(\)\*\+,;=]|:|@)|\/|\?)*)?$/i; +let hasLength = value => !isAbsent(value) && value.length > 0; +let isTrimmed = value => isAbsent(value) || value === value.trim() + module.exports = StringSchema; function StringSchema(){ @@ -30,7 +34,11 @@ inherits(StringSchema, MixedSchema, { required(msg){ var next = MixedSchema.prototype.required.call(this, msg || mixed.required ) - return next.min(1, msg || mixed.required ) + return next.test( + 'required' + , msg || mixed.required + , hasLength + ) }, min(min, msg){ @@ -39,7 +47,7 @@ inherits(StringSchema, MixedSchema, { exclusive: true, message: msg || locale.min, params: { min }, - test: value => value == null || value.length >= min + test: value => isAbsent(value) || value.length >= min }) }, @@ -49,7 +57,7 @@ inherits(StringSchema, MixedSchema, { exclusive: true, message: msg || locale.max, params: { max }, - test: value => value == null || value.length <= max + test: value => isAbsent(value) || value.length <= max }) }, @@ -57,7 +65,7 @@ inherits(StringSchema, MixedSchema, { return this.test({ message: msg || locale.matches, params: { regex }, - test: value => value == null || regex.test(value) + test: value => isAbsent(value) || regex.test(value) }) }, @@ -74,29 +82,29 @@ inherits(StringSchema, MixedSchema, { msg = msg || locale.trim return this - .transform( val => val != null ? val.trim() : val) - .test('trim', msg, val => val == null || val === val.trim()) + .transform(val => val != null ? val.trim() : val) + .test('trim', msg, isTrimmed) }, lowercase(msg){ return this - .transform(val => val != null ? val.toLowerCase() : val) + .transform(value => !isAbsent(value) ? value.toLowerCase() : value) .test({ name: 'string_case', exclusive: true, message: msg || locale.lowercase, - test: val => val == null || val === val.toLowerCase() + test: value => isAbsent(value) || value === value.toLowerCase() }) }, uppercase(msg){ return this - .transform(val => val != null ? val.toUpperCase(): val) + .transform(value => !isAbsent(value) ? value.toUpperCase() : value) .test({ name: 'string_case', exclusive: true, message: msg || locale.uppercase, - test: val => val == null || val === val.toUpperCase() + test: value => isAbsent(value) || value === value.toUpperCase() }) } }) diff --git a/src/util/_.js b/src/util/_.js index db2967012..be3613137 100644 --- a/src/util/_.js +++ b/src/util/_.js @@ -11,7 +11,6 @@ let isDate = obj => Object.prototype.toString.call(obj) === '[object Date]' let isSchema = obj => obj && obj.__isYupSchema__ - function settled(promises){ let settle = promise => promise.then( value => ({ fulfilled: true, value }), @@ -120,4 +119,4 @@ module.exports = { assign, merge, transform, isSchema, isObject, isPlainObject, isDate, settled, collectErrors -} \ No newline at end of file +} diff --git a/src/util/createValidation.js b/src/util/createValidation.js index 5ab164721..b9aa1d057 100644 --- a/src/util/createValidation.js +++ b/src/util/createValidation.js @@ -16,8 +16,9 @@ function createErrorFactory(orginalMessage, orginalPath, value, params, original } } -module.exports = function createValidation({ name, message, test, params, useCallback }){ - +module.exports = function createValidation(options) { + let { name, message, test, params, useCallback } = options + function validate({ value, path, state: { parent }, ...rest }) { var createError = createErrorFactory(message, path, value, params, name) var ctx = { path, parent, createError, type: name, ...rest } @@ -36,7 +37,9 @@ module.exports = function createValidation({ name, message, test, params, useCal }) } - validate.test_name = name + validate.TEST_NAME = name + validate.TEST_FN = test + validate.TEST = options return validate } diff --git a/src/util/isAbsent.js b/src/util/isAbsent.js new file mode 100644 index 000000000..b9c266fc1 --- /dev/null +++ b/src/util/isAbsent.js @@ -0,0 +1,2 @@ + +module.exports = value => value == null diff --git a/test/mixed.js b/test/mixed.js index 63906c3dd..b766d2634 100644 --- a/test/mixed.js +++ b/test/mixed.js @@ -12,6 +12,8 @@ var chai = require('chai') chai.use(chaiAsPromised); chai.should(); +let noop = function(){} + describe( 'Mixed Types ', function(){ it('should be immutable', function(){ @@ -94,6 +96,23 @@ describe( 'Mixed Types ', function(){ ]) }) + it('should dedupe tests with the same test function', function(){ + var inst = mixed() + .test('test', ' ', noop) + .test('test', 'asdasd', noop) + + inst.tests.length.should.equal(1) + inst.tests[0].TEST.message.should.equal('asdasd') + }) + + it('should not dedupe tests with the same test function and different type', function(){ + var inst = mixed() + .test('test', ' ', noop) + .test('test-two', 'asdasd', noop) + + inst.tests.length.should.equal(2) + }) + it('should respect exclusive validation', function(){ var inst = mixed() .test({ message: 'invalid', exclusive: true, name: 'test', test: function(){} }) @@ -108,6 +127,31 @@ describe( 'Mixed Types ', function(){ inst.tests.length.should.equal(2) }) + it('should non-exclusive tests should stack', function(){ + var inst = mixed() + .test({ name: 'test', message: ' ', test: function(){} }) + .test({ name: 'test', message: ' ', test: function(){} }) + + inst.tests.length.should.equal(2) + }) + + it('should replace existing tests, with exclusive test ', function(){ + var inst = mixed() + .test({ name: 'test', message: ' ', test: function(){} }) + .test({ name: 'test', exclusive: true, message: ' ', test: function(){} }) + + inst.tests.length.should.equal(1) + }) + + it('should replace existing exclusive tests, with non-exclusive', function(){ + var inst = mixed() + .test({ name: 'test', exclusive: true, message: ' ', test: function(){} }) + .test({ name: 'test', message: ' ', test: function(){} }) + .test({ name: 'test', message: ' ', test: function(){} }) + + inst.tests.length.should.equal(2) + }) + it('exclusive tests should throw without a name', function(){ (function(){ mixed().test({ message: 'invalid', exclusive: true, test: function(){} }) @@ -235,8 +279,8 @@ describe( 'Mixed Types ', function(){ }) })) - reach(next, 'str').tests.length.should.equal(3) // presence, min and trim - reach(next, 'str').tests[0].VALIDATION_KEY.should.equal('required') // make sure they are in the right order + reach(next, 'str').tests.length.should.equal(3) // presence, alt presence, and trim + reach(next, 'str').tests[0].TEST_NAME.should.equal('required') // make sure they are in the right order return Promise.all([