Skip to content

Commit

Permalink
Handle path shorthand on array().unique()
Browse files Browse the repository at this point in the history
Closes #1075.
  • Loading branch information
Marsup committed Dec 23, 2016
1 parent 954db98 commit bdc50c9
Show file tree
Hide file tree
Showing 4 changed files with 179 additions and 22 deletions.
10 changes: 8 additions & 2 deletions API.md
Original file line number Diff line number Diff line change
Expand Up @@ -792,9 +792,11 @@ const schema = Joi.array().length(5);
Requires the array values to be unique.
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.
You can provide a custom `comparator` that is either :
- 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.
- 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.
Note: remember that if you provide a custom comparator, different types can be passed as parameter depending on the rules you set on items.
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.
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.
Expand All @@ -806,6 +808,10 @@ const schema = Joi.array().unique();
const schema = Joi.array().unique((a, b) => a.property === b.property);
```
```js
const schema = Joi.array().unique('customer.id');
```
### `boolean`
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.
Expand Down
56 changes: 40 additions & 16 deletions lib/array.js
Original file line number Diff line number Diff line change
Expand Up @@ -432,11 +432,20 @@ internals.Array = class extends Any {

unique(comparator) {

const isCustom = !!comparator;
comparator = comparator || Hoek.deepEqual;
Hoek.assert(typeof comparator === 'function', 'comparator must be a function');
Hoek.assert(comparator === undefined ||
typeof comparator === 'function' ||
typeof comparator === 'string', 'comparator must be a function or a string');

return this._test('unique', undefined, function (value, state, options) {
const settings = {};

if (typeof comparator === 'string') {
settings.path = comparator;
}
else if (typeof comparator === 'function') {
settings.comparator = comparator;
}

return this._test('unique', settings, function (value, state, options) {

const found = {
string: {},
Expand All @@ -448,10 +457,11 @@ internals.Array = class extends Any {
custom: new Map()
};

const compare = settings.comparator || Hoek.deepEqual;

for (let i = 0; i < value.length; ++i) {
const item = value[i];
const type = typeof item;
const records = isCustom ? found.custom : found[type];
const item = settings.path ? Hoek.reach(value[i], settings.path) : value[i];
const records = settings.comparator ? found.custom : found[typeof item];

// All available types are supported, so it's not possible to reach 100% coverage without ignoring this line.
// I still want to keep the test for future js versions with new types (eg. Symbol).
Expand All @@ -460,19 +470,26 @@ internals.Array = class extends Any {
const entries = records.entries();
let current;
while (!(current = entries.next()).done) {
if (comparator(current.value[0], item)) {
if (compare(current.value[0], item)) {
const localState = {
key: state.key,
path: (state.path ? state.path + '.' : '') + i,
parent: state.parent,
reference: state.reference
};
return this.createError('array.unique', {

const context = {
pos: i,
value: item,
value: value[i],
dupePos: current.value[1],
dupeValue: current.value[0]
}, localState, options);
dupeValue: value[current.value[1]]
};

if (settings.path) {
context.path = settings.path;
}

return this.createError('array.unique', context, localState, options);
}
}

Expand All @@ -486,12 +503,19 @@ internals.Array = class extends Any {
parent: state.parent,
reference: state.reference
};
return this.createError('array.unique', {

const context = {
pos: i,
value: item,
value: value[i],
dupePos: records[item],
dupeValue: item
}, localState, options);
dupeValue: value[records[item]]
};

if (settings.path) {
context.path = settings.path;
}

return this.createError('array.unique', context, localState, options);
}

records[item] = i;
Expand Down
124 changes: 123 additions & 1 deletion test/array.js
Original file line number Diff line number Diff line change
Expand Up @@ -832,12 +832,134 @@ describe('array', () => {
], done);
});

it('validates using a path comparator', (done) => {

let schema = Joi.array().items(Joi.object({ id: Joi.number() })).unique('id');

Helper.validate(schema, [
[[{ id: 1 }, { id: 2 }, { id: 3 }], true],
[[{ id: 1 }, { id: 2 }, {}], true],
[[{ id: 1 }, { id: 2 }, { id: 1 }], false, null, {
message: '"value" position 2 contains a duplicate value',
details: [{
context: {
dupePos: 0,
dupeValue: { id: 1 },
key: 'value',
path: 'id',
pos: 2,
value: { id: 1 }
},
message: '"value" position 2 contains a duplicate value',
path: '2',
type: 'array.unique'
}]
}],
[[{ id: 1 }, { id: 2 }, {}, { id: 3 }, {}], false, null, {
message: '"value" position 4 contains a duplicate value',
details: [{
context: {
dupePos: 2,
dupeValue: {},
key: 'value',
path: 'id',
pos: 4,
value: {}
},
message: '"value" position 4 contains a duplicate value',
path: '4',
type: 'array.unique'
}]
}]
]);

schema = Joi.array().items(Joi.object({ nested: { id: Joi.number() } })).unique('nested.id');

Helper.validate(schema, [
[[{ nested: { id: 1 } }, { nested: { id: 2 } }, { nested: { id: 3 } }], true],
[[{ nested: { id: 1 } }, { nested: { id: 2 } }, {}], true],
[[{ nested: { id: 1 } }, { nested: { id: 2 } }, { nested: { id: 1 } }], false, null, {
message: '"value" position 2 contains a duplicate value',
details: [{
context: {
dupePos: 0,
dupeValue: { nested: { id: 1 } },
key: 'value',
path: 'nested.id',
pos: 2,
value: { nested: { id: 1 } }
},
message: '"value" position 2 contains a duplicate value',
path: '2',
type: 'array.unique'
}]
}],
[[{ nested: { id: 1 } }, { nested: { id: 2 } }, {}, { nested: { id: 3 } }, {}], false, null, {
message: '"value" position 4 contains a duplicate value',
details: [{
context: {
dupePos: 2,
dupeValue: {},
key: 'value',
path: 'nested.id',
pos: 4,
value: {}
},
message: '"value" position 4 contains a duplicate value',
path: '4',
type: 'array.unique'
}]
}]
]);

schema = Joi.array().items(Joi.object({ nested: { id: Joi.number() } })).unique('nested');

Helper.validate(schema, [
[[{ nested: { id: 1 } }, { nested: { id: 2 } }, { nested: { id: 3 } }], true],
[[{ nested: { id: 1 } }, { nested: { id: 2 } }, {}], true],
[[{ nested: { id: 1 } }, { nested: { id: 2 } }, { nested: { id: 1 } }], false, null, {
message: '"value" position 2 contains a duplicate value',
details: [{
context: {
dupePos: 0,
dupeValue: { nested: { id: 1 } },
key: 'value',
path: 'nested',
pos: 2,
value: { nested: { id: 1 } }
},
message: '"value" position 2 contains a duplicate value',
path: '2',
type: 'array.unique'
}]
}],
[[{ nested: { id: 1 } }, { nested: { id: 2 } }, {}, { nested: { id: 3 } }, {}], false, null, {
message: '"value" position 4 contains a duplicate value',
details: [{
context: {
dupePos: 2,
dupeValue: {},
key: 'value',
path: 'nested',
pos: 4,
value: {}
},
message: '"value" position 4 contains a duplicate value',
path: '4',
type: 'array.unique'
}]
}]
]);

done();
});

it('fails with invalid comparator', (done) => {

expect(() => {

Joi.array().unique({});
}).to.throw(Error, 'comparator must be a function');
}).to.throw(Error, 'comparator must be a function or a string');

done();
});
Expand Down
11 changes: 8 additions & 3 deletions test/helper.js
Original file line number Diff line number Diff line change
Expand Up @@ -57,11 +57,16 @@ exports.validateOptions = function (schema, config, options, callback) {
expect(value).to.equal(expectedValueOrError);
}
else {
if (expectedValueOrError instanceof RegExp) {
expect(err.message).to.match(expectedValueOrError);
const message = expectedValueOrError.message || expectedValueOrError;
if (message instanceof RegExp) {
expect(err.message).to.match(message);
}
else {
expect(err.message).to.equal(expectedValueOrError);
expect(err.message).to.equal(message);
}

if (expectedValueOrError.details) {
expect(err.details).to.equal(expectedValueOrError.details);
}
}
}
Expand Down

0 comments on commit bdc50c9

Please sign in to comment.