diff --git a/src/asserts/one-of-assert.js b/src/asserts/one-of-assert.js new file mode 100644 index 0000000..d1fcd37 --- /dev/null +++ b/src/asserts/one-of-assert.js @@ -0,0 +1,47 @@ +'use strict'; + +/** + * Module dependencies. + */ + +const { Constraint, Validator, Violation } = require('validator.js'); + +/** + * Export `OneOfAssert`. + */ + +module.exports = function oneOfAssert(...constraintSets) { + /** + * Class name. + */ + + this.__class__ = 'OneOf'; + + /** + * Validation algorithm. + */ + + this.validate = value => { + const validator = new Validator(); + const matches = []; + const violations = []; + + for (const constraintSet of constraintSets) { + const result = validator.validate(value, new Constraint(constraintSet, { deepRequired: true })); + + if (result === true) { + matches.push(constraintSet); + } else { + violations.push(result); + } + } + + if (matches.length === 1) { + return true; + } + + throw new Violation(this, value, matches.length > 1 ? { matches: matches.length } : 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..c3b99d0 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 at least one of the provided constraint sets. */ + oneOf(...constraintSets: Record[]): 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..204f041 --- /dev/null +++ b/test/asserts/one-of-assert.test.js @@ -0,0 +1,165 @@ +'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().validate({ bar: 'biz' }); + + assert.fail(); + } catch (e) { + assert.ok(e instanceof Violation); + assert.equal(e.show().assert, 'OneOf'); + assert.equal(e.show().violation.length, 0); + } + }); + + it('should throw an error if value does not match a single constraint set', ({ assert }) => { + try { + Assert.oneOf({ bar: [Assert.equalTo('foo')] }).validate({ bar: 'biz' }); + + assert.fail(); + } catch (e) { + assert.ok(e instanceof Violation); + assert.equal(e.show().assert, 'OneOf'); + assert.equal(e.show().violation.length, 1); + } + }); + + 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')] })] }, + { 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) { + assert.ok(e instanceof Violation); + assert.equal(e.show().assert, 'OneOf'); + assert.deepStrictEqual(e.show().violation, { matches: 2 }); + } + }); + + 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) { + assert.ok(e instanceof Violation); + assert.equal(e.show().assert, 'OneOf'); + assert.deepStrictEqual(e.show().violation, { matches: 2 }); + } + }); + + it('should pass if value matches a single constraint set', ({ assert }) => { + assert.doesNotThrow(() => { + Assert.oneOf({ bar: [Assert.equalTo('biz')] }).validate({ bar: 'biz' }); + }); + }); + + 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')] })] }, + { 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',