diff --git a/README.md b/README.md index 9af350d..f63b9c1 100644 --- a/README.md +++ b/README.md @@ -60,6 +60,7 @@ The following set of extra asserts are provided by this package: | [NullOrBoolean](#nullorboolean) | | | [NullOrDate](#nullordate) | | | [NullOrString](#nullorstring) | | +| [OneOf](#oneof) | | | [Phone](#phone) | [`google-libphonenumber`][google-libphonenumber-url] | | [PlainObject](#plainobject) | | | [RfcNumber](#rfcnumber) | [`validate-rfc`][validate-rfc-url] | @@ -276,6 +277,14 @@ Tests if the value is a `null` or `string`, optionally within some boundaries. - `boundaries` (optional) - `max` and/or `min` boundaries to test the string for. +### OneOf + +Tests if the value matches exactly one of the provided assert sets. Throws a violation if the value matches none or more than one assert set. + +#### Arguments + +- `...constraintSets` (required) - two or more constraint sets to test the value against. Each constraint set must be a plain object mapping field names to arrays of constraints. + ### Phone Tests if the phone is valid and optionally if it belongs to the given country. The phone can be in the national or E164 formats. diff --git a/src/asserts/one-of-assert.js b/src/asserts/one-of-assert.js new file mode 100644 index 0000000..ad767cc --- /dev/null +++ b/src/asserts/one-of-assert.js @@ -0,0 +1,46 @@ +'use strict'; + +/** + * Module dependencies. + */ + +const { Constraint, Violation } = require('validator.js'); + +/** + * Export `OneOfAssert`. + */ + +module.exports = function oneOfAssert(...constraintSets) { + this.__class__ = 'OneOf'; + + if (constraintSets.length < 2) { + throw new Error('OneOf assert requires at least two constraint sets'); + } + + this.validate = value => { + const matches = []; + const violations = []; + + for (const constraintSet of constraintSets) { + const result = new Constraint(constraintSet, { deepRequired: true }).check(value); + + if (result === true) { + matches.push(constraintSet); + } else { + violations.push(result); + } + } + + if (matches.length === 1) { + return true; + } + + if (matches.length > 1) { + violations.push(new Violation(this, value, { value: 'more_than_one_constraint_set_matched' })); + } + + throw new Violation(this, value, violations); + }; + + return this; +}; diff --git a/src/index.js b/src/index.js index 4cd8b16..30edde4 100644 --- a/src/index.js +++ b/src/index.js @@ -36,6 +36,7 @@ const NullOr = require('./asserts/null-or-assert.js'); const NullOrBoolean = require('./asserts/null-or-boolean-assert.js'); const NullOrDate = require('./asserts/null-or-date-assert.js'); const NullOrString = require('./asserts/null-or-string-assert.js'); +const OneOf = require('./asserts/one-of-assert.js'); const Phone = require('./asserts/phone-assert.js'); const PlainObject = require('./asserts/plain-object-assert.js'); const RfcNumber = require('./asserts/rfc-number-assert.js'); @@ -83,6 +84,7 @@ module.exports = { NullOrBoolean, NullOrDate, NullOrString, + OneOf, Phone, PlainObject, RfcNumber, diff --git a/src/types/index.d.ts b/src/types/index.d.ts index 68d90e1..38d806d 100644 --- a/src/types/index.d.ts +++ b/src/types/index.d.ts @@ -170,6 +170,9 @@ export interface ValidatorJSAsserts { /** Value is null or a string (length within `[min, max]`). */ nullOrString(boundaries?: { min?: number; max?: number }): AssertInstance; + /** Value matches exactly one of the provided constraint sets. */ + oneOf(...constraintSets: Array<{ [key: string]: AssertInstance | Array }>): AssertInstance; + /** Valid phone number (optionally by country code). @requires google-libphonenumber */ phone(options?: { countryCode?: string }): AssertInstance; diff --git a/test/asserts/one-of-assert.test.js b/test/asserts/one-of-assert.test.js new file mode 100644 index 0000000..9dd1e8a --- /dev/null +++ b/test/asserts/one-of-assert.test.js @@ -0,0 +1,169 @@ +'use strict'; + +/** + * Module dependencies. + */ + +const { Assert: BaseAssert, Violation } = require('validator.js'); +const { describe, it } = require('node:test'); +const OneOfAssert = require('../../src/asserts/one-of-assert.js'); + +/** + * Extend `Assert` with `OneOfAssert`. + */ + +const Assert = BaseAssert.extend({ + OneOf: OneOfAssert +}); + +/** + * Test `OneOfAssert`. + */ + +describe('OneOfAssert', () => { + it('should throw an error if no constraint sets are provided', ({ assert }) => { + try { + Assert.oneOf(); + + assert.fail(); + } catch (e) { + assert.equal(e.message, 'OneOf assert requires at least two constraint sets'); + } + }); + + it('should throw an error if only one constraint set is provided', ({ assert }) => { + try { + Assert.oneOf({ bar: [Assert.equalTo('foo')] }); + + assert.fail(); + } catch (e) { + assert.equal(e.message, 'OneOf assert requires at least two constraint sets'); + } + }); + + it('should throw an error if value does not match any constraint set', ({ assert }) => { + try { + Assert.oneOf({ bar: [Assert.equalTo('foo')] }, { bar: [Assert.equalTo('baz')] }).validate({ bar: 'biz' }); + + assert.fail(); + } catch (e) { + assert.ok(e instanceof Violation); + assert.equal(e.show().assert, 'OneOf'); + } + }); + + it('should include all violations in the error when no constraint set matches', ({ assert }) => { + try { + Assert.oneOf({ bar: [Assert.equalTo('biz')] }, { bar: [Assert.equalTo('baz')] }).validate({ bar: 'qux' }); + + assert.fail(); + } catch (e) { + const { violation } = e.show(); + + assert.equal(violation.length, 2); + assert.ok(violation[0].bar[0] instanceof Violation); + assert.equal(violation[0].bar[0].show().assert, 'EqualTo'); + assert.equal(violation[0].bar[0].show().violation.value, 'biz'); + assert.ok(violation[1].bar[0] instanceof Violation); + assert.equal(violation[1].bar[0].show().assert, 'EqualTo'); + assert.equal(violation[1].bar[0].show().violation.value, 'baz'); + } + }); + + it('should validate required fields using `deepRequired`', ({ assert }) => { + try { + Assert.oneOf( + { bar: [Assert.required(), Assert.notBlank()] }, + { baz: [Assert.required(), Assert.notBlank()] } + ).validate({}); + + assert.fail(); + } catch (e) { + assert.ok(e instanceof Violation); + assert.equal(e.show().assert, 'OneOf'); + } + }); + + it('should throw an error if a constraint set with an extra assert does not match', ({ assert }) => { + try { + Assert.oneOf( + { + bar: [Assert.equalTo('biz')], + baz: [Assert.oneOf({ qux: [Assert.equalTo('corge')] }, { qux: [Assert.equalTo('grault')] })] + }, + { bar: [Assert.equalTo('baz')] } + ).validate({ bar: 'biz', baz: { qux: 'wrong' } }); + + assert.fail(); + } catch (e) { + assert.ok(e instanceof Violation); + assert.equal(e.show().assert, 'OneOf'); + } + }); + + it('should throw an error if value matches more than one constraint set', ({ assert }) => { + try { + Assert.oneOf({ bar: [Assert.equalTo('biz')] }, { bar: [Assert.equalTo('biz')] }).validate({ bar: 'biz' }); + + assert.fail(); + } catch (e) { + const { violation } = e.show(); + + assert.ok(e instanceof Violation); + assert.equal(e.show().assert, 'OneOf'); + assert.equal(violation.length, 1); + assert.ok(violation[0] instanceof Violation); + assert.equal(violation[0].show().violation.value, 'more_than_one_constraint_set_matched'); + } + }); + + it('should throw an error if value matches more than one constraint set with overlapping schemas', ({ assert }) => { + try { + Assert.oneOf({ bar: [Assert.notBlank()] }, { bar: [Assert.equalTo('biz')] }).validate({ bar: 'biz' }); + + assert.fail(); + } catch (e) { + const { violation } = e.show(); + + assert.ok(e instanceof Violation); + assert.equal(e.show().assert, 'OneOf'); + assert.equal(violation.length, 1); + assert.ok(violation[0] instanceof Violation); + assert.equal(violation[0].show().violation.value, 'more_than_one_constraint_set_matched'); + } + }); + + it('should pass if value matches the first constraint set', ({ assert }) => { + assert.doesNotThrow(() => { + Assert.oneOf({ bar: [Assert.equalTo('biz')] }, { bar: [Assert.equalTo('baz')] }).validate({ bar: 'biz' }); + }); + }); + + it('should pass if value matches the second constraint set', ({ assert }) => { + assert.doesNotThrow(() => { + Assert.oneOf({ bar: [Assert.equalTo('biz')] }, { bar: [Assert.equalTo('baz')] }).validate({ bar: 'baz' }); + }); + }); + + it('should support more than two constraint sets', ({ assert }) => { + assert.doesNotThrow(() => { + Assert.oneOf( + { bar: [Assert.equalTo('biz')] }, + { bar: [Assert.equalTo('baz')] }, + { bar: [Assert.equalTo('qux')] } + ).validate({ bar: 'qux' }); + }); + }); + + it('should pass if a constraint set contains an extra assert', ({ assert }) => { + assert.doesNotThrow(() => { + Assert.oneOf( + { + bar: [Assert.equalTo('biz')], + baz: [Assert.oneOf({ qux: [Assert.equalTo('corge')] }, { qux: [Assert.equalTo('grault')] })] + }, + { bar: [Assert.equalTo('baz')] } + ).validate({ bar: 'biz', baz: { qux: 'corge' } }); + }); + }); +}); diff --git a/test/index.test.js b/test/index.test.js index 196511a..5809556 100644 --- a/test/index.test.js +++ b/test/index.test.js @@ -15,7 +15,7 @@ describe('validator.js-asserts', () => { it('should export all asserts', ({ assert }) => { const assertNames = Object.keys(asserts); - assert.equal(assertNames.length, 41); + assert.equal(assertNames.length, 42); assert.deepEqual(assertNames, [ 'AbaRoutingNumber', 'BankIdentifierCode', @@ -49,6 +49,7 @@ describe('validator.js-asserts', () => { 'NullOrBoolean', 'NullOrDate', 'NullOrString', + 'OneOf', 'Phone', 'PlainObject', 'RfcNumber',