Skip to content

Commit bdc50c9

Browse files
committed
Handle path shorthand on array().unique()
Closes #1075.
1 parent 954db98 commit bdc50c9

File tree

4 files changed

+179
-22
lines changed

4 files changed

+179
-22
lines changed

API.md

+8-2
Original file line numberDiff line numberDiff line change
@@ -792,9 +792,11 @@ const schema = Joi.array().length(5);
792792
793793
Requires the array values to be unique.
794794
795-
You can provide a custom `comparator` function that takes 2 parameters to compare. This function should return whether the 2 parameters are equal or not, you are also **responsible** for this function not to fail, any `Error` would bubble out of Joi.
795+
You can provide a custom `comparator` that is either :
796+
- a function that takes 2 parameters to compare. This function should return whether the 2 parameters are equal or not, you are also **responsible** for this function not to fail, any `Error` would bubble out of Joi.
797+
- a string in dot notation representing the path of the element to do uniqueness check on. Any missing path will be considered undefined, and can as well only exist once.
796798
797-
Note: remember that if you provide a custom comparator, different types can be passed as parameter depending on the rules you set on items.
799+
Note: remember that if you provide a custom comparator function, different types can be passed as parameter depending on the rules you set on items.
798800
799801
Be aware that a deep equality is performed on elements of the array having a type of `object`, a performance penalty is to be expected for this kind of operation.
800802
@@ -806,6 +808,10 @@ const schema = Joi.array().unique();
806808
const schema = Joi.array().unique((a, b) => a.property === b.property);
807809
```
808810
811+
```js
812+
const schema = Joi.array().unique('customer.id');
813+
```
814+
809815
### `boolean`
810816
811817
Generates a schema object that matches a boolean data type. Can also be called via `bool()`. It will also validate the strings `"true"` and `"false"` unless you set the schema in `strict()` mode.

lib/array.js

+40-16
Original file line numberDiff line numberDiff line change
@@ -432,11 +432,20 @@ internals.Array = class extends Any {
432432

433433
unique(comparator) {
434434

435-
const isCustom = !!comparator;
436-
comparator = comparator || Hoek.deepEqual;
437-
Hoek.assert(typeof comparator === 'function', 'comparator must be a function');
435+
Hoek.assert(comparator === undefined ||
436+
typeof comparator === 'function' ||
437+
typeof comparator === 'string', 'comparator must be a function or a string');
438438

439-
return this._test('unique', undefined, function (value, state, options) {
439+
const settings = {};
440+
441+
if (typeof comparator === 'string') {
442+
settings.path = comparator;
443+
}
444+
else if (typeof comparator === 'function') {
445+
settings.comparator = comparator;
446+
}
447+
448+
return this._test('unique', settings, function (value, state, options) {
440449

441450
const found = {
442451
string: {},
@@ -448,10 +457,11 @@ internals.Array = class extends Any {
448457
custom: new Map()
449458
};
450459

460+
const compare = settings.comparator || Hoek.deepEqual;
461+
451462
for (let i = 0; i < value.length; ++i) {
452-
const item = value[i];
453-
const type = typeof item;
454-
const records = isCustom ? found.custom : found[type];
463+
const item = settings.path ? Hoek.reach(value[i], settings.path) : value[i];
464+
const records = settings.comparator ? found.custom : found[typeof item];
455465

456466
// All available types are supported, so it's not possible to reach 100% coverage without ignoring this line.
457467
// I still want to keep the test for future js versions with new types (eg. Symbol).
@@ -460,19 +470,26 @@ internals.Array = class extends Any {
460470
const entries = records.entries();
461471
let current;
462472
while (!(current = entries.next()).done) {
463-
if (comparator(current.value[0], item)) {
473+
if (compare(current.value[0], item)) {
464474
const localState = {
465475
key: state.key,
466476
path: (state.path ? state.path + '.' : '') + i,
467477
parent: state.parent,
468478
reference: state.reference
469479
};
470-
return this.createError('array.unique', {
480+
481+
const context = {
471482
pos: i,
472-
value: item,
483+
value: value[i],
473484
dupePos: current.value[1],
474-
dupeValue: current.value[0]
475-
}, localState, options);
485+
dupeValue: value[current.value[1]]
486+
};
487+
488+
if (settings.path) {
489+
context.path = settings.path;
490+
}
491+
492+
return this.createError('array.unique', context, localState, options);
476493
}
477494
}
478495

@@ -486,12 +503,19 @@ internals.Array = class extends Any {
486503
parent: state.parent,
487504
reference: state.reference
488505
};
489-
return this.createError('array.unique', {
506+
507+
const context = {
490508
pos: i,
491-
value: item,
509+
value: value[i],
492510
dupePos: records[item],
493-
dupeValue: item
494-
}, localState, options);
511+
dupeValue: value[records[item]]
512+
};
513+
514+
if (settings.path) {
515+
context.path = settings.path;
516+
}
517+
518+
return this.createError('array.unique', context, localState, options);
495519
}
496520

497521
records[item] = i;

test/array.js

+123-1
Original file line numberDiff line numberDiff line change
@@ -832,12 +832,134 @@ describe('array', () => {
832832
], done);
833833
});
834834

835+
it('validates using a path comparator', (done) => {
836+
837+
let schema = Joi.array().items(Joi.object({ id: Joi.number() })).unique('id');
838+
839+
Helper.validate(schema, [
840+
[[{ id: 1 }, { id: 2 }, { id: 3 }], true],
841+
[[{ id: 1 }, { id: 2 }, {}], true],
842+
[[{ id: 1 }, { id: 2 }, { id: 1 }], false, null, {
843+
message: '"value" position 2 contains a duplicate value',
844+
details: [{
845+
context: {
846+
dupePos: 0,
847+
dupeValue: { id: 1 },
848+
key: 'value',
849+
path: 'id',
850+
pos: 2,
851+
value: { id: 1 }
852+
},
853+
message: '"value" position 2 contains a duplicate value',
854+
path: '2',
855+
type: 'array.unique'
856+
}]
857+
}],
858+
[[{ id: 1 }, { id: 2 }, {}, { id: 3 }, {}], false, null, {
859+
message: '"value" position 4 contains a duplicate value',
860+
details: [{
861+
context: {
862+
dupePos: 2,
863+
dupeValue: {},
864+
key: 'value',
865+
path: 'id',
866+
pos: 4,
867+
value: {}
868+
},
869+
message: '"value" position 4 contains a duplicate value',
870+
path: '4',
871+
type: 'array.unique'
872+
}]
873+
}]
874+
]);
875+
876+
schema = Joi.array().items(Joi.object({ nested: { id: Joi.number() } })).unique('nested.id');
877+
878+
Helper.validate(schema, [
879+
[[{ nested: { id: 1 } }, { nested: { id: 2 } }, { nested: { id: 3 } }], true],
880+
[[{ nested: { id: 1 } }, { nested: { id: 2 } }, {}], true],
881+
[[{ nested: { id: 1 } }, { nested: { id: 2 } }, { nested: { id: 1 } }], false, null, {
882+
message: '"value" position 2 contains a duplicate value',
883+
details: [{
884+
context: {
885+
dupePos: 0,
886+
dupeValue: { nested: { id: 1 } },
887+
key: 'value',
888+
path: 'nested.id',
889+
pos: 2,
890+
value: { nested: { id: 1 } }
891+
},
892+
message: '"value" position 2 contains a duplicate value',
893+
path: '2',
894+
type: 'array.unique'
895+
}]
896+
}],
897+
[[{ nested: { id: 1 } }, { nested: { id: 2 } }, {}, { nested: { id: 3 } }, {}], false, null, {
898+
message: '"value" position 4 contains a duplicate value',
899+
details: [{
900+
context: {
901+
dupePos: 2,
902+
dupeValue: {},
903+
key: 'value',
904+
path: 'nested.id',
905+
pos: 4,
906+
value: {}
907+
},
908+
message: '"value" position 4 contains a duplicate value',
909+
path: '4',
910+
type: 'array.unique'
911+
}]
912+
}]
913+
]);
914+
915+
schema = Joi.array().items(Joi.object({ nested: { id: Joi.number() } })).unique('nested');
916+
917+
Helper.validate(schema, [
918+
[[{ nested: { id: 1 } }, { nested: { id: 2 } }, { nested: { id: 3 } }], true],
919+
[[{ nested: { id: 1 } }, { nested: { id: 2 } }, {}], true],
920+
[[{ nested: { id: 1 } }, { nested: { id: 2 } }, { nested: { id: 1 } }], false, null, {
921+
message: '"value" position 2 contains a duplicate value',
922+
details: [{
923+
context: {
924+
dupePos: 0,
925+
dupeValue: { nested: { id: 1 } },
926+
key: 'value',
927+
path: 'nested',
928+
pos: 2,
929+
value: { nested: { id: 1 } }
930+
},
931+
message: '"value" position 2 contains a duplicate value',
932+
path: '2',
933+
type: 'array.unique'
934+
}]
935+
}],
936+
[[{ nested: { id: 1 } }, { nested: { id: 2 } }, {}, { nested: { id: 3 } }, {}], false, null, {
937+
message: '"value" position 4 contains a duplicate value',
938+
details: [{
939+
context: {
940+
dupePos: 2,
941+
dupeValue: {},
942+
key: 'value',
943+
path: 'nested',
944+
pos: 4,
945+
value: {}
946+
},
947+
message: '"value" position 4 contains a duplicate value',
948+
path: '4',
949+
type: 'array.unique'
950+
}]
951+
}]
952+
]);
953+
954+
done();
955+
});
956+
835957
it('fails with invalid comparator', (done) => {
836958

837959
expect(() => {
838960

839961
Joi.array().unique({});
840-
}).to.throw(Error, 'comparator must be a function');
962+
}).to.throw(Error, 'comparator must be a function or a string');
841963

842964
done();
843965
});

test/helper.js

+8-3
Original file line numberDiff line numberDiff line change
@@ -57,11 +57,16 @@ exports.validateOptions = function (schema, config, options, callback) {
5757
expect(value).to.equal(expectedValueOrError);
5858
}
5959
else {
60-
if (expectedValueOrError instanceof RegExp) {
61-
expect(err.message).to.match(expectedValueOrError);
60+
const message = expectedValueOrError.message || expectedValueOrError;
61+
if (message instanceof RegExp) {
62+
expect(err.message).to.match(message);
6263
}
6364
else {
64-
expect(err.message).to.equal(expectedValueOrError);
65+
expect(err.message).to.equal(message);
66+
}
67+
68+
if (expectedValueOrError.details) {
69+
expect(err.details).to.equal(expectedValueOrError.details);
6570
}
6671
}
6772
}

0 commit comments

Comments
 (0)