Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add symbol() type #1562

Merged
merged 2 commits into from Aug 13, 2018
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
10 changes: 9 additions & 1 deletion lib/index.js
Expand Up @@ -22,7 +22,8 @@ const internals = {
func: require('./types/func'),
number: require('./types/number'),
object: require('./types/object'),
string: require('./types/string')
string: require('./types/string'),
symbol: require('./types/symbol')
};

internals.callWithDefaults = function (schema, args) {
Expand Down Expand Up @@ -112,6 +113,13 @@ internals.root = function () {
return internals.callWithDefaults.call(this, internals.string, args);
};

root.symbol = function (...args) {

Hoek.assert(args.length === 0, 'Joi.symbol() does not allow arguments.');

return internals.callWithDefaults.call(this, internals.symbol, args);
};

root.ref = function (...args) {

return Ref.create(...args);
Expand Down
4 changes: 4 additions & 0 deletions lib/language.js
Expand Up @@ -157,5 +157,9 @@ exports.errors = {
ref: 'references "{{ref}}" which is not a number',
ip: 'must be a valid ip address with a {{cidr}} CIDR',
ipVersion: 'must be a valid ip address of one of the following versions {{version}} with a {{cidr}} CIDR'
},
symbol: {
base: 'must be a symbol',
map: 'must be one of {{map}}'
}
};
97 changes: 97 additions & 0 deletions lib/types/symbol/index.js
@@ -0,0 +1,97 @@
'use strict';

// Load modules

const Any = require('../any');
const Hoek = require('hoek');


// Declare internals

const internals = {};


internals.Map = class extends Map {

slice() {

return new internals.Map(this);
}

toString() {

const entries = [...this].map(([key, symbol]) => {

key = typeof key === 'symbol' ? key.toString() : JSON.stringify(key);
return `${key} => ${symbol.toString()}`;
});

return `Map { ${entries.join(', ')} }`;
}
};


internals.Symbol = class extends Any {

constructor() {

super();
this._type = 'symbol';
this._inner.map = new internals.Map();
}

_base(value, state, options) {

if (options.convert) {
const lookup = this._inner.map.get(value);
if (lookup) {
value = lookup;
}

if (this._flags.allowOnly) {
return {
value,
errors: (typeof value === 'symbol') ? null : this.createError('symbol.map', { map: this._inner.map }, state, options)
};
}
}

return {
value,
errors: (typeof value === 'symbol') ? null : this.createError('symbol.base', null, state, options)
};
}

map(iterable) {

if (iterable && !iterable[Symbol.iterator] && typeof iterable === 'object') {
iterable = Object.entries(iterable);
}

Hoek.assert(iterable && iterable[Symbol.iterator], 'Iterable must be an iterable or object');
const obj = this.clone();

const symbols = [];
for (const entry of iterable) {
Hoek.assert(entry && entry[Symbol.iterator], 'Entry must be an iterable');
const [key, value] = entry;

Hoek.assert(typeof key !== 'object' && typeof key !== 'function', 'Key must be a simple type');
Hoek.assert(typeof value === 'symbol', 'Value must be a Symbol');
obj._inner.map.set(key, value);
symbols.push(value);
}

return obj.valid(...symbols);
}

describe() {

const description = Any.prototype.describe.call(this);
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That PR made me realize we could do this in ES6 now, see fd28a30.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, I thought as well, but chose to copy the existing style.

description.map = new Map(this._inner.map);
return description;
}
};


module.exports = new internals.Symbol();
224 changes: 224 additions & 0 deletions test/types/symbol.js
@@ -0,0 +1,224 @@
'use strict';

// Load modules

const Lab = require('lab');
const Joi = require('../..');
const Helper = require('../helper');


// Declare internals

const internals = {};


// Test shortcuts

const { describe, it, expect } = exports.lab = Lab.script();


describe('symbol', () => {

it('cannot be called on its own', () => {

const symbol = Joi.symbol;
expect(() => symbol()).to.throw('Must be invoked on a Joi instance.');
});

it('should throw an exception if arguments were passed.', () => {

expect(
() => Joi.symbol('invalid argument.')
).to.throw('Joi.symbol() does not allow arguments.');
});

describe('validate()', () => {

it('handles plain symbols', () => {

const symbols = [Symbol(1), Symbol(2)];
const rule = Joi.symbol();
Helper.validate(rule, [
[symbols[0], true, null, symbols[0]],
[symbols[1], true, null, symbols[1]],
[1, false, null, {
message: '"value" must be a symbol',
details: [{
message: '"value" must be a symbol',
path: [],
type: 'symbol.base',
context: { label: 'value', key: undefined }
}]
}]
]);
});

it('handles simple lookup', () => {

const symbols = [Symbol(1), Symbol(2)];
const otherSymbol = Symbol(1);
const rule = Joi.symbol().valid(symbols);
Helper.validate(rule, [
[symbols[0], true, null, symbols[0]],
[symbols[1], true, null, symbols[1]],
[otherSymbol, false, null, {
message: '"value" must be one of [Symbol(1), Symbol(2)]',
details: [{
message: '"value" must be one of [Symbol(1), Symbol(2)]',
path: [],
type: 'any.allowOnly',
context: { value: otherSymbol, label: 'value', valids: symbols, key: undefined }
}]
}]
]);
});

describe('map', () => {

it('converts keys to correct symbol', () => {

const symbols = [Symbol(1), Symbol(2), Symbol(3)];
const otherSymbol = Symbol(1);
const map = new Map([[1, symbols[0]], ['two', symbols[1]], [symbols[0], symbols[2]]]);
const rule = Joi.symbol().map(map);
Helper.validate(rule, [
[1, true, null, symbols[0]],
[symbols[0], true, null, symbols[0]],
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why wouldn't it become symbols[2] ? Or else why accept symbols as key of a map ?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmm, yeah I didn't write that test as I intended to.

However, it might makes sense to work as it does, since the implementation adds all map values to the valid() values, which is resolved before the mapping.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Then I would deny symbols as keys, that doesn't make sense to me.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Well, normally the mapping would work – it just doesn't in the case, since the symbol is also used as a value.

Anyway, I don't have a problem with removing it. We can always re-add later if it somehow makes sense.

['1', false, null, {
message: '"value" must be one of Map { 1 => Symbol(1), "two" => Symbol(2), Symbol(1) => Symbol(3) }',
details: [{
message: '"value" must be one of Map { 1 => Symbol(1), "two" => Symbol(2), Symbol(1) => Symbol(3) }',
path: [],
type: 'symbol.map',
context: { label: 'value', key: undefined, map }
}]
}],
['two', true, null, symbols[1]],
[otherSymbol, false, null, {
message: '"value" must be one of [Symbol(1), Symbol(2), Symbol(3)]',
details: [{
message: '"value" must be one of [Symbol(1), Symbol(2), Symbol(3)]',
path: [],
type: 'any.allowOnly',
context: { value: otherSymbol, label: 'value', valids: symbols, key: undefined }
}]
}]
]);
});

it('converts keys from object', () => {

const symbols = [Symbol('one'), Symbol('two')];
const otherSymbol = Symbol('one');
const rule = Joi.symbol().map({ one: symbols[0], two: symbols[1] });
Helper.validate(rule, [
[symbols[0], true, null, symbols[0]],
['one', true, null, symbols[0]],
['two', true, null, symbols[1]],
[otherSymbol, false, null, {
message: '"value" must be one of [Symbol(one), Symbol(two)]',
details: [{
message: '"value" must be one of [Symbol(one), Symbol(two)]',
path: [],
type: 'any.allowOnly',
context: { value: otherSymbol, label: 'value', valids: symbols, key: undefined }
}]
}],
['toString', false, null, {
message: '"value" must be one of Map { "one" => Symbol(one), "two" => Symbol(two) }',
details: [{
message: '"value" must be one of Map { "one" => Symbol(one), "two" => Symbol(two) }',
path: [],
type: 'symbol.map',
context: { label: 'value', key: undefined, map: new Map([['one', symbols[0]], ['two', symbols[1]]]) }
}]
}]
]);
});

it('appends to existing map', () => {

const symbols = [Symbol(1), Symbol(2)];
const otherSymbol = Symbol(1);
const rule = Joi.symbol().map([[1, symbols[0]]]).map([[2, symbols[1]]]);
Helper.validate(rule, [
[1, true, null, symbols[0]],
[2, true, null, symbols[1]],
[otherSymbol, false, null, {
message: '"value" must be one of [Symbol(1), Symbol(2)]',
details: [{
message: '"value" must be one of [Symbol(1), Symbol(2)]',
path: [],
type: 'any.allowOnly',
context: { value: otherSymbol, label: 'value', valids: symbols, key: undefined }
}]
}]
]);
});

it('throws on bad input', () => {

expect(
() => Joi.symbol().map()
).to.throw('Iterable must be an iterable or object');

expect(
() => Joi.symbol().map(Symbol())
).to.throw('Iterable must be an iterable or object');

expect(
() => Joi.symbol().map([undefined])
).to.throw('Entry must be an iterable');

expect(
() => Joi.symbol().map([123])
).to.throw('Entry must be an iterable');

expect(
() => Joi.symbol().map([[123, 456]])
).to.throw('Value must be a Symbol');

expect(
() => Joi.symbol().map([[{}, Symbol()]])
).to.throw('Key must be a simple type');
});
});

it('handles plain symbols when convert is disabled', async () => {

const symbols = [Symbol(1), Symbol(2)];
const schema = Joi.symbol().map([[1, symbols[0]], ['two', symbols[1]]]).options({ convert: false });
const result = await schema.validate(symbols[1]);
expect(result).to.equal(symbols[1]);
});

it('errors on mapped input and convert is disabled', async () => {

const symbols = [Symbol(1), Symbol(2)];
const schema = Joi.symbol().map([[1, symbols[0]], ['two', symbols[1]]]).options({ convert: false });
const err = await expect(schema.validate(1)).to.reject();
expect(err).to.be.an.error('"value" must be a symbol');
expect(err.details).to.equal([{
message: '"value" must be a symbol',
path: [],
type: 'symbol.base',
context: { label: 'value', key: undefined }
}]);
});

it('should describe value map', () => {

const symbols = [Symbol(1), Symbol(2)];
const map = new Map([[1, symbols[0]], ['two', symbols[1]]]);
const schema = Joi.symbol().map(map).describe();
expect(schema).to.equal({
type: 'symbol',
flags: {
allowOnly: true
},
map,
valids: symbols
});
});
});
});