From 1081aeda84ea29ceb49de02abd3dbbdc2b5d8093 Mon Sep 17 00:00:00 2001 From: Nicolas Morel Date: Mon, 9 Oct 2017 20:47:35 +0200 Subject: [PATCH] Add a "peek" feature to alternatives.when Fixes #1325 --- API.md | 45 +- lib/types/alternatives/index.js | 45 +- lib/types/any/index.js | 9 +- test/types/alternatives.js | 992 +++++++++++++++++--------------- test/types/any.js | 99 +++- 5 files changed, 715 insertions(+), 475 deletions(-) diff --git a/API.md b/API.md index e4ea325fe..a06dec62c 100644 --- a/API.md +++ b/API.md @@ -685,12 +685,12 @@ const b = Joi.string().valid('b'); const ab = a.concat(b); ``` -#### `any.when(ref, options)` +#### `any.when(condition, options)` Converts the type into an [`alternatives`](#alternatives) type where the conditions are merged into the type definition where: -- `ref` - the key name or [reference](#refkey-options). +- `condition` - the key name or [reference](#refkey-options), or a schema. - `options` - an object with: - - `is` - the required condition **joi** type. Anything that is not a joi schema will be converted using [Joi.compile](#compileschema). + - `is` - the required condition **joi** type. Anything that is not a joi schema will be converted using [Joi.compile](#compileschema). Forbidden when `condition` is a schema. - `then` - the alternative schema type if the condition is true. Required if `otherwise` is missing. - `otherwise` - the alternative schema type if the condition is false. Required if `then` is missing. @@ -704,6 +704,23 @@ const schema = { }; ``` +Or with a schema: +```js +const schema = Joi.object({ + a: Joi.any().valid('x'), + b: Joi.any() +}).when(Joi.object({ b: Joi.exist() }).unknown(), { + then: Joi.object({ + a: Joi.valid('y') + }), + otherwise: Joi.object({ + a: Joi.valid('z') + }) +}); +``` + +Note that this style is much more useful when your whole schema depends on the value of one of its property, or if you find yourself repeating the check for many keys of an object. + Alternatively, if you want to specify a specific type such as `string`, `array`, etc, you can do so like this: ```js @@ -2018,12 +2035,13 @@ const alt = Joi.alternatives().try(Joi.number(), Joi.string()); alt.validate('a', (err, value) => { }); ``` -#### `alternatives.when(ref, options)` +#### `alternatives.when(condition, options)` -Adds a conditional alternative schema type based on another key (not the same as `any.when()`) value where: -- `ref` - the key name or [reference](#refkey-options). +Adds a conditional alternative schema type, either based on another key (not the same as `any.when()`) value, or a +schema peeking into the current value, where: +- `condition` - the key name or [reference](#refkey-options), or a schema. - `options` - an object with: - - `is` - the required condition **joi** type. + - `is` - the required condition **joi** type. Forbidden when `condition` is a schema. - `then` - the alternative schema type to **try** if the condition is true. Required if `otherwise` is missing. - `otherwise` - the alternative schema type to **try** if the condition is false. Required if `then` is missing. @@ -2034,6 +2052,19 @@ const schema = { }; ``` +```js +const schema = Joi.alternatives().when(Joi.object({ b: 5 }).unknown(), { + then: Joi.object({ + a: Joi.string(), + b: Joi.any() + }), + otherwise: Joi.object({ + a: Joi.number(), + b: Joi.any() + }) +}); +``` + Note that `when()` only adds additional alternatives to try and does not impact the overall type. Setting a `required()` rule on a single alternative will not apply to the overall key. For example, this definition of `a`: diff --git a/lib/types/alternatives/index.js b/lib/types/alternatives/index.js index c6c1eb688..fcbf1fb96 100644 --- a/lib/types/alternatives/index.js +++ b/lib/types/alternatives/index.js @@ -31,9 +31,10 @@ internals.Alternatives = class extends Any { for (let i = 0; i < il; ++i) { const item = this._inner.matches[i]; - const schema = item.schema; - if (!schema) { - const failed = item.is._validate(item.ref(state.reference || state.parent, options), null, options, state.parent).errors; + if (!item.schema) { + const schema = item.peek || item.is; + const input = item.is ? item.ref(state.reference || state.parent, options) : value; + const failed = schema._validate(input, null, options, state.parent).errors; if (failed) { if (item.otherwise) { @@ -51,7 +52,7 @@ internals.Alternatives = class extends Any { continue; } - const result = schema._validate(value, state, options); + const result = item.schema._validate(value, state, options); if (!result.errors) { // Found a valid match return result; } @@ -84,25 +85,35 @@ internals.Alternatives = class extends Any { return obj; } - when(ref, options) { + when(condition, options) { - Hoek.assert(Ref.isRef(ref) || typeof ref === 'string', 'Invalid reference:', ref); + let schemaCondition = false; + Hoek.assert(Ref.isRef(condition) || typeof condition === 'string' || (schemaCondition = condition instanceof Any), 'Invalid condition:', condition); Hoek.assert(options, 'Missing options'); Hoek.assert(typeof options === 'object', 'Invalid options'); - Hoek.assert(options.hasOwnProperty('is'), 'Missing "is" directive'); + if (schemaCondition) { + Hoek.assert(!options.hasOwnProperty('is'), '"is" can not be used with a schema condition'); + } + else { + Hoek.assert(options.hasOwnProperty('is'), 'Missing "is" directive'); + } Hoek.assert(options.then !== undefined || options.otherwise !== undefined, 'options must have at least one of "then" or "otherwise"'); const obj = this.clone(); - let is = Cast.schema(this._currentJoi, options.is); + let is; + if (!schemaCondition) { + is = Cast.schema(this._currentJoi, options.is); - if (options.is === null || !(Ref.isRef(options.is) || options.is instanceof Any)) { + if (options.is === null || !(Ref.isRef(options.is) || options.is instanceof Any)) { - // Only apply required if this wasn't already a schema or a ref, we'll suppose people know what they're doing - is = is.required(); + // Only apply required if this wasn't already a schema or a ref, we'll suppose people know what they're doing + is = is.required(); + } } const item = { - ref: Cast.ref(ref), + ref: schemaCondition ? null : Cast.ref(condition), + peek: schemaCondition ? condition : null, is, then: options.then !== undefined ? Cast.schema(this._currentJoi, options.then) : undefined, otherwise: options.otherwise !== undefined ? Cast.schema(this._currentJoi, options.otherwise) : undefined @@ -114,8 +125,10 @@ internals.Alternatives = class extends Any { item.otherwise = item.otherwise && obj._baseType.concat(item.otherwise); } - Ref.push(obj._refs, item.ref); - obj._refs = obj._refs.concat(item.is._refs); + if (!schemaCondition) { + Ref.push(obj._refs, item.ref); + obj._refs = obj._refs.concat(item.is._refs); + } if (item.then && item.then._refs) { obj._refs = obj._refs.concat(item.then._refs); @@ -146,9 +159,11 @@ internals.Alternatives = class extends Any { // when() - const when = { + const when = item.is ? { ref: item.ref.toString(), is: item.is.describe() + } : { + peek: item.peek.describe() }; if (item.then) { diff --git a/lib/types/any/index.js b/lib/types/any/index.js index 766d7ada8..56afbb158 100644 --- a/lib/types/any/index.js +++ b/lib/types/any/index.js @@ -385,7 +385,7 @@ module.exports = internals.Any = class { return obj; } - when(ref, options) { + when(condition, options) { Hoek.assert(options && typeof options === 'object', 'Invalid options'); Hoek.assert(options.then !== undefined || options.otherwise !== undefined, 'options must have at least one of "then" or "otherwise"'); @@ -394,7 +394,12 @@ module.exports = internals.Any = class { const otherwise = options.hasOwnProperty('otherwise') ? this.concat(Cast.schema(this._currentJoi, options.otherwise)) : undefined; Alternatives = Alternatives || require('../alternatives'); - const obj = Alternatives.when(ref, { is: options.is, then, otherwise }); + + const alternativeOptions = { then, otherwise }; + if (Object.prototype.hasOwnProperty.call(options, 'is')) { + alternativeOptions.is = options.is; + } + const obj = Alternatives.when(condition, alternativeOptions); obj._flags.presence = 'ignore'; obj._baseType = this; diff --git a/test/types/alternatives.js b/test/types/alternatives.js index 22bb19d53..4e75c3746 100644 --- a/test/types/alternatives.js +++ b/test/types/alternatives.js @@ -283,490 +283,546 @@ describe('alternatives', () => { expect(() => { Joi.alternatives().when(5, { is: 6, then: Joi.number() }); - }).to.throw('Invalid reference: 5'); + }).to.throw('Invalid condition: 5'); done(); }); - it('validates conditional alternatives', (done) => { + describe('with ref', () => { - const schema = { - a: Joi.alternatives() - .when('b', { is: 5, then: 'x', otherwise: 'y' }) - .try('z'), - b: Joi.any() - }; + it('validates conditional alternatives', (done) => { - Helper.validate(schema, [ - [{ a: 'x', b: 5 }, true], - [{ a: 'x', b: 6 }, false, null, { - message: 'child "a" fails because ["a" must be one of [y]]', - details: [{ - message: '"a" must be one of [y]', - path: ['a'], - type: 'any.allowOnly', - context: { valids: ['y'], label: 'a', key: 'a' } - }] - }], - [{ a: 'y', b: 5 }, false, null, { - message: 'child "a" fails because ["a" must be one of [x]]', - details: [{ - message: '"a" must be one of [x]', - path: ['a'], - type: 'any.allowOnly', - context: { valids: ['x'], label: 'a', key: 'a' } - }] - }], - [{ a: 'y', b: 6 }, true], - [{ a: 'z', b: 5 }, false, null, { - message: 'child "a" fails because ["a" must be one of [x]]', - details: [{ - message: '"a" must be one of [x]', - path: ['a'], - type: 'any.allowOnly', - context: { valids: ['x'], label: 'a', key: 'a' } - }] - }], - [{ a: 'z', b: 6 }, false, null, { - message: 'child "a" fails because ["a" must be one of [y]]', - details: [{ - message: '"a" must be one of [y]', - path: ['a'], - type: 'any.allowOnly', - context: { valids: ['y'], label: 'a', key: 'a' } - }] - }] - ], done); - }); - - it('validates conditional alternatives (empty key)', (done) => { - - const schema = { - a: Joi.alternatives() - .when('', { is: 5, then: 'x', otherwise: 'y' }) - .try('z'), - '': Joi.any() - }; + const schema = { + a: Joi.alternatives() + .when('b', { is: 5, then: 'x', otherwise: 'y' }) + .try('z'), + b: Joi.any() + }; - Helper.validate(schema, [ - [{ a: 'x', '': 5 }, true], - [{ a: 'x', '': 6 }, false, null, { - message: 'child "a" fails because ["a" must be one of [y]]', - details: [{ - message: '"a" must be one of [y]', - path: ['a'], - type: 'any.allowOnly', - context: { valids: ['y'], label: 'a', key: 'a' } - }] - }], - [{ a: 'y', '': 5 }, false, null, { - message: 'child "a" fails because ["a" must be one of [x]]', - details: [{ - message: '"a" must be one of [x]', - path: ['a'], - type: 'any.allowOnly', - context: { valids: ['x'], label: 'a', key: 'a' } - }] - }], - [{ a: 'y', '': 6 }, true], - [{ a: 'z', '': 5 }, false, null, { - message: 'child "a" fails because ["a" must be one of [x]]', - details: [{ - message: '"a" must be one of [x]', - path: ['a'], - type: 'any.allowOnly', - context: { valids: ['x'], label: 'a', key: 'a' } - }] - }], - [{ a: 'z', '': 6 }, false, null, { - message: 'child "a" fails because ["a" must be one of [y]]', - details: [{ - message: '"a" must be one of [y]', - path: ['a'], - type: 'any.allowOnly', - context: { valids: ['y'], label: 'a', key: 'a' } + Helper.validate(schema, [ + [{ a: 'x', b: 5 }, true], + [{ a: 'x', b: 6 }, false, null, { + message: 'child "a" fails because ["a" must be one of [y]]', + details: [{ + message: '"a" must be one of [y]', + path: ['a'], + type: 'any.allowOnly', + context: { valids: ['y'], label: 'a', key: 'a' } + }] + }], + [{ a: 'y', b: 5 }, false, null, { + message: 'child "a" fails because ["a" must be one of [x]]', + details: [{ + message: '"a" must be one of [x]', + path: ['a'], + type: 'any.allowOnly', + context: { valids: ['x'], label: 'a', key: 'a' } + }] + }], + [{ a: 'y', b: 6 }, true], + [{ a: 'z', b: 5 }, false, null, { + message: 'child "a" fails because ["a" must be one of [x]]', + details: [{ + message: '"a" must be one of [x]', + path: ['a'], + type: 'any.allowOnly', + context: { valids: ['x'], label: 'a', key: 'a' } + }] + }], + [{ a: 'z', b: 6 }, false, null, { + message: 'child "a" fails because ["a" must be one of [y]]', + details: [{ + message: '"a" must be one of [y]', + path: ['a'], + type: 'any.allowOnly', + context: { valids: ['y'], label: 'a', key: 'a' } + }] }] - }] - ], done); - }); - - it('validates only then', (done) => { - - const schema = { - a: Joi.alternatives() - .when(Joi.ref('b'), { is: 5, then: 'x' }) - .try('z'), - b: Joi.any() - }; + ], done); + }); - Helper.validate(schema, [ - [{ a: 'x', b: 5 }, true], - [{ a: 'x', b: 6 }, false, null, { - message: 'child "a" fails because ["a" must be one of [z]]', - details: [{ - message: '"a" must be one of [z]', - path: ['a'], - type: 'any.allowOnly', - context: { valids: ['z'], label: 'a', key: 'a' } - }] - }], - [{ a: 'y', b: 5 }, false, null, { - message: 'child "a" fails because ["a" must be one of [x]]', - details: [{ - message: '"a" must be one of [x]', - path: ['a'], - type: 'any.allowOnly', - context: { valids: ['x'], label: 'a', key: 'a' } - }] - }], - [{ a: 'y', b: 6 }, false, null, { - message: 'child "a" fails because ["a" must be one of [z]]', - details: [{ - message: '"a" must be one of [z]', - path: ['a'], - type: 'any.allowOnly', - context: { valids: ['z'], label: 'a', key: 'a' } - }] - }], - [{ a: 'z', b: 5 }, false, null, { - message: 'child "a" fails because ["a" must be one of [x]]', - details: [{ - message: '"a" must be one of [x]', - path: ['a'], - type: 'any.allowOnly', - context: { valids: ['x'], label: 'a', key: 'a' } + it('validates conditional alternatives (empty key)', (done) => { + + const schema = { + a: Joi.alternatives() + .when('', { is: 5, then: 'x', otherwise: 'y' }) + .try('z'), + '': Joi.any() + }; + + Helper.validate(schema, [ + [{ a: 'x', '': 5 }, true], + [{ a: 'x', '': 6 }, false, null, { + message: 'child "a" fails because ["a" must be one of [y]]', + details: [{ + message: '"a" must be one of [y]', + path: ['a'], + type: 'any.allowOnly', + context: { valids: ['y'], label: 'a', key: 'a' } + }] + }], + [{ a: 'y', '': 5 }, false, null, { + message: 'child "a" fails because ["a" must be one of [x]]', + details: [{ + message: '"a" must be one of [x]', + path: ['a'], + type: 'any.allowOnly', + context: { valids: ['x'], label: 'a', key: 'a' } + }] + }], + [{ a: 'y', '': 6 }, true], + [{ a: 'z', '': 5 }, false, null, { + message: 'child "a" fails because ["a" must be one of [x]]', + details: [{ + message: '"a" must be one of [x]', + path: ['a'], + type: 'any.allowOnly', + context: { valids: ['x'], label: 'a', key: 'a' } + }] + }], + [{ a: 'z', '': 6 }, false, null, { + message: 'child "a" fails because ["a" must be one of [y]]', + details: [{ + message: '"a" must be one of [y]', + path: ['a'], + type: 'any.allowOnly', + context: { valids: ['y'], label: 'a', key: 'a' } + }] }] - }], - [{ a: 'z', b: 6 }, true] - ], done); - }); - - it('validates only otherwise', (done) => { + ], done); + }); - const schema = { - a: Joi.alternatives() - .when('b', { is: 5, otherwise: 'y' }) - .try('z'), - b: Joi.any() - }; + it('validates only then', (done) => { + + const schema = { + a: Joi.alternatives() + .when(Joi.ref('b'), { is: 5, then: 'x' }) + .try('z'), + b: Joi.any() + }; + + Helper.validate(schema, [ + [{ a: 'x', b: 5 }, true], + [{ a: 'x', b: 6 }, false, null, { + message: 'child "a" fails because ["a" must be one of [z]]', + details: [{ + message: '"a" must be one of [z]', + path: ['a'], + type: 'any.allowOnly', + context: { valids: ['z'], label: 'a', key: 'a' } + }] + }], + [{ a: 'y', b: 5 }, false, null, { + message: 'child "a" fails because ["a" must be one of [x]]', + details: [{ + message: '"a" must be one of [x]', + path: ['a'], + type: 'any.allowOnly', + context: { valids: ['x'], label: 'a', key: 'a' } + }] + }], + [{ a: 'y', b: 6 }, false, null, { + message: 'child "a" fails because ["a" must be one of [z]]', + details: [{ + message: '"a" must be one of [z]', + path: ['a'], + type: 'any.allowOnly', + context: { valids: ['z'], label: 'a', key: 'a' } + }] + }], + [{ a: 'z', b: 5 }, false, null, { + message: 'child "a" fails because ["a" must be one of [x]]', + details: [{ + message: '"a" must be one of [x]', + path: ['a'], + type: 'any.allowOnly', + context: { valids: ['x'], label: 'a', key: 'a' } + }] + }], + [{ a: 'z', b: 6 }, true] + ], done); + }); - Helper.validate(schema, [ - [{ a: 'x', b: 5 }, false, null, { - message: 'child "a" fails because ["a" must be one of [z]]', - details: [{ - message: '"a" must be one of [z]', - path: ['a'], - type: 'any.allowOnly', - context: { valids: ['z'], label: 'a', key: 'a' } - }] - }], - [{ a: 'x', b: 6 }, false, null, { - message: 'child "a" fails because ["a" must be one of [y]]', - details: [{ - message: '"a" must be one of [y]', - path: ['a'], - type: 'any.allowOnly', - context: { valids: ['y'], label: 'a', key: 'a' } - }] - }], - [{ a: 'y', b: 5 }, false, null, { - message: 'child "a" fails because ["a" must be one of [z]]', - details: [{ - message: '"a" must be one of [z]', - path: ['a'], - type: 'any.allowOnly', - context: { valids: ['z'], label: 'a', key: 'a' } - }] - }], - [{ a: 'y', b: 6 }, true], - [{ a: 'z', b: 5 }, true], - [{ a: 'z', b: 6 }, false, null, { - message: 'child "a" fails because ["a" must be one of [y]]', - details: [{ - message: '"a" must be one of [y]', - path: ['a'], - type: 'any.allowOnly', - context: { valids: ['y'], label: 'a', key: 'a' } + it('validates only otherwise', (done) => { + + const schema = { + a: Joi.alternatives() + .when('b', { is: 5, otherwise: 'y' }) + .try('z'), + b: Joi.any() + }; + + Helper.validate(schema, [ + [{ a: 'x', b: 5 }, false, null, { + message: 'child "a" fails because ["a" must be one of [z]]', + details: [{ + message: '"a" must be one of [z]', + path: ['a'], + type: 'any.allowOnly', + context: { valids: ['z'], label: 'a', key: 'a' } + }] + }], + [{ a: 'x', b: 6 }, false, null, { + message: 'child "a" fails because ["a" must be one of [y]]', + details: [{ + message: '"a" must be one of [y]', + path: ['a'], + type: 'any.allowOnly', + context: { valids: ['y'], label: 'a', key: 'a' } + }] + }], + [{ a: 'y', b: 5 }, false, null, { + message: 'child "a" fails because ["a" must be one of [z]]', + details: [{ + message: '"a" must be one of [z]', + path: ['a'], + type: 'any.allowOnly', + context: { valids: ['z'], label: 'a', key: 'a' } + }] + }], + [{ a: 'y', b: 6 }, true], + [{ a: 'z', b: 5 }, true], + [{ a: 'z', b: 6 }, false, null, { + message: 'child "a" fails because ["a" must be one of [y]]', + details: [{ + message: '"a" must be one of [y]', + path: ['a'], + type: 'any.allowOnly', + context: { valids: ['y'], label: 'a', key: 'a' } + }] }] - }] - ], done); - }); - - it('validates "then" when a preceding "when" has only "otherwise"', (done) => { + ], done); + }); - const schema = Joi.object({ - a: Joi.number(), - b: Joi.number(), - c: Joi.number() - .when('a', { is: 1, otherwise: Joi.number().min(1) }) - .when('b', { is: 1, then: Joi.number().min(1) }) + it('validates "then" when a preceding "when" has only "otherwise"', (done) => { + + const schema = Joi.object({ + a: Joi.number(), + b: Joi.number(), + c: Joi.number() + .when('a', { is: 1, otherwise: Joi.number().min(1) }) + .when('b', { is: 1, then: Joi.number().min(1) }) + }); + + Helper.validate(schema, [ + [{ a: 1, b: 1, c: 0 }, false, null, { + message: 'child "c" fails because ["c" must be larger than or equal to 1]', + details: [{ + message: '"c" must be larger than or equal to 1', + path: ['c'], + type: 'number.min', + context: { limit: 1, value: 0, label: 'c', key: 'c' } + }] + }], + [{ a: 1, b: 1, c: 1 }, true], + [{ a: 0, b: 1, c: 1 }, true], + [{ a: 1, b: 0, c: 0 }, true] + ], done); }); - Helper.validate(schema, [ - [{ a: 1, b: 1, c: 0 }, false, null, { - message: 'child "c" fails because ["c" must be larger than or equal to 1]', - details: [{ - message: '"c" must be larger than or equal to 1', - path: ['c'], - type: 'number.min', - context: { limit: 1, value: 0, label: 'c', key: 'c' } + it('validates when is is null', (done) => { + + const schema = { + a: Joi.alternatives().when('b', { is: null, then: 'x', otherwise: Joi.number() }), + b: Joi.any() + }; + + Helper.validate(schema, [ + [{ a: 1 }, true], + [{ a: 'y' }, false, null, { + message: 'child "a" fails because ["a" must be a number]', + details: [{ + message: '"a" must be a number', + path: ['a'], + type: 'number.base', + context: { label: 'a', key: 'a' } + }] + }], + [{ a: 'x', b: null }, true], + [{ a: 'y', b: null }, false, null, { + message: 'child "a" fails because ["a" must be one of [x]]', + details: [{ + message: '"a" must be one of [x]', + path: ['a'], + type: 'any.allowOnly', + context: { valids: ['x'], label: 'a', key: 'a' } + }] + }], + [{ a: 1, b: null }, false, null, { + message: 'child "a" fails because ["a" must be a string]', + details: [{ + message: '"a" must be a string', + path: ['a'], + type: 'string.base', + context: { value: 1, label: 'a', key: 'a' } + }] }] - }], - [{ a: 1, b: 1, c: 1 }, true], - [{ a: 0, b: 1, c: 1 }, true], - [{ a: 1, b: 0, c: 0 }, true] - ], done); - }); - - it('validates when is is null', (done) => { - - const schema = { - a: Joi.alternatives().when('b', { is: null, then: 'x', otherwise: Joi.number() }), - b: Joi.any() - }; + ], done); + }); - Helper.validate(schema, [ - [{ a: 1 }, true], - [{ a: 'y' }, false, null, { - message: 'child "a" fails because ["a" must be a number]', - details: [{ - message: '"a" must be a number', - path: ['a'], - type: 'number.base', - context: { label: 'a', key: 'a' } - }] - }], - [{ a: 'x', b: null }, true], - [{ a: 'y', b: null }, false, null, { - message: 'child "a" fails because ["a" must be one of [x]]', - details: [{ - message: '"a" must be one of [x]', - path: ['a'], - type: 'any.allowOnly', - context: { valids: ['x'], label: 'a', key: 'a' } - }] - }], - [{ a: 1, b: null }, false, null, { - message: 'child "a" fails because ["a" must be a string]', - details: [{ - message: '"a" must be a string', - path: ['a'], - type: 'string.base', - context: { value: 1, label: 'a', key: 'a' } + it('validates when is has ref', (done) => { + + const schema = { + a: Joi.alternatives().when('b', { is: Joi.ref('c'), then: 'x' }), + b: Joi.any(), + c: Joi.number() + }; + + Helper.validate(schema, [ + [{ a: 'x', b: 5, c: '5' }, true], + [{ a: 'x', b: 5, c: '1' }, false, null, { + message: 'child "a" fails because ["a" not matching any of the allowed alternatives]', + details: [{ + message: '"a" not matching any of the allowed alternatives', + path: ['a'], + type: 'alternatives.base', + context: { label: 'a', key: 'a' } + }] + }], + [{ a: 'x', b: '5', c: '5' }, false, null, { + message: 'child "a" fails because ["a" not matching any of the allowed alternatives]', + details: [{ + message: '"a" not matching any of the allowed alternatives', + path: ['a'], + type: 'alternatives.base', + context: { label: 'a', key: 'a' } + }] + }], + [{ a: 'y', b: 5, c: 5 }, false, null, { + message: 'child "a" fails because ["a" must be one of [x]]', + details: [{ + message: '"a" must be one of [x]', + path: ['a'], + type: 'any.allowOnly', + context: { valids: ['x'], label: 'a', key: 'a' } + }] + }], + [{ a: 'y' }, false, null, { + message: 'child "a" fails because ["a" must be one of [x]]', + details: [{ + message: '"a" must be one of [x]', + path: ['a'], + type: 'any.allowOnly', + context: { valids: ['x'], label: 'a', key: 'a' } + }] }] - }] - ], done); - }); - - it('validates when is has ref', (done) => { - - const schema = { - a: Joi.alternatives().when('b', { is: Joi.ref('c'), then: 'x' }), - b: Joi.any(), - c: Joi.number() - }; + ], done); + }); - Helper.validate(schema, [ - [{ a: 'x', b: 5, c: '5' }, true], - [{ a: 'x', b: 5, c: '1' }, false, null, { - message: 'child "a" fails because ["a" not matching any of the allowed alternatives]', - details: [{ - message: '"a" not matching any of the allowed alternatives', - path: ['a'], - type: 'alternatives.base', - context: { label: 'a', key: 'a' } - }] - }], - [{ a: 'x', b: '5', c: '5' }, false, null, { - message: 'child "a" fails because ["a" not matching any of the allowed alternatives]', - details: [{ - message: '"a" not matching any of the allowed alternatives', - path: ['a'], - type: 'alternatives.base', - context: { label: 'a', key: 'a' } - }] - }], - [{ a: 'y', b: 5, c: 5 }, false, null, { - message: 'child "a" fails because ["a" must be one of [x]]', - details: [{ - message: '"a" must be one of [x]', - path: ['a'], - type: 'any.allowOnly', - context: { valids: ['x'], label: 'a', key: 'a' } - }] - }], - [{ a: 'y' }, false, null, { - message: 'child "a" fails because ["a" must be one of [x]]', - details: [{ - message: '"a" must be one of [x]', - path: ['a'], - type: 'any.allowOnly', - context: { valids: ['x'], label: 'a', key: 'a' } + it('validates when then has ref', (done) => { + + const ref = Joi.ref('c'); + const schema = { + a: Joi.alternatives().when('b', { is: 5, then: ref }), + b: Joi.any(), + c: Joi.number() + }; + + Helper.validate(schema, [ + [{ a: 'x', b: 5, c: '1' }, false, null, { + message: 'child "a" fails because ["a" must be one of [ref:c]]', + details: [{ + message: '"a" must be one of [ref:c]', + path: ['a'], + type: 'any.allowOnly', + context: { valids: [ref], label: 'a', key: 'a' } + }] + }], + [{ a: 1, b: 5, c: '1' }, true], + [{ a: '1', b: 5, c: '1' }, false, null, { + message: 'child "a" fails because ["a" must be one of [ref:c]]', + details: [{ + message: '"a" must be one of [ref:c]', + path: ['a'], + type: 'any.allowOnly', + context: { valids: [ref], label: 'a', key: 'a' } + }] }] - }] - ], done); - }); - - it('validates when then has ref', (done) => { - - const ref = Joi.ref('c'); - const schema = { - a: Joi.alternatives().when('b', { is: 5, then: ref }), - b: Joi.any(), - c: Joi.number() - }; + ], done); + }); - Helper.validate(schema, [ - [{ a: 'x', b: 5, c: '1' }, false, null, { - message: 'child "a" fails because ["a" must be one of [ref:c]]', - details: [{ - message: '"a" must be one of [ref:c]', - path: ['a'], - type: 'any.allowOnly', - context: { valids: [ref], label: 'a', key: 'a' } - }] - }], - [{ a: 1, b: 5, c: '1' }, true], - [{ a: '1', b: 5, c: '1' }, false, null, { - message: 'child "a" fails because ["a" must be one of [ref:c]]', - details: [{ - message: '"a" must be one of [ref:c]', - path: ['a'], - type: 'any.allowOnly', - context: { valids: [ref], label: 'a', key: 'a' } + it('validates when otherwise has ref', (done) => { + + const ref = Joi.ref('c'); + const schema = { + a: Joi.alternatives().when('b', { is: 6, otherwise: ref }), + b: Joi.any(), + c: Joi.number() + }; + + Helper.validate(schema, [ + [{ a: 'x', b: 5, c: '1' }, false, null, { + message: 'child "a" fails because ["a" must be one of [ref:c]]', + details: [{ + message: '"a" must be one of [ref:c]', + path: ['a'], + type: 'any.allowOnly', + context: { valids: [ref], label: 'a', key: 'a' } + }] + }], + [{ a: 1, b: 5, c: '1' }, true], + [{ a: '1', b: 5, c: '1' }, false, null, { + message: 'child "a" fails because ["a" must be one of [ref:c]]', + details: [{ + message: '"a" must be one of [ref:c]', + path: ['a'], + type: 'any.allowOnly', + context: { valids: [ref], label: 'a', key: 'a' } + }] }] - }] - ], done); - }); + ], done); + }); - it('validates when otherwise has ref', (done) => { + it('validates when empty value', (done) => { - const ref = Joi.ref('c'); - const schema = { - a: Joi.alternatives().when('b', { is: 6, otherwise: ref }), - b: Joi.any(), - c: Joi.number() - }; + const schema = { + a: Joi.alternatives().when('b', { is: true, then: Joi.required() }), + b: Joi.boolean().default(false) + }; - Helper.validate(schema, [ - [{ a: 'x', b: 5, c: '1' }, false, null, { - message: 'child "a" fails because ["a" must be one of [ref:c]]', - details: [{ - message: '"a" must be one of [ref:c]', - path: ['a'], - type: 'any.allowOnly', - context: { valids: [ref], label: 'a', key: 'a' } + Helper.validate(schema, [ + [{ b: false }, true], + [{ b: true }, true] // true because required() only applies to the one alternative + ], done); + }); + + it('validates when missing value', (done) => { + + const schema = Joi.object({ + a: Joi.alternatives().when('b', { + is: 5, + then: Joi.optional(), + otherwise: Joi.required() + }).required(), + b: Joi.number() + }); + + Helper.validate(schema, [ + [{ a: 1 }, true], + [{}, false, null, { + message: 'child "a" fails because ["a" is required]', + details: [{ + message: '"a" is required', + path: ['a'], + type: 'any.required', + context: { label: 'a', key: 'a' } + }] + }], + [{ b: 1 }, false, null, { + message: 'child "a" fails because ["a" is required]', + details: [{ + message: '"a" is required', + path: ['a'], + type: 'any.required', + context: { label: 'a', key: 'a' } + }] + }], + [{ a: 1, b: 1 }, true], + [{ a: 1, b: 5 }, true], + [{ b: 5 }, false, null, { + message: 'child "a" fails because ["a" is required]', + details: [{ + message: '"a" is required', + path: ['a'], + type: 'any.required', + context: { label: 'a', key: 'a' } + }] }] - }], - [{ a: 1, b: 5, c: '1' }, true], - [{ a: '1', b: 5, c: '1' }, false, null, { - message: 'child "a" fails because ["a" must be one of [ref:c]]', - details: [{ - message: '"a" must be one of [ref:c]', - path: ['a'], - type: 'any.allowOnly', - context: { valids: [ref], label: 'a', key: 'a' } + ], done); + }); + + it('validates with nested whens', (done) => { + + // If ((b === 0 && a === 123) || + // (b !== 0 && a === anything)) + // then c === 456 + // else c === 789 + const schema = Joi.object({ + a: Joi.number().required(), + b: Joi.number().required(), + c: Joi.when('a', { + is: Joi.when('b', { + is: Joi.valid(0), + then: Joi.valid(123) + }), + then: Joi.valid(456), + otherwise: Joi.valid(789) + }) + }); + + Helper.validate(schema, [ + [{ a: 123, b: 0, c: 456 }, true], + [{ a: 0, b: 1, c: 456 }, true], + [{ a: 0, b: 0, c: 789 }, true], + [{ a: 123, b: 456, c: 456 }, true], + [{ a: 0, b: 0, c: 456 }, false, null, { + message: 'child "c" fails because ["c" must be one of [789]]', + details: [{ + message: '"c" must be one of [789]', + path: ['c'], + type: 'any.allowOnly', + context: { valids: [789], label: 'c', key: 'c' } + }] + }], + [{ a: 123, b: 456, c: 789 }, false, null, { + message: 'child "c" fails because ["c" must be one of [456]]', + details: [{ + message: '"c" must be one of [456]', + path: ['c'], + type: 'any.allowOnly', + context: { valids: [456], label: 'c', key: 'c' } + }] }] - }] - ], done); + ], done); + }); }); - it('validates when empty value', (done) => { - - const schema = { - a: Joi.alternatives().when('b', { is: true, then: Joi.required() }), - b: Joi.boolean().default(false) - }; - - Helper.validate(schema, [ - [{ b: false }, true], - [{ b: true }, true] // true because required() only applies to the one alternative - ], done); - }); + describe('with schema', () => { - it('validates when missing value', (done) => { + it('should peek inside a simple value', (done) => { - const schema = Joi.object({ - a: Joi.alternatives().when('b', { is: 5, then: Joi.optional(), otherwise: Joi.required() }).required(), - b: Joi.number() + const schema = Joi.number().when(Joi.number().min(0), { then: Joi.number().min(10) }); + Helper.validate(schema, [ + [-1, true, null, -1], + [1, false, null, { + message: '"value" must be larger than or equal to 10', + details: [{ + message: '"value" must be larger than or equal to 10', + path: [], + type: 'number.min', + context: { limit: 10, value: 1, key: undefined, label: 'value' } + }] + }], + [10, true, null, 10] + ], done); }); - Helper.validate(schema, [ - [{ a: 1 }, true], - [{}, false, null, { - message: 'child "a" fails because ["a" is required]', - details: [{ - message: '"a" is required', - path: ['a'], - type: 'any.required', - context: { label: 'a', key: 'a' } - }] - }], - [{ b: 1 }, false, null, { - message: 'child "a" fails because ["a" is required]', - details: [{ - message: '"a" is required', - path: ['a'], - type: 'any.required', - context: { label: 'a', key: 'a' } - }] - }], - [{ a: 1, b: 1 }, true], - [{ a: 1, b: 5 }, true], - [{ b: 5 }, false, null, { - message: 'child "a" fails because ["a" is required]', - details: [{ - message: '"a" is required', - path: ['a'], - type: 'any.required', - context: { label: 'a', key: 'a' } - }] - }] - ], done); - }); - - it('validates with nested whens', (done) => { - - // If ((b === 0 && a === 123) || - // (b !== 0 && a === anything)) - // then c === 456 - // else c === 789 - const schema = Joi.object({ - a: Joi.number().required(), - b: Joi.number().required(), - c: Joi.when('a', { - is: Joi.when('b', { - is: Joi.valid(0), - then: Joi.valid(123) - }), - then: Joi.valid(456), - otherwise: Joi.valid(789) - }) + it('should peek inside an object', (done) => { + + const schema = Joi.object().keys({ + foo: Joi.string(), + bar: Joi.number() + }).when(Joi.object().keys({ + foo: Joi.only('hasBar').required() + }).unknown(), { + then: Joi.object().keys({ + bar: Joi.required() + }) + }); + Helper.validate(schema, [ + [{ foo: 'whatever' }, true, null, { foo: 'whatever' }], + [{ foo: 'hasBar' }, false, null, { + message: 'child "bar" fails because ["bar" is required]', + details: [{ + message: '"bar" is required', + path: ['bar'], + type: 'any.required', + context: { key: 'bar', label: 'bar' } + }] + }], + [{ foo: 'hasBar', bar: 42 }, true, null, { foo: 'hasBar', bar: 42 }], + [{}, true, null, {}] + ], done); }); - - Helper.validate(schema, [ - [{ a: 123, b: 0, c: 456 }, true], - [{ a: 0, b: 1, c: 456 }, true], - [{ a: 0, b: 0, c: 789 }, true], - [{ a: 123, b: 456, c: 456 }, true], - [{ a: 0, b: 0, c: 456 }, false, null, { - message: 'child "c" fails because ["c" must be one of [789]]', - details: [{ - message: '"c" must be one of [789]', - path: ['c'], - type: 'any.allowOnly', - context: { valids: [789], label: 'c', key: 'c' } - }] - }], - [{ a: 123, b: 456, c: 789 }, false, null, { - message: 'child "c" fails because ["c" must be one of [456]]', - details: [{ - message: '"c" must be one of [456]', - path: ['c'], - type: 'any.allowOnly', - context: { valids: [456], label: 'c', key: 'c' } - }] - }] - ], done); }); }); @@ -945,6 +1001,42 @@ describe('alternatives', () => { done(); }); + it('describes when (with schema)', (done) => { + + const schema = Joi.alternatives() + .when(Joi.string().label('foo'), { + then: Joi.string().required().min(1), + otherwise: Joi.boolean() + }); + + const outcome = { + type: 'alternatives', + alternatives: [{ + peek: { + type: 'string', + flags: {}, + label: 'foo', + invalids: [''] + }, + then: { + type: 'string', + flags: { presence: 'required' }, + invalids: [''], + rules: [{ arg: 1, name: 'min' }] + }, + otherwise: { + type: 'boolean', + flags: { insensitive: true }, + truthy: [true], + falsy: [false] + } + }] + }; + + expect(Joi.describe(schema)).to.equal(outcome); + done(); + }); + it('describes inherited fields (from any)', (done) => { const schema = Joi.alternatives() diff --git a/test/types/any.js b/test/types/any.js index 28332c4d3..0836668f9 100644 --- a/test/types/any.js +++ b/test/types/any.js @@ -2211,7 +2211,7 @@ describe('any', () => { ], done); }); - it('forks type into alternatives (with a schema)', (done) => { + it('forks type into alternatives (with is as a schema)', (done) => { const schema = { a: Joi.any(), @@ -2270,6 +2270,75 @@ describe('any', () => { ], done); }); + it('forks type into alternatives (with a schema as condition)', (done) => { + + const schema = Joi.object({ + a: Joi.string(), + b: Joi.number(), + c: Joi.boolean() + }) + .when(Joi.object({ a: Joi.string().min(2).required() }).unknown(), { + then: Joi.object({ b: Joi.required() }) + }) + .when(Joi.object({ b: Joi.number().required().min(5), c: Joi.only(true).required() }).unknown(), { + then: Joi.object({ a: Joi.string().required().min(3) }) + }); + + Helper.validate(schema, [ + [{ a: 0 }, false, null, { + message: 'child "a" fails because ["a" must be a string]', + details: [{ + message: '"a" must be a string', + path: ['a'], + type: 'string.base', + context: { value: 0, key: 'a', label: 'a' } + }] + }], + [{ a: 'a' }, true], + [{ a: 'a', b: 'b' }, false, null, { + message: 'child "b" fails because ["b" must be a number]', + details: [{ + message: '"b" must be a number', + path: ['b'], + type: 'number.base', + context: { key: 'b', label: 'b' } + }] + }], + [{ a: 'a', b: 0 }, true], + [{ a: 'a', b: 0, c: true }, true], + [{ a: 'a', b: 0, c: 'c' }, false, null, { + message: 'child "c" fails because ["c" must be a boolean]', + details: [{ + message: '"c" must be a boolean', + path: ['c'], + type: 'boolean.base', + context: { key: 'c', label: 'c' } + }] + }], + [{ a: 'aa' }, false, null, { + message: 'child "b" fails because ["b" is required]', + details: [{ + message: '"b" is required', + path: ['b'], + type: 'any.required', + context: { key: 'b', label: 'b' } + }] + }], + [{ a: 'aa', b: 0 }, true], + [{ a: 'aa', b: 10 }, true], + [{ a: 'a', b: 10 }, true], + [{ a: 'a', b: 10, c: true }, false, null, { + message: 'child "a" fails because ["a" length must be at least 3 characters long]', + details: [{ + message: '"a" length must be at least 3 characters long', + path: ['a'], + type: 'string.min', + context: { encoding: undefined, limit: 3, value: 'a', key: 'a', label: 'a' } + }] + }] + ], done); + }); + it('makes peer required', (done) => { const schema = { @@ -2331,6 +2400,34 @@ describe('any', () => { }); done(); }); + + it('can describe as the original object (with a schema as a condition)', (done) => { + + const schema = Joi.number().min(10).when(Joi.number().min(5), { then: Joi.number().max(20).required() }).describe(); + expect(schema).to.equal({ + type: 'alternatives', + flags: { presence: 'ignore' }, + base: { + type: 'number', + invalids: [Infinity, -Infinity], + rules: [{ arg: 10, name: 'min' }] + }, + alternatives: [{ + peek: { + type: 'number', + invalids: [Infinity, -Infinity], + rules: [{ name: 'min', arg: 5 }] + }, + then: { + type: 'number', + flags: { presence: 'required' }, + invalids: [Infinity, -Infinity], + rules: [{ name: 'min', arg: 10 }, { name: 'max', arg: 20 }] + } + }] + }); + done(); + }); }); describe('empty()', () => {