From 6e9046bc69db30fcf05b292f1cbe593536f8fbb3 Mon Sep 17 00:00:00 2001 From: Jason Quense Date: Thu, 14 Apr 2016 13:08:07 -0400 Subject: [PATCH] [changed] clean up interface, added lazy(), and fixed object strict semantics --- .eslintrc | 4 + README.md | 33 ++- karma.conf.js | 1 + package.json | 5 +- src/array.js | 34 +-- src/index.js | 4 +- src/mixed.js | 67 +++--- src/object.js | 133 +++++------ src/util/createValidation.js | 7 +- src/util/lazy.js | 26 +++ src/util/reach.js | 19 +- src/util/reference.js | 15 +- test/array.js | 98 +++++--- test/mixed.js | 214 +++++++++--------- test/object.js | 424 ++++++++++++++++++++++------------- test/yup.js | 8 +- tests-webpack.js | 24 +- 17 files changed, 656 insertions(+), 460 deletions(-) create mode 100644 src/util/lazy.js diff --git a/.eslintrc b/.eslintrc index ed4b94ab5..0d3777748 100644 --- a/.eslintrc +++ b/.eslintrc @@ -1,6 +1,10 @@ { "parser": "babel-eslint", "extends": "eslint:recommended", + "globals": { + "sinon": true, + "expect": true + }, "env": { "browser": true, "node": true, diff --git a/README.md b/README.md index 45e80a54e..b3339d71f 100644 --- a/README.md +++ b/README.md @@ -187,7 +187,7 @@ Adds a new method to the core schema types. A friendlier convenience method for #### `yup.ref(path: string, options: { contextPrefix: string }): Ref` Creates a reference to another sibling or sibling descendant field. Ref's are resolved -at _run time_ and supported where specified. Ref's are evaluated in in the proper order so that +at _validation/cast time_ and supported where specified. Ref's are evaluated in in the proper order so that the ref value is resolved before the field using the ref (be careful of circular dependencies!). ```js @@ -203,6 +203,36 @@ inst.cast({ foo: { bar: 'boom' } }, { context: { x: 5 } }) // { baz: 'boom', x: 5, { foo: { bar: 'boom' } }, } ``` +#### `yup.lazy((value: any) => Schema): Lazy` + +creates a schema that is evaluated at validation/cast time. Useful for creating +recursive schema like Trees, for polymophic fields and arrays. + +__CAUTION!__ When defining parent-child recursive object schema, you want to reset the `default()` +to `undefined` on the child otherwise the object will infinitely nest itself when you cast it!. + +```js +var node = object({ + id: number(), + child: yup.lazy(() => + node.default(undefined) + ) +}) + +let renderable = yup.lazy(value => { + switch (typeof value) { + case 'number': + return number() + case 'string': + return string() + default: + return mixed() + } +}) + +let renderables = array().of(renderable) +``` + #### `ValidationError(errors: string | Array, value: any, path: string)` Thrown on failed validations, with the following properties @@ -241,6 +271,7 @@ the cast object itself. Collects schema details (like meta, labels, and active tests) into a serializable description object. + ``` SchemaDescription { type: string, diff --git a/karma.conf.js b/karma.conf.js index ff1202668..64568e8d5 100644 --- a/karma.conf.js +++ b/karma.conf.js @@ -11,6 +11,7 @@ module.exports = function (config) { reporters: ['mocha'], files: [ + require.resolve('sinon/pkg/sinon-1.17.3.js'), 'tests-webpack.js' ], diff --git a/package.json b/package.json index 572f62dfa..684cc49c2 100644 --- a/package.json +++ b/package.json @@ -28,6 +28,7 @@ "babel-loader": "^6.2.4", "babel-plugin-add-module-exports": "^0.1.2", "babel-plugin-transform-object-assign": "^6.5.0", + "babel-polyfill": "^6.7.4", "babel-preset-es2015": "^6.6.0", "babel-preset-es2015-loose": "^7.0.0", "babel-preset-react": "^6.5.0", @@ -49,8 +50,8 @@ "node-libs-browser": "^0.5.2", "phantomjs": "^1.9.17", "release-script": "^0.5.2", - "sinon": "^1.10.3", - "sinon-chai": "^2.5.0", + "sinon": "^1.17.3", + "sinon-chai": "^2.8.0", "webpack": "^1.12.2" }, "dependencies": { diff --git a/src/array.js b/src/array.js index 27ebae5e5..04dbca00b 100644 --- a/src/array.js +++ b/src/array.js @@ -44,7 +44,7 @@ inherits(ArraySchema, MixedSchema, { }, _cast(_value, _opts) { - var value = MixedSchema.prototype._cast.call(this, _value) + var value = MixedSchema.prototype._cast.call(this, _value, _opts) //should ignore nulls here if (!this._typeCheck(value) || !this._subType) @@ -53,38 +53,40 @@ inherits(ArraySchema, MixedSchema, { return value.map(v => this._subType.cast(v, _opts)) }, - _validate(_value, _opts, _state){ + _validate(_value, options = {}) { var errors = [] - , context, subType, schema, endEarly, recursive; + , subType, endEarly, recursive; - _state = _state || {} - context = _state.parent || (_opts || {}).context - schema = this._resolve(context) - subType = schema._subType - endEarly = schema._option('abortEarly', _opts) - recursive = schema._option('recursive', _opts) + subType = this._subType + endEarly = this._option('abortEarly', options) + recursive = this._option('recursive', options) - return MixedSchema.prototype._validate.call(this, _value, _opts, _state) + return MixedSchema.prototype._validate.call(this, _value, options) .catch(endEarly ? null : err => { errors = err return err.value }) - .then(function(value){ - if (!recursive || !subType || !schema._typeCheck(value) ) { + .then((value) => { + if (!recursive || !subType || !this._typeCheck(value) ) { if (errors.length) throw errors[0] return value } let result = value.map((item, key) => { - var path = (_state.path || '') + '[' + key + ']' - , state = { ..._state, path, key, parent: value}; + var path = (options.path || '') + '[' + key + ']' - return subType._validate(item, _opts, state) + // object._validate note for isStrict explanation + var innerOptions = { ...options, path, key, strict: true, parent: value }; + + if (subType.validate) + return subType.validate(item, innerOptions) + + return true }) result = endEarly ? Promise.all(result).catch(scopeError(value)) - : collectErrors(result, value, _state.path, errors) + : collectErrors(result, value, options.path, errors) return result.then(() => value) }) diff --git a/src/index.js b/src/index.js index b8bdab582..e43ea5db9 100644 --- a/src/index.js +++ b/src/index.js @@ -1,7 +1,8 @@ 'use strict'; var mixed = require('./mixed') , bool = require('./boolean') - , Ref = require('./util/reference'); + , Ref = require('./util/reference') + , Lazy = require('./util/lazy'); var isSchema = schema => schema && !!schema.__isYupSchema__; @@ -19,6 +20,7 @@ module.exports = { ValidationError: require('./util/validation-error'), ref: (key, options) => new Ref(key, options), + lazy: (fn) => new Lazy(fn), isSchema, diff --git a/src/mixed.js b/src/mixed.js index f88739f83..0404bf676 100644 --- a/src/mixed.js +++ b/src/mixed.js @@ -111,8 +111,18 @@ SchemaType.prototype = { return !this._typeCheck || this._typeCheck(v) }, + resolve(context, parent) { + if (this._conditions.length) { + return this._conditions.reduce((schema, match) => + match.resolve(schema, match.getValue(parent, context)), this) + } + + return this + }, + cast(value, opts = {}) { - var schema = this._resolve(opts.context, opts.parent) + let schema = this.resolve(opts.context, opts.parent) + return schema._cast(value, opts) }, @@ -121,54 +131,53 @@ SchemaType.prototype = { : this.transforms.reduce( (value, transform) => transform.call(this, value, _value), _value) - if (value === undefined && _.has(this, '_default')) + if (value === undefined && (_.has(this, '_default'))) { value = this.default() + } return value }, - _resolve(context, parent) { - if (this._conditions.length) { - return this._conditions.reduce((schema, match) => - match.resolve(schema, match.getValue(parent, context)), this) - } + validate(value, options = {}, cb) { + if (typeof options === 'function') + cb = options, options = {} - return this + let schema = this.resolve(options.context, options.parent) + + return nodeify(schema._validate(value, options), cb) }, //-- tests - _validate(_value, options = {}, state = {}) { - let context = options.context - , parent = state.parent - , value = _value + _validate(_value, options = {}) { + let value = _value , schema, endEarly, isStrict; - schema = this._resolve(context, parent) - isStrict = schema._option('strict', options) - endEarly = schema._option('abortEarly', options) + schema = this + isStrict = this._option('strict', options) + endEarly = this._option('abortEarly', options) - let path = state.path + let path = options.path let label = this._label - if (!state.isCast && !isStrict) - value = schema._cast(value, options) - + if (!isStrict) { + value = this._cast(value, options, options) + } // value is cast, we can check if it meets type requirements - let validationParams = { value, path, state, schema, options, label } + let validationParams = { value, path, schema: this, options, label } let initialTests = [] if (schema._typeError) - initialTests.push(schema._typeError(validationParams)); + initialTests.push(this._typeError(validationParams)); - if (schema._whitelistError) - initialTests.push(schema._whitelistError(validationParams)); + if (this._whitelistError) + initialTests.push(this._whitelistError(validationParams)); - if (schema._blacklistError) - initialTests.push(schema._blacklistError(validationParams)); + if (this._blacklistError) + initialTests.push(this._blacklistError(validationParams)); return runValidations(initialTests, endEarly, value, path) .then(() => runValidations( - schema.tests.map(fn => fn(validationParams)) + this.tests.map(fn => fn(validationParams)) , endEarly , value , path @@ -176,12 +185,6 @@ SchemaType.prototype = { .then(() => value) }, - validate(value, options, cb) { - if (typeof options === 'function') - cb = options, options = {} - - return nodeify(this._validate(value, options, {}), cb) - }, isValid(value, options, cb) { if (typeof options === 'function') diff --git a/src/object.js b/src/object.js index 4706fd677..3c720b1a3 100644 --- a/src/object.js +++ b/src/object.js @@ -9,12 +9,10 @@ var MixedSchema = require('./mixed') , { isObject , transform - , assign , inherits , collectErrors , isSchema, has } = require('./util/_'); -let isRecursive = schema => (schema._subType || schema) === '$this' c.type('altCamel', function(str) { let result = c.camel(str) @@ -23,11 +21,6 @@ c.type('altCamel', function(str) { return idx === 0 ? result : (str.substr(0, idx) + result) }) -let childSchema = (field, parent) => { - if (!isRecursive(field)) return field - - return field.of ? field.of(parent) : parent -} let scopeError = value => err => { err.value = value @@ -77,93 +70,87 @@ inherits(ObjectSchema, MixedSchema, { return isObject(value) || typeof value === 'function'; }, - _cast(_value, _opts = {}) { - var schema = this - , value = MixedSchema.prototype._cast.call(schema, _value) + _cast(_value, opts = {}) { + var value = MixedSchema.prototype._cast.call(this, _value, opts) //should ignore nulls here - if (!schema._typeCheck(value)) + if (value === undefined) + return this.default(); + + if (!this._typeCheck(value)) return value; - var fields = schema.fields - , strip = schema._option('stripUnknown', _opts) === true - , extra = Object.keys(value).filter( v => schema._nodes.indexOf(v) === -1) - , props = schema._nodes.concat(extra); + var fields = this.fields + , strip = this._option('stripUnknown', opts) === true + , extra = Object.keys(value).filter(v => this._nodes.indexOf(v) === -1) + , props = this._nodes.concat(extra); - schema.withMutation(() => { - let innerOptions = { ..._opts, parent: {} }; + let innerOptions = { ...opts, parent: {} }; + value = transform(props, function(obj, prop) { + let field = fields[prop] + let exists = has(value, prop); + if (field) { + let fieldValue; - value = transform(props, function(obj, prop) { - let field = fields[prop] - let exists = has(value, prop); + if (field._strip === true) + return - if (Ref.isRef(field)) { - let refValue = field.getValue(obj, innerOptions.context) + fieldValue = field.cast(value[prop], innerOptions) - if (refValue !== undefined) - obj[prop] = refValue - } - else if (exists && field) { - tempClearDefault(schema, () => { - let fieldSchema = childSchema(field, schema.default(undefined)) - - if (fieldSchema._strip !== true) { - obj[prop] = fieldSchema.cast(value[prop], innerOptions) - } - }) - } - else if (exists && !strip) - obj[prop] = value[prop] - - else if (field) { - var fieldDefault = field.default ? field.default() : undefined + if (fieldValue !== undefined) + obj[prop] = fieldValue + } + else if (exists && !strip) + obj[prop] = value[prop] - if (fieldDefault !== undefined) - obj[prop] = fieldDefault - } - }, innerOptions.parent) - }) + }, innerOptions.parent) return value }, - _validate(_value, _opts, _state) { + _validate(_value, opts = {}) { var errors = [] - , state = _state || {} - , context, schema - , endEarly, recursive; + , endEarly, isStrict, recursive; - context = state.parent || (_opts || {}).context - schema = this._resolve(context) - endEarly = schema._option('abortEarly', _opts) - recursive = schema._option('recursive', _opts) + isStrict = this._option('strict', opts) + endEarly = this._option('abortEarly', opts) + recursive = this._option('recursive', opts) return MixedSchema.prototype._validate - .call(this, _value, _opts, state) + .call(this, _value, opts) .catch(endEarly ? null : err => { errors.push(err) return err.value }) .then(value => { if (!recursive || !isObject(value)) { // only iterate though actual objects - if ( errors.length ) throw errors[0] + if (errors.length) throw errors[0] return value } - let result = schema._nodes.map(function(key) { - var path = (state.path ? (state.path + '.') : '') + key - , field = childSchema(schema.fields[key], schema) + let result = this._nodes.map((key) => { + var path = (opts.path ? (opts.path + '.') : '') + key + , field = this.fields[key] + , innerOptions = { ...opts, key, path, parent: value }; + + if (field) { + // inner fields are always strict: + // 1. this isn't strict so we just cast the value leaving nested values already cast + // 2. this is strict in which case the nested values weren't cast either + innerOptions.strict = true; + + if (field.validate) + return field.validate(value[key], innerOptions) + } - return field._validate(value[key] - , _opts - , { ...state, key, path, parent: value }) + return true }) result = endEarly ? Promise.all(result).catch(scopeError(value)) - : collectErrors(result, value, state.path, errors) + : collectErrors(result, value, opts.path, errors) return result.then(() => value) }) @@ -179,14 +166,14 @@ inherits(ObjectSchema, MixedSchema, { shape(schema, excludes = []) { var next = this.clone() - , fields = assign(next.fields, schema); + , fields = Object.assign(next.fields, schema); - if ( !Array.isArray(excludes[0])) + if (!Array.isArray(excludes[0])) excludes = [excludes] next.fields = fields - if ( excludes.length ) + if (excludes.length) next._excludedEdges = next._excludedEdges.concat( excludes.map(v => `${v[0]}-${v[1]}`)) // 'node-othernode' @@ -251,15 +238,15 @@ function unknown(ctx, value) { // ugly optimization avoiding a clone. clears default for recursive // cast and resets it below; -function tempClearDefault(schema, fn) { - let hasDflt = has(schema, '_default') - , dflt = schema._default; - - fn(schema) - - if (hasDflt) schema.default(dflt) - else delete schema._default -} +// function tempClearDefault(schema, fn) { +// let hasDflt = has(schema, '_default') +// , dflt = schema._default; +// +// fn(schema) +// +// if (hasDflt) schema.default(dflt) +// else delete schema._default +// } function sortFields(fields, excludes = []){ var edges = [], nodes = [] diff --git a/src/util/createValidation.js b/src/util/createValidation.js index ec588a5cf..79c58b282 100644 --- a/src/util/createValidation.js +++ b/src/util/createValidation.js @@ -28,9 +28,10 @@ function createErrorFactory({ value, label, resolve, ...opts}) { module.exports = function createValidation(options) { let { name, message, test, params, useCallback } = options - function validate({ value, path, label, state: { parent }, ...rest }) { + function validate({ value, path, label, options, ...rest }) { + let parent = options.parent; var resolve = (value) => Ref.isRef(value) - ? value.getValue(parent, rest.options.context) + ? value.getValue(parent, options.context) : value var createError = createErrorFactory({ @@ -38,7 +39,7 @@ module.exports = function createValidation(options) { , label, resolve, name }) - var ctx = { path, parent, type: name, createError, resolve, ...rest } + var ctx = { path, parent, type: name, createError, resolve, options, ...rest } return new Promise((resolve, reject) => { !useCallback diff --git a/src/util/lazy.js b/src/util/lazy.js new file mode 100644 index 000000000..30526fec9 --- /dev/null +++ b/src/util/lazy.js @@ -0,0 +1,26 @@ +var { isSchema } = require('./_') + +class Lazy { + constructor(mapFn) { + this.resolve = (value) => { + let schema = mapFn(value) + if (!isSchema(schema)) + throw new TypeError('lazy() functions must return a valid schema') + + return schema + } + } + + cast(value, options) { + return this.resolve(value) + .cast(value, options) + } + + validate(value, options) { + return this.resolve(value) + .validate(value, options) + } +} + + +export default Lazy diff --git a/src/util/reach.js b/src/util/reach.js index af172defd..ea8a712f8 100644 --- a/src/util/reach.js +++ b/src/util/reach.js @@ -13,12 +13,23 @@ module.exports = function (obj, path, value, context) { let part = isBracket ? trim(_part) : _part; if (isArray || has(obj, '_subType')) { // we skipped an array - obj = obj._resolve(context, parent)._subType; - value = value && value[0] + let idx = isArray ? parseInt(part, 10) : 0 + obj = obj.resolve(context, parent)._subType; + + if (value) { + + if (isArray && idx >= value.length) { + throw new Error( + `Yup.reach cannot resolve an array item at index: ${_part}, in the path: ${path}. ` + + `because there is no value at that index. ` + ) + } + value = value[idx] + } } if (!isArray) { - obj = obj._resolve(context, parent); + obj = obj.resolve(context, parent); if (!has(obj, 'fields') || !has(obj.fields, part)) throw new Error( @@ -34,5 +45,5 @@ module.exports = function (obj, path, value, context) { } }) - return obj && obj._resolve(parent) + return obj && obj.resolve(value, parent) } diff --git a/src/util/reference.js b/src/util/reference.js index 39b383e35..2c3394d12 100644 --- a/src/util/reference.js +++ b/src/util/reference.js @@ -14,14 +14,25 @@ export default class Ref { validateName(key) let prefix = options.contextPrefix || '$'; - this.key = key; + if (typeof key === 'function') { + key = '.'; + + } + + this.key = key.trim(); this.prefix = prefix; - this.isContext = key.indexOf(prefix) === 0 + this.isContext = this.key.indexOf(prefix) === 0 + this.isSelf = this.key === '.'; + this.path = this.isContext ? this.key.slice(this.prefix.length) : this.key this._get = getter(this.path, true) this.map = mapFn || (value => value); } + cast(value, { parent, context }) { + return this.getValue(parent, context) + } + getValue(parent, context) { let isContext = this.isContext let value = this._get(isContext ? context : (parent || context) || {}) diff --git a/test/array.js b/test/array.js index 572e8da4d..5956645aa 100644 --- a/test/array.js +++ b/test/array.js @@ -13,29 +13,40 @@ chai.should(); describe('Array types', function(){ - it('should CAST correctly', function(){ - var inst = array(); - - inst.cast('[2,3,5,6]').should.eql([2, 3, 5, 6]) - - inst.cast(['4', 5, false]).should.eql(['4', 5, false]) - - inst.of(number()).cast(['4', 5, false]).should.eql([4, 5, 0]) - inst.of(string()).cast(['4', 5, false]).should.eql(['4', '5', 'false']) - - chai.expect( - inst.cast(null)).to.equal(null) - - chai.expect(inst.nullable() - .compact() - .cast(null)).to.equal(null) + describe('casting', ()=> { + it ('should parse json strings', () => { + array() + .cast('[2,3,5,6]') + .should.eql([2, 3, 5, 6]) + }) + + it ('should return null for failed casts', () => { + expect( + array().cast('asfasf')).to.equal(null) + + expect( + array().cast(null)).to.equal(null) + }) + + it ('should recursively cast fields', () => { + array().of(number()) + .cast(['4', 5, false]) + .should.eql([4, 5, 0]) + + array().of(string()) + .cast(['4', 5, false]) + .should.eql(['4', '5', 'false']) + }) }) + it('should handle DEFAULT', function(){ - var inst = array() + expect(array().default()).to.equal(undefined) - chai.expect(inst.default()).to.equal(undefined) - inst.default(function(){ return [1, 2, 3] }).default().should.eql([1, 2, 3]) + array() + .default(() => [1, 2, 3]) + .default() + .should.eql([1, 2, 3]) }) it('should type check', function(){ @@ -47,7 +58,7 @@ describe('Array types', function(){ inst.isType(NaN).should.equal(false) inst.isType(34545).should.equal(false) - chai.expect( + expect( inst.isType(null)).to.equal(false) inst.nullable().isType(null).should.equal(true) @@ -67,35 +78,48 @@ describe('Array types', function(){ .should.eql([{name: 'john'}]) }) - it('should VALIDATE correctly', function(){ + describe('validation', () => { - var inst = array().required().of(number().max(5)) + it ('should allow undefined', async () => { - return Promise.all([ + await array().of(number().max(5)) + .isValid() + .should.become(true) + }) - array().of(number().max(5)).isValid().should.eventually.equal(true), + it ('should not allow null when not nullable', async () => { - array().isValid(null).should.eventually.equal(false), - array().nullable().isValid(null).should.eventually.equal(true), + await array().isValid(null).should.become(false) - inst.isValid(['gg', 3]).should.eventually.equal(false), + await array().nullable().isValid(null).should.become(true) + }) - inst.isValid(['4', 3]).should.eventually.equal(true), + it ('should respect subtype validations', async () => { + var inst = array() + .of(number().max(5)) - inst.validate(['4', 3]).should.be.fulfilled.then(function(val){ - val.should.eql([4, 3]) - }), + await inst.isValid(['gg', 3]).should.become(false) + await inst.isValid([7, 3]).should.become(false) - inst.validate(['7', 3]).should.be.rejected, + let value = await inst.validate(['4', 3]) + + value.should.eql([4, 3]) + }) - inst.validate().should.be.rejected.then(function(err){ - err.errors.length.should.equal(1) - err.errors[0].should.contain('required') - }) - ]) + it('should prevent recursive casting', async () => { + let castSpy = sinon.spy(string.prototype, '_cast'); + let value = await array(string()) + .validate([ 5 ]) + + value[0].should.equal('5') + + castSpy.should.have.been.calledOnce + string.prototype._cast.restore() + }) }) + it('should respect abortEarly', function(){ var inst = array() .of(object({ str: string().required() })) diff --git a/test/mixed.js b/test/mixed.js index 3f250a4d9..46fc1c12b 100644 --- a/test/mixed.js +++ b/test/mixed.js @@ -40,34 +40,31 @@ describe( 'Mixed Types ', function(){ return inst.cast().should.equal('hello') }) - it('should check types', function(){ + it('should check types', async function(){ var inst = string().strict().typeError('must be a ${type}!') - return Promise.all([ - inst.validate(5).should.be.rejected.then(function(err) { - err.type.should.equal('typeError') - err.message.should.equal('must be a string!') - err.inner.length.should.equal(0) - }), - inst.validate(5, { abortEarly: false }).should.be.rejected.then(function(err) { - chai.expect(err.type).to.not.exist - err.message.should.equal('must be a string!') - err.inner.length.should.equal(1) - }) - ]) + let error = await inst.validate(5).should.be.rejected + + error.type.should.equal('typeError') + error.message.should.equal('must be a string!') + error.inner.length.should.equal(0) + + error = await inst.validate(5, { abortEarly: false }).should.be.rejected + + chai.expect(error.type).to.not.exist + error.message.should.equal('must be a string!') + error.inner.length.should.equal(1) }) - it('should limit values', function(){ + it('should limit values', async function(){ var inst = mixed().oneOf([5, 'hello']) - return Promise.all([ - inst.isValid(5).should.eventually.equal(true), - inst.isValid('hello').should.eventually.equal(true), + await inst.isValid(5).should.eventually.equal(true) + await inst.isValid('hello').should.eventually.equal(true) - inst.validate(6).should.be.rejected.then(function(err) { - err.errors[0].should.equal('this must be one the following values: 5, hello') - }) - ]) + let err = await inst.validate(6).should.be.rejected + + err.errors[0].should.equal('this must be one the following values: 5, hello') }) it('should ignore absent values', function(){ @@ -171,22 +168,22 @@ describe( 'Mixed Types ', function(){ it('should respect exclusive validation', function(){ var inst = mixed() - .test({ message: 'invalid', exclusive: true, name: 'test', test: function(){} }) - .test({ message: 'also invalid', name: 'test', test: function(){} }) + .test({ message: 'invalid', exclusive: true, name: 'test', test: function(){} }) + .test({ message: 'also invalid', name: 'test', test: function(){} }) inst.tests.length.should.equal(1) inst = mixed() - .test({ message: 'invalid', name: 'test', test: function(){} }) - .test({ message: 'also invalid', name: 'test', test: function(){} }) + .test({ message: 'invalid', name: 'test', test: function(){} }) + .test({ message: 'also invalid', name: 'test', test: 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(){} }) + .test({ name: 'test', message: ' ', test: function(){} }) + .test({ name: 'test', message: ' ', test: function(){} }) inst.tests.length.should.equal(2) }) @@ -194,7 +191,7 @@ describe( 'Mixed Types ', function(){ 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(){} }) + .test({ name: 'test', exclusive: true, message: ' ', test: function(){} }) inst.tests.length.should.equal(1) }) @@ -202,8 +199,8 @@ describe( 'Mixed Types ', function(){ 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(){} }) + .test({ name: 'test', message: ' ', test: function(){} }) + .test({ name: 'test', message: ' ', test: function(){} }) inst.tests.length.should.equal(2) }) @@ -214,20 +211,14 @@ describe( 'Mixed Types ', function(){ }).should.throw() }) - it('exclusive tests should replace previous ones', function(){ - var inst = mixed().test({ message: 'invalid', exclusive: true, name: 'max', test: function(v){ - return v < 5 - }}) - - return Promise.all([ + it('exclusive tests should replace previous ones', async function(){ + var inst = mixed().test({ message: 'invalid', exclusive: true, name: 'max', test: v => v < 5 }) - inst.isValid(8).should.eventually.become(false), + await inst.isValid(8).should.eventually.become(false), - inst.test({ message: 'invalid', exclusive: true, name: 'max', test: function(v){ - return v < 10 - }}) - .isValid(8).should.eventually.become(true) - ]) + await inst + .test({ message: 'invalid', exclusive: true, name: 'max', test: v => v < 10 }) + .isValid(8).should.eventually.become(true) }) it('tests should be called with the correct `this`', function(done){ @@ -319,7 +310,8 @@ describe( 'Mixed Types ', function(){ }) }) - it('should concat schemas', function(){ + describe('concat', () => { + var next var inst = object({ str: string().required(), obj: object({ @@ -327,33 +319,49 @@ describe( 'Mixed Types ', function(){ }) }) - var next = inst.concat(object({ - str: string().required().trim(), - str2: string().required(), - obj: object({ - str: string().required() - }) - })) + beforeEach(() => { + next = inst.concat(object({ + str: string().required().trim(), + str2: string().required(), + obj: object({ + str: string().required() + }) + })) + }) - 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 + it ('should have teh correct number of tests', () => { + reach(next, 'str').tests.length.should.equal(3) // presence, alt presence, and trim + }) - return Promise.all([ + it ('should have the tests in the correct order', () => { + reach(next, 'str').tests[0].TEST_NAME.should.equal('required') + }) - inst.isValid({ str: 'hi', str2: 'hi', obj: {} }).should.become(true), + it ('should validate correctly', async () => { + await inst + .isValid({ str: 'hi', str2: 'hi', obj: {} }) + .should.become(true) - next.validate({ str: ' hi ', str2: 'hi', obj: { str: 'hi' } }).should.be.fulfilled.then(function(value){ - value.should.deep.eql({ str: 'hi', str2: 'hi', obj: {str: 'hi'} }) - }), + ;(await next + .validate({ str: ' hi ', str2: 'hi', obj: { str: 'hi' } }) + .should.be.fulfilled) + .should.deep.eql({ str: 'hi', str2: 'hi', obj: {str: 'hi'} }) + }) - next.validate({ str: 'hi', str2: 'hi', obj: {} }).should.be.rejected.then(function(err){ - err.message.should.contain('obj.str is a required field') - }), + it ('should throw the correct validation errors', async () => { - next.validate({ str2: 'hi', obj: { str: 'hi'} }).should.be.rejected.then(function(err){ - err.message.should.contain('str is a required field') - }) - ]) + let result = await next + .validate({ str: 'hi', str2: 'hi', obj: {} }) + .should.be.rejected + + result.message.should.contain('obj.str is a required field') + + result = await next + .validate({ str2: 'hi', obj: { str: 'hi'} }) + .should.be.rejected + + result.message.should.contain('str is a required field') + }) }) @@ -390,30 +398,23 @@ describe( 'Mixed Types ', function(){ }) }) - it('should handle conditionals', function(){ + it('should handle conditionals', async function(){ var inst = mixed() .when('prop', { is: 5, then: mixed().required('from parent') }) - return Promise.all([ - //parent - inst._validate(undefined, {}, { parent: { prop: 5 }}).should.be.rejected, - inst._validate(undefined, {}, { parent: { prop: 1 }}).should.be.fulfilled, - inst._validate('hello', {}, { parent: { prop: 5 }}).should.be.fulfilled - ]) - .then(function(){ - - inst = string().when('prop', { - is: function(val) { return val === 5 }, - then: string().required(), - otherwise: string().min(4) - }) + await inst.validate(undefined, { parent: { prop: 5 }}).should.be.rejected, + await inst.validate(undefined, { parent: { prop: 1 }}).should.be.fulfilled, + await inst.validate('hello', { parent: { prop: 5 }}).should.be.fulfilled - return Promise.all([ - inst._validate(undefined, {}, { parent: { prop: 5 }}).should.be.rejected, - inst._validate('hello', {}, { parent: { prop: 1 }}).should.be.fulfilled, - inst._validate('hel', {}, { parent: { prop: 1 }}).should.be.rejected - ]) + inst = string().when('prop', { + is: function(val) { return val === 5 }, + then: string().required(), + otherwise: string().min(4) }) + + await inst.validate(undefined, { parent: { prop: 5 }}).should.be.rejected, + await inst.validate('hello', { parent: { prop: 1 }}).should.be.fulfilled, + await inst.validate('hel', { parent: { prop: 1 }}).should.be.rejected }) it('should handle multiple conditionals', function() { @@ -439,28 +440,23 @@ describe( 'Mixed Types ', function(){ }) - it('should require context when needed', function(){ + it('should require context when needed', async function(){ var inst = mixed() .when('$prop', { is: 5, then: mixed().required('from context') }) - return Promise.all([ - inst._validate(undefined, { context: { prop: 5 }}, {}).should.be.rejected, - inst._validate(undefined, { context: { prop: 1 }}, {}).should.be.fulfilled, - inst._validate('hello', { context: { prop: 5 }}, {}).should.be.fulfilled - ]) - .then(function(){ - inst = string().when('$prop', { - is: function(val) { return val === 5 }, - then: string().required(), - otherwise: string().min(4) - }) + await inst.validate(undefined, { context: { prop: 5 }}).should.be.rejected, + await inst.validate(undefined, { context: { prop: 1 }}).should.be.fulfilled, + await inst.validate('hello', { context: { prop: 5 }}).should.be.fulfilled - return Promise.all([ - inst._validate(undefined, { context: { prop: 5 }}, {}).should.be.rejected, - inst._validate('hello', { context: { prop: 1 }}, {}).should.be.fulfilled, - inst._validate('hel', { context: { prop: 1 }}, {}).should.be.rejected - ]) + inst = string().when('$prop', { + is: function(val) { return val === 5 }, + then: string().required(), + otherwise: string().min(4) }) + + await inst.validate(undefined, { context: { prop: 5 }}).should.be.rejected, + await inst.validate('hello', { context: { prop: 1 }}).should.be.fulfilled, + await inst.validate('hel', { context: { prop: 1 }}).should.be.rejected }) it('should not use context refs in object calculations', function(){ @@ -471,17 +467,15 @@ describe( 'Mixed Types ', function(){ inst.default().should.eql({ prop: undefined }) }) - it('should use label in error message', function () { - var label = 'Label' - var inst = object({ - prop: string().required().label(label) - }) + it('should use label in error message', async function () { + var label = 'Label' + var inst = object({ + prop: string().required().label(label) + }) - return Promise.all([ - inst.validate({}).should.be.rejected.then(function (err) { - err.message.should.equal(`${label} is a required field`) - }) - ]) + await inst.validate({}).should.be.rejected.then(function (err) { + err.message.should.equal(`${label} is a required field`) + }) }) it('should add meta() data', () => { diff --git a/test/object.js b/test/object.js index 1f81e5a19..3c39c6de0 100644 --- a/test/object.js +++ b/test/object.js @@ -5,7 +5,7 @@ var chai = require('chai') , Promise = require('promise/src/es6-extensions') , { mixed, string, date, number - , bool, array, object, ref + , bool, array, object, ref, lazy, reach } = require('../src'); chai.use(chaiAsPromised); @@ -14,26 +14,31 @@ chai.should(); describe('Object types', function(){ - it('should CAST correctly', function(){ - - var inst = object() - , obj = { - num: '5', - str: 'hello', - arr: ['4', 5, false], - dte: '2014-09-23T19:25:25Z', - nested: { str: 5 }, - arrNested: [{ num: 5 }, { num: '5' }] - } + describe('casting', ()=> { + it ('should parse json strings', () => { + object({ hello: number() }) + .cast('{ \"hello\": \"5\" }') + .should.eql({ + hello: 5 + }) + }) - object() - .shape({ hello: number() }) - .cast('{ \"hello\": \"5\" }').should.eql({ hello: 5 }) + it ('should return null for failed casts', () => { + chai.expect( + object().cast('dfhdfh')).to.equal(null) + }) - chai.expect( - object().cast('dfhdfh')).to.eql(null) + it ('should recursively cast fields', () => { + var obj = { + num: '5', + str: 'hello', + arr: ['4', 5, false], + dte: '2014-09-23T19:25:25Z', + nested: { str: 5 }, + arrNested: [{ num: 5 }, { num: '5' }] + } - inst = inst.shape({ + object({ num: number(), str: string(), arr: array().of(number()), @@ -43,99 +48,119 @@ describe('Object types', function(){ object().shape({ num: number() }) ) }) - //console.log(inst.cast(obj)) - - inst.cast(obj).should.eql({ - num: 5, - str: 'hello', - arr: [4, 5, 0], - dte: new Date(1411500325000), - nested: { str: '5' }, - arrNested: [ - { num: 5 }, - { num: 5 } - ] + .cast(obj).should.eql({ + num: 5, + str: 'hello', + arr: [4, 5, 0], + dte: new Date(1411500325000), + nested: { str: '5' }, + arrNested: [{ num: 5 }, { num: 5 }] + }) }) }) + describe('validation', () => { + var inst, obj; - it('should VALIDATE correctly', function(){ - var inst - , obj = { - num: '4', - str: 'hello', - arr: ['4', 5, false], - dte: '2014-09-23T19:25:25Z', - nested: { str: 5 }, - arrNested: [{ num: 5 }, { num: '2' }] - } - - inst = object().shape({ + beforeEach(() => { + inst = object().shape({ num: number().max(4), str: string(), arr: array().of(number().max(6)), dte: date(), + nested: object().shape({ str: string().min(3) }).required(), + arrNested: array().of(object().shape({ num: number() })) + }) + obj = { + num: '4', + str: 'hello', + arr: ['4', 5, false], + dte: '2014-09-23T19:25:25Z', + nested: { str: 5 }, + arrNested: [{ num: 5 }, { num: '2' }] + } + }) - nested: object() - .shape({ str: string().min(3) }) - .required(), + it ('should run validations recursively', async () => { + let error = await inst.validate(obj).should.be.rejected; - arrNested: array().of( - object().shape({ num: number() }) - ) - }) + error.errors.length.should.equal(1) + error.errors[0].should.contain('nested.str') - return inst.validate(obj).should.be.rejected - .then(function(err){ - err.errors.length.should.equal(1) - err.errors[0].should.contain('nested.str') - }) - .then(function(){ + obj.arr[1] = 8 - obj.arr[1] = 8 + await inst.isValid().should.eventually.equal(true), - return Promise.all([ - inst.isValid().should.eventually.equal(true), + error = await inst.validate(obj).should.be.rejected + error.errors[0].should.contain('arr[1]') + }) - inst.validate(obj).should.be.rejected.then(function(err){ - err.errors[0].should.contain('arr[1]') - }) - ]) + it('should prevent recursive casting', async () => { + let castSpy = sinon.spy(string.prototype, '_cast'); + + inst = object({ + field: string() }) - }) - it('should not clone during validating', function(){ - var inst = object().shape({ - num: number().max(4), - str: string(), - arr: array().of(number().max(6)), - dte: date(), + let value = await inst.validate({ field: 5 }) - nested: object() - .shape({ str: string().min(3) }) - .required(), + value.field.should.equal('5') + + castSpy.should.have.been.calledOnce - arrNested: array().of( - object().shape({ num: number() }) - ) + string.prototype._cast.restore() + }) + + it('should respect strict for nested values', async () => { + inst = object({ + field: string() }) + .strict() - let base = mixed.prototype.clone; - let replace = () => mixed.prototype.clone = base - mixed.prototype.clone = function(...args) { - if (!this._mutate) - throw new Error('should not call clone') + let err = await inst.validate({ field: 5 }).should.be.rejected - return base.apply(this, args) - } + err.message.should.match(/must be a `string` type/) + }) - return inst - .validate({ - nested: { str: 5 }, - arrNested: [{ num: 5 }, { num: '2' }] + it('should handle custom validation', async function(){ + var inst = object().shape({ + prop: mixed(), + other: mixed() }) - .then(replace) - .catch(replace) + .test('test', '${path} oops', () => false) + + let err = await inst.validate({}).should.be.rejected + + err.errors[0].should.equal('this oops') + }) + + it('should not clone during validating', async function() { + let base = mixed.prototype.clone; + + mixed.prototype.clone = function(...args) { + if (!this._mutate) + throw new Error('should not call clone') + + return base.apply(this, args) + } + + try { + await inst.validate({ + nested: { str: 'jimmm' }, + arrNested: [{ num: 5 }, { num: '2' }] + }) + await inst.validate({ + nested: { str: 5 }, + arrNested: [{ num: 5 }, { num: '2' }] + }) + } + catch (err) {} //eslint-disable-line + finally { + mixed.prototype.clone = base + } + }) + + }) @@ -165,37 +190,63 @@ describe('Object types', function(){ inst.should.have.deep.property('fields.prop') }) - it('should create a reasonable default', function(){ + describe('object defaults', () => { + let objSchema; - object({ - str: string(), + beforeEach(() => { + objSchema = object({ nest: object({ str: string().default('hi') }) + }) }) - .default().should.eql({ nest: { str: 'hi' }, str: undefined }) - object({ - str: string(), - nest: object({ str: string().default('hi') }) + it ('should expand objects by default', () => { + objSchema.default().should.eql({ + nest: { str: 'hi' } + }) }) - .default({ boom: 'hi'}) - .default() - .should.eql({ boom: 'hi'}) + it ('should accept a user provided default', () => { + objSchema = objSchema.default({ boom: 'hi'}) + + objSchema.default().should.eql({ + boom: 'hi' + }) + }) - chai.expect(object({ + it ('should add empty keys when sub schema has no default', () => { + object({ str: string(), nest: object({ str: string() }) + }) + .default() + .should.eql({ + nest: { str: undefined }, + str: undefined + }) + }) + + it ('should create defaults for missing object fields', () => { + + object({ + prop: mixed(), + other: object({ + x: object({ b: string() }) + }) + }) + .cast({ prop: 'foo' }) + .should.eql({ + prop: 'foo', + other: { x: { b: undefined } } + }) }) - .default()).to.eql({ nest: { str: undefined }, str: undefined }) }) it('should handle empty keys', function(){ var inst = object().shape({ - prop: mixed() - }) - + prop: mixed() + }) return Promise.all([ @@ -241,23 +292,9 @@ describe('Object types', function(){ }) }) - it('should handle custom validation', function(){ - var inst = object().shape({ - prop: mixed(), - other: mixed() - }) - inst = inst.test('test', '${path} oops', function(){ - return false - }) - return inst.validate({}).should.be.rejected - .then(function(err){ - err.errors[0].should.equal('this oops') - }) - }) - - it('should allow refs', function() { + it('should allow refs', async function() { var schema = object({ quz: ref('baz'), baz: ref('foo.bar'), @@ -267,53 +304,130 @@ describe('Object types', function(){ x: ref('$x') }) - schema.cast({ foo: { bar: 'boom' } }, { context: { x: 5 } }) - .should.eql({ - foo: { - bar: 'boom' - }, - baz: 'boom', - quz: 'boom', - x: 5 - }) + let value = await schema.validate({ + foo: { bar: 'boom' } + }, { context: { x: 5 } }) + + //console.log(value) + value.should.eql({ + foo: { + bar: 'boom' + }, + baz: 'boom', + quz: 'boom', + x: 5 + }) + + }) - it('should allow nesting with "$this"', function(){ - var inst = object().shape({ - child: '$this', - str: string().trim().default('yo!'), - other: string().required('required!'), - arr: array().of('$this') - }) + describe('lazy evaluation', () => { + let types = { + 'string': string(), + 'number': number() + } - inst.default().should.eql({ - child: undefined, - other: undefined, - arr: undefined, - str: 'yo!' + it('should be cast-able', () => { + let inst = lazy(()=> number()) + + inst.cast.should.be.a('function') + inst.cast('4').should.equal(4) }) - return Promise.all([ + it('should be validatable', async () => { + let inst = lazy(()=> string().trim('trim me!').strict()) - inst.validate({ - child: { other: undefined }, - other: 'ff' - }).should.be.rejected - .then(function(err){ - err.errors[0].should.equal('required!') - }), + inst.validate.should.be.a('function') - inst.validate({ - arr: [{ other: undefined, str: ' john ' }], - other: 'ff' - }).should.be.rejected - .then(function(err){ - err.value.arr[0].str.should.equal('john') - err.errors[0].should.equal('required!') + try { + await inst.validate(' john ') + } + catch (err) { + err.message.should.equal('trim me!') + } + }) + + it('should resolve to schema', () => { + let inst = object({ + nested: lazy(()=> inst), + x: object({ + y: lazy(()=> inst) }) - ]) + }) + + reach(inst, 'nested').should.equal(inst) + reach(inst, 'x.y').should.equal(inst) + }) + + it('should be passed the value', (done) => { + let inst = object({ + nested: lazy(value => { + value.should.equal('foo') + done() + }) + }) + + inst.cast({ nested: 'foo' }) + }) + + it('should always return a schema', () => { + (() => lazy(() => {}).cast()) + .should.throw(/must return a valid schema/) + }) + + it('should set the correct path', async () => { + let inst = object({ + str: string().required().nullable(), + nested: lazy(() => inst.default(undefined)) + }) + + let value = { + nested: { str: null }, + str: 'foo' + } + + try { + await inst.validate(value, { strict: true }) + } + catch (err) { + err.path.should.equal('nested.str') + err.message.should.match(/required/) + } + }) + + it('should resolve array sub types', async () => { + let inst = object({ + str: string().required().nullable(), + nested: array().of( + lazy(() => inst.default(undefined)) + ) + }) + + let value = { + nested: [{ str: null }], + str: 'foo' + } + + try { + await inst.validate(value, { strict: true }) + } + catch (err) { + err.path.should.equal('nested[0].str') + err.message.should.match(/required/) + } + }) + + it('should resolve for each array item', async () => { + let inst = array() + .of(lazy(value => types[typeof value])) + + let val = await inst.validate(['john', 4], { strict: true }) + + val.should.eql(['john', 4]) + }) }) + it('should respect abortEarly', function(){ var inst = object({ nest: object({ diff --git a/test/yup.js b/test/yup.js index f881e39a9..6c440a3d3 100644 --- a/test/yup.js +++ b/test/yup.js @@ -111,7 +111,7 @@ describe('Yup', function(){ let value = { bar: 3, nested: { - arr: [{ foo: 5 }] + arr: [{ foo: 5 }, { foo: 3 }] } } @@ -120,8 +120,10 @@ describe('Yup', function(){ reach(inst, 'nested.arr.num', value, context).should.equal(num) reach(inst, 'nested.arr[].num', value, context).should.equal(num) - reach(inst, 'nested.arr[1].num', value, context).should.equal(num) - reach(inst, 'nested["arr"][1].num', value, context).should.not.equal(number()) + reach(inst, 'nested.arr[0].num', value, context).should.equal(num) + + // should fail b/c item[1] is used to resolve the schema + reach(inst, 'nested["arr"][1].num', value, context).should.not.equal(num) return reach(inst, 'nested.arr[].num', value, context).isValid(5) .then((valid) => { diff --git a/tests-webpack.js b/tests-webpack.js index 3ccce28f1..4272c1028 100644 --- a/tests-webpack.js +++ b/tests-webpack.js @@ -1,24 +1,5 @@ 'use strict'; -var slice = Array.prototype.slice; - -// Phantom js polyfill -if (!Function.prototype.bind) { - Function.prototype.bind = function(context) { - var func = this; - var args = slice.call(arguments, 1); - - function bound() { - var invokedAsConstructor = func.prototype && (this instanceof func); - return func.apply( - !invokedAsConstructor && context || this, - args.concat(slice.call(arguments)) - ); - } - bound.prototype = func.prototype; - return bound; - }; -} - +require('babel-polyfill') var chai = require('chai') @@ -26,7 +7,8 @@ chai.use(require('chai-as-promised')) chai.use(require('sinon-chai')) chai.should(); +global.expect = window.expect = chai.expect; -var testsContext = require.context("./test", true); +var testsContext = require.context('./test', true); testsContext.keys().forEach(testsContext);