diff --git a/README.md b/README.md index 9af350d..d0cb6cd 100644 --- a/README.md +++ b/README.md @@ -29,6 +29,7 @@ The following set of extra asserts are provided by this package: | Assert | Peer Dependency | | :------------------------------------------------------------------------------ | :--------------------------------------------------- | | [AbaRoutingNumber](#abaroutingnumber) | [`abavalidator`][abavalidator-url] | +| [AnyOf](#anyof) | | | [BankIdentifierCode](#bankidentifiercode-bic) (_BIC_) | | | [BigNumber](#bignumber) | [`bignumber.js`][bignumber-url] | | [BigNumberEqualTo](#bignumberequalto) | [`bignumber.js`][bignumber-url] | @@ -74,6 +75,14 @@ The following set of extra asserts are provided by this package: Tests if the value is a valid [ABA Routing Number](http://www.accuity.com/PageFiles/255/ROUTING_NUMBER_POLICY.pdf). +### AnyOf + +Tests if the value matches at least one of the provided constraint sets. Throws a violation if the value matches none of the constraint sets. + +#### 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. + ### BankIdentifierCode (_BIC_) Tests if the value is a valid Bank Identifier Code (_BIC_) as defined in the [ISO-9362](http://www.iso.org/iso/home/store/catalogue_tc/catalogue_detail.htm?csnumber=60390) standard. diff --git a/src/asserts/any-of-assert.js b/src/asserts/any-of-assert.js new file mode 100644 index 0000000..21ac2a0 --- /dev/null +++ b/src/asserts/any-of-assert.js @@ -0,0 +1,45 @@ +'use strict'; + +/** + * Module dependencies. + */ + +const { Constraint, Violation } = require('validator.js'); + +/** + * Export `AnyOfAssert`. + */ + +module.exports = function anyOfAssert(...constraintSets) { + /** + * Class name. + */ + + this.__class__ = 'AnyOf'; + + if (constraintSets.length < 2) { + throw new Error('AnyOf assert requires at least two constraint sets'); + } + + /** + * Validation algorithm. + */ + + this.validate = value => { + const violations = []; + + for (const constraintSet of constraintSets) { + const result = new Constraint(constraintSet, { deepRequired: true }).check(value); + + if (result === true) { + return true; + } + + violations.push(result); + } + + throw new Violation(this, value, violations); + }; + + return this; +}; diff --git a/src/index.js b/src/index.js index 4cd8b16..8b5a21e 100644 --- a/src/index.js +++ b/src/index.js @@ -5,6 +5,7 @@ */ const AbaRoutingNumber = require('./asserts/aba-routing-number-assert.js'); +const AnyOf = require('./asserts/any-of-assert.js'); const BankIdentifierCode = require('./asserts/bank-identifier-code-assert.js'); const BigNumber = require('./asserts/big-number-assert.js'); const BigNumberEqualTo = require('./asserts/big-number-equal-to-assert.js'); @@ -52,6 +53,7 @@ const Uuid = require('./asserts/uuid-assert.js'); module.exports = { AbaRoutingNumber, + AnyOf, BankIdentifierCode, BigNumber, BigNumberEqualTo, diff --git a/src/types/index.d.ts b/src/types/index.d.ts index 68d90e1..2756b1e 100644 --- a/src/types/index.d.ts +++ b/src/types/index.d.ts @@ -27,6 +27,9 @@ export interface ValidatorJSAsserts { */ abaRoutingNumber(): AssertInstance; + /** Value matches one or more of the provided constraint sets. */ + anyOf(...constraintSets: Array<{ [key: string]: AssertInstance | Array }>): AssertInstance; + /** Valid BIC (Bank Identifier Code) used for international wire transfers. */ bankIdentifierCode(): AssertInstance; diff --git a/test/asserts/any-of-assert.test.js b/test/asserts/any-of-assert.test.js new file mode 100644 index 0000000..0d3a962 --- /dev/null +++ b/test/asserts/any-of-assert.test.js @@ -0,0 +1,155 @@ +'use strict'; + +/** + * Module dependencies. + */ + +const { Assert: BaseAssert, Violation } = require('validator.js'); +const { describe, it } = require('node:test'); +const AnyOfAssert = require('../../src/asserts/any-of-assert.js'); + +/** + * Extend `Assert` with `AnyOfAssert`. + */ + +const Assert = BaseAssert.extend({ + AnyOf: AnyOfAssert +}); + +/** + * Test `AnyOfAssert`. + */ + +describe('AnyOfAssert', () => { + it('should throw an error if no constraint sets are provided', ({ assert }) => { + try { + Assert.anyOf(); + + assert.fail(); + } catch (e) { + assert.equal(e.message, 'AnyOf assert requires at least two constraint sets'); + } + }); + + it('should throw an error if only one constraint set is provided', ({ assert }) => { + try { + Assert.anyOf({ bar: [Assert.equalTo('foo')] }); + + assert.fail(); + } catch (e) { + assert.equal(e.message, 'AnyOf assert requires at least two constraint sets'); + } + }); + + it('should throw an error if value does not match any constraint set', ({ assert }) => { + try { + Assert.anyOf({ 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, 'AnyOf'); + } + }); + + it('should include all violations in the error when no constraint set matches', ({ assert }) => { + try { + Assert.anyOf({ 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.anyOf( + { 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, 'AnyOf'); + } + }); + + it('should throw an error if a constraint set with an extra assert does not match', ({ assert }) => { + try { + Assert.anyOf( + { + bar: [Assert.equalTo('biz')], + baz: [Assert.anyOf({ 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, 'AnyOf'); + } + }); + + it('should pass if a constraint set contains either a required field or an optional field', ({ assert }) => { + assert.doesNotThrow(() => { + Assert.anyOf({ bar: [Assert.required(), Assert.notBlank()] }, { baz: Assert.notBlank() }).validate({}); + }); + }); + + it('should pass if value matches more than one constraint set', ({ assert }) => { + assert.doesNotThrow(() => { + Assert.anyOf({ bar: [Assert.equalTo('biz')] }, { bar: [Assert.equalTo('biz')] }).validate({ bar: 'biz' }); + }); + }); + + it('should pass if value matches more than one constraint set with different constraints', ({ assert }) => { + assert.doesNotThrow(() => { + Assert.anyOf({ bar: [Assert.notBlank()] }, { bar: [Assert.equalTo('biz')] }).validate({ bar: 'biz' }); + }); + }); + + it('should pass if value matches the first constraint set', ({ assert }) => { + assert.doesNotThrow(() => { + Assert.anyOf({ 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.anyOf({ bar: [Assert.equalTo('biz')] }, { bar: [Assert.equalTo('baz')] }).validate({ bar: 'baz' }); + }); + }); + + it('should support more than two constraint sets', ({ assert }) => { + assert.doesNotThrow(() => { + Assert.anyOf( + { 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.anyOf( + { + bar: [Assert.equalTo('biz')], + baz: [Assert.anyOf({ 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..2adc8e4 100644 --- a/test/index.test.js +++ b/test/index.test.js @@ -15,9 +15,10 @@ 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', + 'AnyOf', 'BankIdentifierCode', 'BigNumber', 'BigNumberEqualTo',