Skip to content

Commit 4d6edd6

Browse files
Add AnyOf assert
1 parent a90a45e commit 4d6edd6

6 files changed

Lines changed: 294 additions & 1 deletion

File tree

README.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ The following set of extra asserts are provided by this package:
2929
| Assert | Peer Dependency |
3030
| :------------------------------------------------------------------------------ | :--------------------------------------------------- |
3131
| [AbaRoutingNumber](#abaroutingnumber) | [`abavalidator`][abavalidator-url] |
32+
| [AnyOf](#anyof) | |
3233
| [BankIdentifierCode](#bankidentifiercode-bic) (_BIC_) | |
3334
| [BigNumber](#bignumber) | [`bignumber.js`][bignumber-url] |
3435
| [BigNumberEqualTo](#bignumberequalto) | [`bignumber.js`][bignumber-url] |
@@ -74,6 +75,14 @@ The following set of extra asserts are provided by this package:
7475

7576
Tests if the value is a valid [ABA Routing Number](http://www.accuity.com/PageFiles/255/ROUTING_NUMBER_POLICY.pdf).
7677

78+
### AnyOf
79+
80+
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.
81+
82+
#### Arguments
83+
84+
- `...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).
85+
7786
### BankIdentifierCode (_BIC_)
7887

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

src/asserts/any-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 `AnyOfAssert`.
11+
*/
12+
13+
module.exports = function anyOfAssert(...constraintSets) {
14+
/**
15+
* Class name.
16+
*/
17+
18+
this.__class__ = 'AnyOf';
19+
20+
if (constraintSets.length < 2) {
21+
throw new Error('AnyOf 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 violations = [];
36+
37+
for (const constraintSet of constraintSets) {
38+
try {
39+
let result;
40+
41+
if (Array.isArray(constraintSet)) {
42+
for (const constraint of constraintSet) {
43+
constraint.validate(value);
44+
}
45+
46+
result = true;
47+
} else if (typeof constraintSet.validate === 'function') {
48+
result = constraintSet.validate(value);
49+
} else {
50+
const normalized = Object.fromEntries(
51+
Object.entries(constraintSet).map(([key, value]) => [key, Array.isArray(value) ? value : [value]])
52+
);
53+
54+
result = this.validator.validate(value, new Constraint(normalized, { deepRequired: true }));
55+
}
56+
57+
if (result === true) {
58+
return true;
59+
}
60+
61+
violations.push(result);
62+
} catch (violation) {
63+
violations.push(violation);
64+
}
65+
}
66+
67+
throw new Violation(this, value, violations);
68+
};
69+
70+
return this;
71+
};

src/index.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
*/
66

77
const AbaRoutingNumber = require('./asserts/aba-routing-number-assert.js');
8+
const AnyOf = require('./asserts/any-of-assert.js');
89
const BankIdentifierCode = require('./asserts/bank-identifier-code-assert.js');
910
const BigNumber = require('./asserts/big-number-assert.js');
1011
const BigNumberEqualTo = require('./asserts/big-number-equal-to-assert.js');
@@ -52,6 +53,7 @@ const Uuid = require('./asserts/uuid-assert.js');
5253

5354
module.exports = {
5455
AbaRoutingNumber,
56+
AnyOf,
5557
BankIdentifierCode,
5658
BigNumber,
5759
BigNumberEqualTo,

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+
export type ConstraintSet = AssertInstance | Array<AssertInstance> | { [key: string]: ConstraintSet };
25+
2026
/**
2127
* Core `validator.js-asserts` methods (lower-cased).
2228
*/
@@ -27,6 +33,9 @@ export interface ValidatorJSAsserts {
2733
*/
2834
abaRoutingNumber(): AssertInstance;
2935

36+
/** Value matches one or more of the provided constraint sets. */
37+
anyOf(...constraintSets: Array<ConstraintSet>): AssertInstance;
38+
3039
/** Valid BIC (Bank Identifier Code) used for international wire transfers. */
3140
bankIdentifierCode(): AssertInstance;
3241

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

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

test/index.test.js

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,9 +15,10 @@ 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',
21+
'AnyOf',
2122
'BankIdentifierCode',
2223
'BigNumber',
2324
'BigNumberEqualTo',

0 commit comments

Comments
 (0)