diff --git a/API.md b/API.md index 1102760ae..76c1e9868 100644 --- a/API.md +++ b/API.md @@ -1603,6 +1603,7 @@ const schema = Joi.object().length(5); Specify validation rules for unknown keys matching a pattern where: - `regex` - a regular expression tested against the unknown key names. +- `regex` - may also be a schema object validated against the unknown key names. - `schema` - the schema object matching keys must validate against. ```js diff --git a/lib/types/object/index.js b/lib/types/object/index.js index 78833b752..562b11d93 100644 --- a/lib/types/object/index.js +++ b/lib/types/object/index.js @@ -249,14 +249,24 @@ internals.Object = class extends Any { for (let i = 0; i < this._inner.patterns.length; ++i) { const pattern = this._inner.patterns[i]; - if (pattern.regex.test(key)) { + let shouldProcess = false; + + if (pattern.regex && pattern.patternRule.test(key)) { + shouldProcess = true; + } + else if (!pattern.regex) { + const keyResult = pattern.patternRule.validate(key); + shouldProcess = !keyResult.error; + } + + if (shouldProcess) { unprocessed.delete(key); - const result = pattern.rule._validate(item, localState, options); + const result = pattern.valueRule._validate(item, localState, options); if (result.errors) { errors.push(this.createError('object.child', { key, - child: pattern.rule._getLabel(key), + child: pattern.valueRule._getLabel(key), reason: result.errors }, localState, options)); @@ -438,10 +448,15 @@ internals.Object = class extends Any { pattern(pattern, schema) { - Hoek.assert(pattern instanceof RegExp, 'Invalid regular expression'); + Hoek.assert(pattern instanceof RegExp || pattern instanceof Any, 'pattern must be a regex or schema'); Hoek.assert(schema !== undefined, 'Invalid rule'); - pattern = new RegExp(pattern.source, pattern.ignoreCase ? 'i' : undefined); // Future version should break this and forbid unsupported regex flags + let regex = false; + + if (pattern instanceof RegExp) { + pattern = new RegExp(pattern.source, pattern.ignoreCase ? 'i' : undefined); // Future version should break this and forbid unsupported regex flags + regex = true; + } try { schema = Cast.schema(this._currentJoi, schema); @@ -454,9 +469,8 @@ internals.Object = class extends Any { throw castErr; } - const obj = this.clone(); - obj._inner.patterns.push({ regex: pattern, rule: schema }); + obj._inner.patterns.push({ patternRule: pattern, valueRule: schema, regex }); return obj; } @@ -642,7 +656,12 @@ internals.Object = class extends Any { for (let i = 0; i < this._inner.patterns.length; ++i) { const pattern = this._inner.patterns[i]; - description.patterns.push({ regex: pattern.regex.toString(), rule: pattern.rule.describe() }); + if (pattern.regex) { + description.patterns.push({ regex: pattern.patternRule.toString(), rule: pattern.valueRule.describe() }); + } + else { + description.patterns.push({ regex: pattern.patternRule.describe(), rule: pattern.valueRule.describe() }); + } } } diff --git a/test/types/object.js b/test/types/object.js index 01d5b3d98..261ac8c91 100644 --- a/test/types/object.js +++ b/test/types/object.js @@ -1548,6 +1548,43 @@ describe('object', () => { ] }); }); + + it('describes patterns with schema', () => { + + const schema = Joi.object({ + a: Joi.string() + }).pattern(Joi.string().uuid('uuidv4'), Joi.boolean()); + + expect(schema.describe()).to.equal({ + type: 'object', + children: { + a: { + type: 'string', + invalids: [''] + } + }, + patterns: [ + { + regex: { + invalids: [''], + rules: [{ + arg: 'uuidv4', + name: 'guid' + }], + type: 'string' + }, + rule: { + type: 'boolean', + truthy: [true], + falsy: [false], + flags: { + insensitive: true + } + } + } + ] + }); + }); }); describe('length()', () => { @@ -1609,7 +1646,7 @@ describe('object', () => { }); - it('validates unknown keys using a pattern', async () => { + it('validates unknown keys using a regex pattern', async () => { const schema = Joi.object({ a: Joi.number() @@ -1667,6 +1704,65 @@ describe('object', () => { ]); }); + it('validates unknown keys using a schema pattern', async () => { + + const schema = Joi.object({ + a: Joi.number() + }).pattern(Joi.number().positive(), Joi.boolean()) + .pattern(Joi.string().length(2), 'x'); + + const err = await expect(Joi.validate({ bb: 'y', 5: 'x' }, schema, { abortEarly: false })).to.reject(); + expect(err).to.be.an.error('child "5" fails because ["5" must be a boolean]. child "bb" fails because ["bb" must be one of [x]]'); + expect(err.details).to.equal([ + { + message: '"5" must be a boolean', + path: ['5'], + type: 'boolean.base', + context: { label: '5', key: '5' } + }, + { + message: '"bb" must be one of [x]', + path: ['bb'], + type: 'any.allowOnly', + context: { value: 'y', valids: ['x'], label: 'bb', key: 'bb' } + } + ]); + + Helper.validate(schema, [ + [{ a: 5 }, true], + [{ a: 'x' }, 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' } + }] + }], + [{ b: 'x' }, false, null, { + message: '"b" is not allowed', + details: [{ + message: '"b" is not allowed', + path: ['b'], + type: 'object.allowUnknown', + context: { child: 'b', label: 'b', key: 'b' } + }] + }], + [{ bb: 'x' }, true], + [{ 5: 'x' }, false, null, { + message: 'child "5" fails because ["5" must be a boolean]', + details: [{ + message: '"5" must be a boolean', + path: ['5'], + type: 'boolean.base', + context: { label: '5', key: '5' } + }] + }], + [{ 5: false }, true], + [{ 5: undefined }, true] + ]); + }); + it('validates unknown keys using a pattern (nested)', async () => { const schema = { @@ -1693,7 +1789,33 @@ describe('object', () => { ]); }); - it('errors when using a pattern on empty schema with unknown(false) and pattern mismatch', async () => { + it('validates unknown keys using a pattern (nested)', async () => { + + const schema = { + x: Joi.object({ + a: Joi.number() + }).pattern(Joi.number().positive(), Joi.boolean()).pattern(Joi.string().length(2), 'x') + }; + + const err = await expect(Joi.validate({ x: { bb: 'y', 5: 'x' } }, schema, { abortEarly: false })).to.reject(); + expect(err).to.be.an.error('child "x" fails because [child "5" fails because ["5" must be a boolean], child "bb" fails because ["bb" must be one of [x]]]'); + expect(err.details).to.equal([ + { + message: '"5" must be a boolean', + path: ['x', '5'], + type: 'boolean.base', + context: { label: '5', key: '5' } + }, + { + message: '"bb" must be one of [x]', + path: ['x', 'bb'], + type: 'any.allowOnly', + context: { value: 'y', valids: ['x'], label: 'bb', key: 'bb' } + } + ]); + }); + + it('errors when using a pattern on empty schema with unknown(false) and regex pattern mismatch', async () => { const schema = Joi.object().pattern(/\d/, Joi.number()).unknown(false); @@ -1706,6 +1828,19 @@ describe('object', () => { }]); }); + it('errors when using a pattern on empty schema with unknown(false) and schema pattern mismatch', async () => { + + const schema = Joi.object().pattern(Joi.number().positive(), Joi.number()).unknown(false); + + const err = await expect(Joi.validate({ a: 5 }, schema, { abortEarly: false })).to.reject('"a" is not allowed'); + expect(err.details).to.equal([{ + message: '"a" is not allowed', + path: ['a'], + type: 'object.allowUnknown', + context: { child: 'a', label: 'a', key: 'a' } + }]); + }); + it('removes global flag from patterns', async () => { const schema = Joi.object().pattern(/a/g, Joi.number()); @@ -1719,6 +1854,19 @@ describe('object', () => { const value = await Joi.validate({ a1: undefined, a2: null, a3: 'test' }, schema); expect(value).to.equal({ a1: undefined, a2: undefined, a3: 'test' }); }); + + it('should throw an error if pattern is not regex or instance of Any', () => { + + let error; + try { + Joi.object().pattern(17, Joi.boolean()); + error = false; + } + catch (e) { + error = true; + } + expect(error).to.equal(true); + }); }); describe('with()', () => {