Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 47 additions & 0 deletions src/asserts/one-of-assert.js
Original file line number Diff line number Diff line change
@@ -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;
};
2 changes: 2 additions & 0 deletions src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down Expand Up @@ -83,6 +84,7 @@ module.exports = {
NullOrBoolean,
NullOrDate,
NullOrString,
OneOf,
Phone,
PlainObject,
RfcNumber,
Expand Down
3 changes: 3 additions & 0 deletions src/types/index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, AssertInstance[]>[]): AssertInstance;

/** Valid phone number (optionally by country code). @requires google-libphonenumber */
phone(options?: { countryCode?: string }): AssertInstance;

Expand Down
165 changes: 165 additions & 0 deletions test/asserts/one-of-assert.test.js
Original file line number Diff line number Diff line change
@@ -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' } });
});
});
});
3 changes: 2 additions & 1 deletion test/index.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -49,6 +49,7 @@ describe('validator.js-asserts', () => {
'NullOrBoolean',
'NullOrDate',
'NullOrString',
'OneOf',
'Phone',
'PlainObject',
'RfcNumber',
Expand Down