Skip to content

Commit 00c1e03

Browse files
Add OneOf assert
1 parent 26ecd42 commit 00c1e03

6 files changed

Lines changed: 307 additions & 1 deletion

File tree

README.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,7 @@ The following set of extra asserts are provided by this package:
6060
| [NullOrBoolean](#nullorboolean) | |
6161
| [NullOrDate](#nullordate) | |
6262
| [NullOrString](#nullorstring) | |
63+
| [OneOf](#oneof) | |
6364
| [Phone](#phone) | [`google-libphonenumber`][google-libphonenumber-url] |
6465
| [PlainObject](#plainobject) | |
6566
| [RfcNumber](#rfcnumber) | [`validate-rfc`][validate-rfc-url] |
@@ -276,6 +277,14 @@ Tests if the value is a `null` or `string`, optionally within some boundaries.
276277

277278
- `boundaries` (optional) - `max` and/or `min` boundaries to test the string for.
278279

280+
### OneOf
281+
282+
Tests if the value matches exactly one of the provided constraint sets. Throws a violation if the value matches none or more than one constraint set.
283+
284+
#### Arguments
285+
286+
- `...constraintSets` (required) - two or more constraint sets to test the value against. Each constraint set can be a plain object mapping field names to arrays of asserts, an assert instance, or an array of assert instances (all of which must pass).
287+
279288
### Phone
280289

281290
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.

src/asserts/one-of-assert.js

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
'use strict';
2+
3+
/**
4+
* Module dependencies.
5+
*/
6+
7+
const { Constraint, Validator, Violation } = require('validator.js');
8+
9+
/**
10+
* Export `OneOfAssert`.
11+
*/
12+
13+
module.exports = function oneOfAssert(...constraintSets) {
14+
/**
15+
* Class name.
16+
*/
17+
18+
this.__class__ = 'OneOf';
19+
20+
if (constraintSets.length < 2) {
21+
throw new Error('OneOf constraint requires at least two constraint sets');
22+
}
23+
24+
/**
25+
* Validator instance.
26+
*/
27+
28+
this.validator = new Validator();
29+
30+
/**
31+
* Validation algorithm.
32+
*/
33+
34+
this.validate = value => {
35+
const matches = [];
36+
const violations = [];
37+
38+
for (const constraintSet of constraintSets) {
39+
try {
40+
if (typeof constraintSet.validate === 'function') {
41+
constraintSet.validate(value);
42+
matches.push(constraintSet);
43+
} else if (Array.isArray(constraintSet)) {
44+
for (const assert of constraintSet) {
45+
assert.validate(value);
46+
}
47+
48+
matches.push(constraintSet);
49+
} else {
50+
const result = this.validator.validate(value, new Constraint(constraintSet, { deepRequired: true }));
51+
52+
if (result === true) {
53+
matches.push(constraintSet);
54+
} else {
55+
violations.push(result);
56+
}
57+
}
58+
} catch (violation) {
59+
violations.push(violation);
60+
}
61+
}
62+
63+
if (matches.length === 1) {
64+
return true;
65+
}
66+
67+
throw new Violation(this, value, matches.length > 1 ? { matches: matches.length } : violations);
68+
};
69+
70+
return this;
71+
};

src/index.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ const NullOr = require('./asserts/null-or-assert.js');
3636
const NullOrBoolean = require('./asserts/null-or-boolean-assert.js');
3737
const NullOrDate = require('./asserts/null-or-date-assert.js');
3838
const NullOrString = require('./asserts/null-or-string-assert.js');
39+
const OneOf = require('./asserts/one-of-assert.js');
3940
const Phone = require('./asserts/phone-assert.js');
4041
const PlainObject = require('./asserts/plain-object-assert.js');
4142
const RfcNumber = require('./asserts/rfc-number-assert.js');
@@ -83,6 +84,7 @@ module.exports = {
8384
NullOrBoolean,
8485
NullOrDate,
8586
NullOrString,
87+
OneOf,
8688
Phone,
8789
PlainObject,
8890
RfcNumber,

src/types/index.d.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,12 @@ interface AssertInstance {
1717
hasGroups(): boolean;
1818
}
1919

20+
/**
21+
* Constraint set.
22+
*/
23+
24+
type ConstraintSet = AssertInstance | Array<AssertInstance> | { [key: string]: Array<AssertInstance> };
25+
2026
/**
2127
* Core `validator.js-asserts` methods (lower-cased).
2228
*/
@@ -170,6 +176,9 @@ export interface ValidatorJSAsserts {
170176
/** Value is null or a string (length within `[min, max]`). */
171177
nullOrString(boundaries?: { min?: number; max?: number }): AssertInstance;
172178

179+
/** Value matches exactly one of the provided constraint sets. */
180+
oneOf(...constraintSets: Array<ConstraintSet>): AssertInstance;
181+
173182
/** Valid phone number (optionally by country code). @requires google-libphonenumber */
174183
phone(options?: { countryCode?: string }): AssertInstance;
175184

test/asserts/one-of-assert.test.js

Lines changed: 214 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,214 @@
1+
'use strict';
2+
3+
/**
4+
* Module dependencies.
5+
*/
6+
7+
const { Assert: BaseAssert, Violation } = require('validator.js');
8+
const { describe, it } = require('node:test');
9+
const OneOfAssert = require('../../src/asserts/one-of-assert.js');
10+
11+
/**
12+
* Extend `Assert` with `OneOfAssert`.
13+
*/
14+
15+
const Assert = BaseAssert.extend({
16+
OneOf: OneOfAssert
17+
});
18+
19+
/**
20+
* Test `OneOfAssert`.
21+
*/
22+
23+
describe('OneOfAssert', () => {
24+
it('should throw an error if no constraint sets are provided', ({ assert }) => {
25+
try {
26+
Assert.oneOf();
27+
28+
assert.fail();
29+
} catch (e) {
30+
assert.equal(e.message, 'OneOf constraint requires at least two constraint sets');
31+
}
32+
});
33+
34+
it('should throw an error if only one constraint set is provided', ({ assert }) => {
35+
try {
36+
Assert.oneOf({ bar: [Assert.equalTo('foo')] });
37+
38+
assert.fail();
39+
} catch (e) {
40+
assert.equal(e.message, 'OneOf constraint requires at least two constraint sets');
41+
}
42+
});
43+
44+
it('should throw an error if value does not match any constraint set', ({ assert }) => {
45+
try {
46+
Assert.oneOf({ bar: [Assert.equalTo('foo')] }, { bar: [Assert.equalTo('baz')] }).validate({ bar: 'biz' });
47+
48+
assert.fail();
49+
} catch (e) {
50+
assert.ok(e instanceof Violation);
51+
assert.equal(e.show().assert, 'OneOf');
52+
}
53+
});
54+
55+
it('should include all violations in the error when no constraint set matches', ({ assert }) => {
56+
try {
57+
Assert.oneOf({ bar: [Assert.equalTo('biz')] }, { bar: [Assert.equalTo('baz')] }).validate({ bar: 'qux' });
58+
59+
assert.fail();
60+
} catch (e) {
61+
const { violation } = e.show();
62+
63+
assert.equal(violation.length, 2);
64+
assert.ok(violation[0].bar[0] instanceof Violation);
65+
assert.equal(violation[0].bar[0].show().assert, 'EqualTo');
66+
assert.equal(violation[0].bar[0].show().violation.value, 'biz');
67+
assert.ok(violation[1].bar[0] instanceof Violation);
68+
assert.equal(violation[1].bar[0].show().assert, 'EqualTo');
69+
assert.equal(violation[1].bar[0].show().violation.value, 'baz');
70+
}
71+
});
72+
73+
it('should validate required fields using `deepRequired`', ({ assert }) => {
74+
try {
75+
Assert.oneOf(
76+
{ bar: [Assert.required(), Assert.notBlank()] },
77+
{ baz: [Assert.required(), Assert.notBlank()] }
78+
).validate({});
79+
80+
assert.fail();
81+
} catch (e) {
82+
assert.ok(e instanceof Violation);
83+
assert.equal(e.show().assert, 'OneOf');
84+
}
85+
});
86+
87+
it('should throw an error if a constraint set with an extra assert does not match', ({ assert }) => {
88+
try {
89+
Assert.oneOf(
90+
{
91+
bar: [Assert.equalTo('biz')],
92+
baz: [Assert.oneOf({ qux: [Assert.equalTo('corge')] }, { qux: [Assert.equalTo('grault')] })]
93+
},
94+
{ bar: [Assert.equalTo('baz')] }
95+
).validate({ bar: 'biz', baz: { qux: 'wrong' } });
96+
97+
assert.fail();
98+
} catch (e) {
99+
assert.ok(e instanceof Violation);
100+
assert.equal(e.show().assert, 'OneOf');
101+
}
102+
});
103+
104+
it('should throw an error if value does not match any assert instance constraint set', ({ assert }) => {
105+
try {
106+
Assert.oneOf(Assert.ofLength({ max: 1 }), Assert.ofLength({ min: 5 })).validate('foo');
107+
108+
assert.fail();
109+
} catch (e) {
110+
assert.ok(e instanceof Violation);
111+
assert.equal(e.show().assert, 'OneOf');
112+
}
113+
});
114+
115+
it('should throw an error if value matches more than one assert instance constraint set', ({ assert }) => {
116+
try {
117+
Assert.oneOf(Assert.ofLength({ max: 5 }), Assert.ofLength({ min: 2 })).validate('foo');
118+
119+
assert.fail();
120+
} catch (e) {
121+
assert.ok(e instanceof Violation);
122+
assert.equal(e.show().assert, 'OneOf');
123+
assert.deepStrictEqual(e.show().violation, { matches: 2 });
124+
}
125+
});
126+
127+
it('should throw an error if value does not match any assert array constraint set', ({ assert }) => {
128+
try {
129+
Assert.oneOf([Assert.equalTo('foo')], [Assert.equalTo('bar')]).validate('baz');
130+
131+
assert.fail();
132+
} catch (e) {
133+
assert.ok(e instanceof Violation);
134+
assert.equal(e.show().assert, 'OneOf');
135+
}
136+
});
137+
138+
it('should throw an error if value matches more than one assert array constraint set', ({ assert }) => {
139+
try {
140+
Assert.oneOf(
141+
[Assert.notBlank(), Assert.ofLength({ min: 1 })],
142+
[Assert.notBlank(), Assert.ofLength({ max: 5 })]
143+
).validate('foo');
144+
145+
assert.fail();
146+
} catch (e) {
147+
assert.ok(e instanceof Violation);
148+
assert.equal(e.show().assert, 'OneOf');
149+
assert.deepStrictEqual(e.show().violation, { matches: 2 });
150+
}
151+
});
152+
153+
it('should throw an error if value matches more than one constraint set', ({ assert }) => {
154+
try {
155+
Assert.oneOf({ bar: [Assert.equalTo('biz')] }, { bar: [Assert.equalTo('biz')] }).validate({ bar: 'biz' });
156+
157+
assert.fail();
158+
} catch (e) {
159+
assert.ok(e instanceof Violation);
160+
assert.equal(e.show().assert, 'OneOf');
161+
assert.deepStrictEqual(e.show().violation, { matches: 2 });
162+
}
163+
});
164+
165+
it('should throw an error if value matches more than one constraint set with overlapping schemas', ({ assert }) => {
166+
try {
167+
Assert.oneOf({ bar: [Assert.notBlank()] }, { bar: [Assert.equalTo('biz')] }).validate({ bar: 'biz' });
168+
169+
assert.fail();
170+
} catch (e) {
171+
assert.ok(e instanceof Violation);
172+
assert.equal(e.show().assert, 'OneOf');
173+
assert.deepStrictEqual(e.show().violation, { matches: 2 });
174+
}
175+
});
176+
177+
it('should pass if value matches the first constraint set', () => {
178+
Assert.oneOf({ bar: [Assert.equalTo('biz')] }, { bar: [Assert.equalTo('baz')] }).validate({ bar: 'biz' });
179+
});
180+
181+
it('should pass if value matches the second constraint set', () => {
182+
Assert.oneOf({ bar: [Assert.equalTo('biz')] }, { bar: [Assert.equalTo('baz')] }).validate({ bar: 'baz' });
183+
});
184+
185+
it('should support more than two constraint sets', () => {
186+
Assert.oneOf(
187+
{ bar: [Assert.equalTo('biz')] },
188+
{ bar: [Assert.equalTo('baz')] },
189+
{ bar: [Assert.equalTo('qux')] }
190+
).validate({ bar: 'qux' });
191+
});
192+
193+
it('should pass if a constraint set contains an extra assert', () => {
194+
Assert.oneOf(
195+
{
196+
bar: [Assert.equalTo('biz')],
197+
baz: [Assert.oneOf({ qux: [Assert.equalTo('corge')] }, { qux: [Assert.equalTo('grault')] })]
198+
},
199+
{ bar: [Assert.equalTo('baz')] }
200+
).validate({ bar: 'biz', baz: { qux: 'corge' } });
201+
});
202+
203+
it('should pass if value matches exactly one assert instance constraint set', () => {
204+
Assert.oneOf(Assert.ofLength({ max: 2 }), Assert.ofLength({ min: 3 })).validate('foo');
205+
});
206+
207+
it('should pass if value matches the first assert array constraint set', () => {
208+
Assert.oneOf([Assert.equalTo('foo')], [Assert.equalTo('bar')]).validate('foo');
209+
});
210+
211+
it('should pass if value matches the second assert array constraint set', () => {
212+
Assert.oneOf([Assert.equalTo('foo')], [Assert.equalTo('bar')]).validate('bar');
213+
});
214+
});

test/index.test.js

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ describe('validator.js-asserts', () => {
1515
it('should export all asserts', ({ assert }) => {
1616
const assertNames = Object.keys(asserts);
1717

18-
assert.equal(assertNames.length, 41);
18+
assert.equal(assertNames.length, 42);
1919
assert.deepEqual(assertNames, [
2020
'AbaRoutingNumber',
2121
'BankIdentifierCode',
@@ -49,6 +49,7 @@ describe('validator.js-asserts', () => {
4949
'NullOrBoolean',
5050
'NullOrDate',
5151
'NullOrString',
52+
'OneOf',
5253
'Phone',
5354
'PlainObject',
5455
'RfcNumber',

0 commit comments

Comments
 (0)