diff --git a/README.md b/README.md index dc00957..f4bfc0f 100644 --- a/README.md +++ b/README.md @@ -27,7 +27,11 @@ When rendering a component that uses `@argument`, the initial value of the prope In addition, any unexpected arguments to a component will also cause an error. -### Defining Types +## Defining Types + +The `@argument` decorator takes a definition for what kind of value the property should be set to. Usually, this will represent the type of the value. + +### Primitive Types For primitives types, the name should be provided as a string (as with `string` in the example above). The available types match those of Typescript, including: @@ -89,6 +93,28 @@ In addition, this library includes several predefined types for convenience: These types can also be imported from `@ember-decorators/argument/types` +### Class Instances + +The `@argument` decorator can also take a class constructor to validate that the property value is an instance of that class + +```js +import Component from '@ember/component'; +import { argument } from '@ember-decorators/argument'; + +class Task { + constructor() { + this.complete = false; + } +} + +export default class TaskComponent extends Component { + @argument(Task) + task; +} +``` + +Passing a class works with all of the type helpers mentioned above. + ## Installation While `ember-decorators` is not a hard requirement to use this addon, it's recommended as it adds the base class field and decorator babel transforms diff --git a/addon/-private/combinators/and.js b/addon/-private/combinators/and.js new file mode 100644 index 0000000..b8d5486 --- /dev/null +++ b/addon/-private/combinators/and.js @@ -0,0 +1,20 @@ +import BaseValidator from '../validators/-base'; + +export class AndValidator extends BaseValidator { + constructor(...validators) { + super(); + + this.validators = validators; + } + + check(value) { + return this.validators.reduce( + (acc, validator) => acc && validator.check(value), + true + ); + } +} + +export default function and(...validators) { + return new AndValidator(...validators); +} diff --git a/addon/-private/combinators/index.js b/addon/-private/combinators/index.js new file mode 100644 index 0000000..1f5f375 --- /dev/null +++ b/addon/-private/combinators/index.js @@ -0,0 +1,3 @@ +export { default as and } from './and'; +export { default as not } from './not'; +export { default as or } from './or'; diff --git a/addon/-private/combinators/not.js b/addon/-private/combinators/not.js new file mode 100644 index 0000000..f317cd2 --- /dev/null +++ b/addon/-private/combinators/not.js @@ -0,0 +1,17 @@ +import BaseValidator from '../validators/-base'; + +export class NotValidator extends BaseValidator { + constructor(validator) { + super(); + + this.validator = validator; + } + + check(value) { + return !this.validator.check(value); + } +} + +export default function not(validator) { + return new NotValidator(validator); +} diff --git a/addon/-private/combinators/or.js b/addon/-private/combinators/or.js new file mode 100644 index 0000000..8f515d9 --- /dev/null +++ b/addon/-private/combinators/or.js @@ -0,0 +1,17 @@ +import BaseValidator from '../validators/-base'; + +export class OrValidator extends BaseValidator { + constructor(...validators) { + super(); + + this.validators = validators; + } + + check(value) { + return this.validators.some(validator => validator.check(value)); + } +} + +export default function or(...validators) { + return new OrValidator(...validators); +} diff --git a/addon/-private/resolve-validator.js b/addon/-private/resolve-validator.js new file mode 100644 index 0000000..dfb8bde --- /dev/null +++ b/addon/-private/resolve-validator.js @@ -0,0 +1,63 @@ +import { assert } from '@ember/debug'; + +import AnyValidator from './validators/any'; +import BaseValidator from './validators/-base'; +import InstanceOfValidator from './validators/instance-of'; +import { + BOOLEAN as BOOLEAN_TYPE, + NUMBER as NUMBER_TYPE, + STRING as STRING_TYPE, + SYMBOL as SYMBOL_TYPE +} from './validators/type-match'; +import { + NULL as NULL_VALUE, + UNDEFINED as UNDEFINED_VALUE +} from './validators/value-match'; +import { not, or } from './combinators/index'; + +const primitiveTypeValidators = { + any: new AnyValidator('any'), + object: not( + or( + BOOLEAN_TYPE, + NUMBER_TYPE, + STRING_TYPE, + SYMBOL_TYPE, + NULL_VALUE, + UNDEFINED_VALUE + ) + ), + + boolean: BOOLEAN_TYPE, + number: NUMBER_TYPE, + string: STRING_TYPE, + symbol: SYMBOL_TYPE, + + null: NULL_VALUE, + undefined: UNDEFINED_VALUE +}; + +export default function resolveValidator(type) { + if (type === null) { + return NULL_VALUE; + } else if (type === undefined) { + return UNDEFINED_VALUE; + } else if (type instanceof BaseValidator) { + return type; + } else if (typeof type === 'function' || typeof type === 'object') { + // We allow objects for certain classes in IE, like Element, which have typeof 'object' for some reason + return new InstanceOfValidator(type); + } else if (typeof type === 'string') { + assert( + `Unknown primitive type received: ${type}`, + primitiveTypeValidators[type] !== undefined + ); + + return primitiveTypeValidators[type]; + } else { + assert( + `Types must either be a primitive type string, class, validator, or null or undefined, received: ${type}`, + false + ); + } +} diff --git a/addon/-private/types/array-of.js b/addon/-private/types/array-of.js index fb87a81..2723b42 100644 --- a/addon/-private/types/array-of.js +++ b/addon/-private/types/array-of.js @@ -1,6 +1,24 @@ -import { assert } from '@ember/debug'; -import { resolveValidator, makeValidator } from '../validators'; import { isArray } from '@ember/array'; +import { assert } from '@ember/debug'; + +import resolveValidator from '../resolve-validator'; +import BaseValidator from '../validators/-base'; + +class ArrayOfValidator extends BaseValidator { + constructor(validator) { + super(); + + this.validator = validator; + } + + toString() { + return `arrayOf(${this.validator})`; + } + + check(value) { + return isArray(value) && value.every(value => this.validator.check(value)); + } +} export default function arrayOf(type) { assert( @@ -10,7 +28,5 @@ export default function arrayOf(type) { const validator = resolveValidator(type); - return makeValidator(`arrayOf(${validator})`, value => { - return isArray(value) && value.every(validator); - }); + return new ArrayOfValidator(validator); } diff --git a/addon/-private/types/one-of.js b/addon/-private/types/one-of.js index d015beb..cd8de89 100644 --- a/addon/-private/types/one-of.js +++ b/addon/-private/types/one-of.js @@ -1,5 +1,13 @@ import { assert } from '@ember/debug'; -import { makeValidator } from '../validators'; + +import { OrValidator } from '../combinators/or'; +import ValueMatchValidator from '../validators/value-match'; + +class OneOfValidator extends OrValidator { + toString() { + return `oneOf(${this.validators.join()})`; + } +} export default function oneOf(...list) { assert( @@ -11,7 +19,7 @@ export default function oneOf(...list) { list.every(item => typeof item === 'string') ); - return makeValidator(`oneOf(${list.join()})`, value => { - return list.includes(value); - }); + const validators = list.map(value => new ValueMatchValidator(value)); + + return new OneOfValidator(...validators); } diff --git a/addon/-private/types/optional.js b/addon/-private/types/optional.js index addabfa..3a7814d 100644 --- a/addon/-private/types/optional.js +++ b/addon/-private/types/optional.js @@ -1,8 +1,23 @@ import { assert } from '@ember/debug'; -import { resolveValidator, makeValidator } from '../validators'; -const nullValidator = resolveValidator(null); -const undefinedValidator = resolveValidator(undefined); +import resolveValidator from '../resolve-validator'; +import { + NULL as NULL_VALUE, + UNDEFINED as UNDEFINED_VALUE +} from '../validators/value-match'; +import { OrValidator } from '../combinators/or'; + +class OptionalValidator extends OrValidator { + constructor(validator) { + super(NULL_VALUE, UNDEFINED_VALUE, validator); + + this.originalValidator = validator; + } + + toString() { + return `optional(${this.originalValidator})`; + } +} export default function optional(type) { assert( @@ -11,20 +26,15 @@ export default function optional(type) { ); const validator = resolveValidator(type); - const validatorDesc = validator.toString(); assert( `Passsing 'null' to the 'optional' helper does not make sense.`, - validatorDesc !== 'null' + validator.toString() !== 'null' ); assert( `Passsing 'undefined' to the 'optional' helper does not make sense.`, - validatorDesc !== 'undefined' + validator.toString() !== 'undefined' ); - return makeValidator( - `optional(${validator})`, - value => - nullValidator(value) || undefinedValidator(value) || validator(value) - ); + return new OptionalValidator(validator); } diff --git a/addon/-private/types/shape-of.js b/addon/-private/types/shape-of.js index 71913ad..597f0ce 100644 --- a/addon/-private/types/shape-of.js +++ b/addon/-private/types/shape-of.js @@ -1,6 +1,37 @@ import { assert } from '@ember/debug'; import { get } from '@ember/object'; -import { resolveValidator, makeValidator } from '../validators'; + +import resolveValidator from '../resolve-validator'; +import BaseValidator from '../validators/-base'; + +class ShapeOfValidator extends BaseValidator { + constructor(shape) { + super(); + + this.shape = {}; + this.typeDesc = []; + + for (let key in shape) { + this.shape[key] = resolveValidator(shape[key]); + + this.typeDesc.push(`${key}:${shape[key]}`); + } + } + + toString() { + return `shapeOf({${this.typeDesc.join()}})`; + } + + check(value) { + for (let key in this.shape) { + if (this.shape[key].check(get(value, key)) !== true) { + return false; + } + } + + return true; + } +} export default function shapeOf(shape) { assert( @@ -16,21 +47,5 @@ export default function shapeOf(shape) { Object.keys(shape).length > 0 ); - let typeDesc = []; - - for (let key in shape) { - shape[key] = resolveValidator(shape[key]); - - typeDesc.push(`${key}:${shape[key]}`); - } - - return makeValidator(`shapeOf({${typeDesc.join()}})`, value => { - for (let key in shape) { - if (shape[key](get(value, key)) !== true) { - return false; - } - } - - return true; - }); + return new ShapeOfValidator(shape); } diff --git a/addon/-private/types/union-of.js b/addon/-private/types/union-of.js index 3a88de9..388b1b4 100644 --- a/addon/-private/types/union-of.js +++ b/addon/-private/types/union-of.js @@ -1,5 +1,13 @@ import { assert } from '@ember/debug'; -import { resolveValidator, makeValidator } from '../validators'; + +import resolveValidator from '../resolve-validator'; +import { OrValidator } from '../combinators/or'; + +class UnionOfValidator extends OrValidator { + toString() { + return `unionOf(${this.validators.join()})`; + } +} export default function unionOf(...types) { assert( @@ -9,7 +17,5 @@ export default function unionOf(...types) { const validators = types.map(resolveValidator); - return makeValidator(`unionOf(${validators.join()})`, value => { - return validators.some(validator => validator(value)); - }); + return new UnionOfValidator(...validators); } diff --git a/addon/-private/validators.js b/addon/-private/validators.js deleted file mode 100644 index 9c54b11..0000000 --- a/addon/-private/validators.js +++ /dev/null @@ -1,58 +0,0 @@ -import { assert } from '@ember/debug'; - -function instanceOf(type) { - return makeValidator(type.toString(), value => value instanceof type); -} - -const primitiveTypeValidators = { - any: makeValidator('any', () => true), - object: makeValidator('object', value => { - return ( - typeof value !== 'boolean' && - typeof value !== 'number' && - typeof value !== 'string' && - typeof value !== 'symbol' && - value !== null && - value !== undefined - ); - }), - - boolean: makeValidator('boolean', value => typeof value === 'boolean'), - number: makeValidator('number', value => typeof value === 'number'), - string: makeValidator('string', value => typeof value === 'string'), - symbol: makeValidator('symbol', value => typeof value === 'symbol'), - - null: makeValidator('null', value => value === null), - undefined: makeValidator('undefined', value => value === undefined) -}; - -export function makeValidator(desc, fn) { - fn.isValidator = true; - fn.toString = () => desc; - return fn; -} - -export function resolveValidator(type) { - if (type === null || type === undefined) { - return type === null - ? primitiveTypeValidators.null - : primitiveTypeValidators.undefined; - } else if (type.isValidator === true) { - return type; - } else if (typeof type === 'function' || typeof type === 'object') { - // We allow objects for certain classes in IE, like Element, which have typeof 'object' for some reason - return instanceOf(type); - } else if (typeof type === 'string') { - assert( - `Unknown primitive type received: ${type}`, - primitiveTypeValidators[type] !== undefined - ); - - return primitiveTypeValidators[type]; - } else { - assert( - `Types must either be a primitive type string, class, validator, or null or undefined, received: ${type}`, - false - ); - } -} diff --git a/addon/-private/validators/-base.js b/addon/-private/validators/-base.js new file mode 100644 index 0000000..2213e0c --- /dev/null +++ b/addon/-private/validators/-base.js @@ -0,0 +1,50 @@ +function isPrimitive(value) { + return ( + typeof value === 'boolean' || + typeof value === 'number' || + typeof value === 'string' || + typeof value === 'symbol' + ); +} + +function formatSingleValue(value) { + if (value === null) { + return 'null'; + } + + if (value === undefined) { + return 'undefined'; + } + + if (isPrimitive(value)) { + return typeof value === 'string' ? `'${value}'` : value; + } + + return `an instance of \`${value.constructor.name}\``; +} + +export default class BaseValidator { + check() { + throw new Error('Subclass must implement a `check` method'); + } + + formatValue(value) { + if (Array.isArray(value)) { + return `[${value.map(formatSingleValue).join(', ')}]`; + } + + return formatSingleValue(value); + } + + run(klass, key, value, phase) { + if (this.check(value) === false) { + let formattedValue = this.formatValue(value); + + throw new Error( + `${ + klass.name + }#${key} expected value of type ${this} during '${phase}', but received: ${formattedValue}` + ); + } + } +} diff --git a/addon/-private/validators/any.js b/addon/-private/validators/any.js new file mode 100644 index 0000000..ea1bb39 --- /dev/null +++ b/addon/-private/validators/any.js @@ -0,0 +1,7 @@ +import BaseValidator from './-base'; + +export default class AnyValidator extends BaseValidator { + check() { + return true; + } +} diff --git a/addon/-private/validators/instance-of.js b/addon/-private/validators/instance-of.js new file mode 100644 index 0000000..08df548 --- /dev/null +++ b/addon/-private/validators/instance-of.js @@ -0,0 +1,17 @@ +import BaseValidator from './-base'; + +export default class InstanceOfValidator extends BaseValidator { + constructor(klass) { + super(); + + this.klass = klass; + } + + toString() { + return `\`${this.klass.name}\``; + } + + check(value) { + return value instanceof this.klass; + } +} diff --git a/addon/-private/validators/type-match.js b/addon/-private/validators/type-match.js new file mode 100644 index 0000000..09c0834 --- /dev/null +++ b/addon/-private/validators/type-match.js @@ -0,0 +1,25 @@ +import BaseValidator from './-base'; + +export default class TypeMatchValidator extends BaseValidator { + constructor(type) { + super(); + + this.type = type; + } + + toString() { + return this.type; + } + + check(value) { + return typeof value === this.type; + } +} + +export const BOOLEAN = new TypeMatchValidator('boolean'); + +export const NUMBER = new TypeMatchValidator('number'); + +export const STRING = new TypeMatchValidator('string'); + +export const SYMBOL = new TypeMatchValidator('symbol'); diff --git a/addon/-private/validators/value-match.js b/addon/-private/validators/value-match.js new file mode 100644 index 0000000..ceb72ff --- /dev/null +++ b/addon/-private/validators/value-match.js @@ -0,0 +1,21 @@ +import BaseValidator from './-base'; + +export default class ValueMatchValidator extends BaseValidator { + constructor(value) { + super(); + + this.value = value; + } + + toString() { + return `${this.value}`; + } + + check(value) { + return this.value === value; + } +} + +export const NULL = new ValueMatchValidator(null); + +export const UNDEFINED = new ValueMatchValidator(undefined); diff --git a/addon/-private/wrap-field.js b/addon/-private/wrap-field.js index 2c135b4..a3d3319 100644 --- a/addon/-private/wrap-field.js +++ b/addon/-private/wrap-field.js @@ -24,15 +24,15 @@ class ValidatedProperty { this.originalValue = originalValue; this.typeValidators = typeValidators; - runValidator(typeValidators, klass, keyName, originalValue, 'init'); + typeValidators.run(klass, keyName, originalValue, 'init'); } get(obj, keyName) { let { klass, typeValidators } = this; let newValue = this._get(obj, keyName); - if (typeValidators.length > 0) { - runValidator(typeValidators, klass, keyName, newValue, 'get'); + if (typeValidators) { + typeValidators.run(klass, keyName, newValue, 'get'); } return newValue; @@ -42,8 +42,8 @@ class ValidatedProperty { let { klass, typeValidators } = this; let newValue = this._set(obj, keyName, value); - if (typeValidators.length > 0) { - runValidator(typeValidators, klass, keyName, newValue, 'set'); + if (typeValidators) { + typeValidators.run(klass, keyName, newValue, 'set'); } return newValue; @@ -121,17 +121,6 @@ class ComputedValidatedProperty extends ValidatedProperty { } } -function runValidator(validator, klass, key, value, phase) { - if (validator(value) === false) { - let formattedValue = typeof value === 'string' ? `'${value}'` : value; - throw new Error( - `${ - klass.name - }#${key} expected value of type ${validator} during '${phase}', but received: ${formattedValue}` - ); - } -} - export function wrapField(klass, instance, validations, keyName) { const typeValidators = validations[keyName]; diff --git a/addon/index.js b/addon/index.js index d59917e..a136faf 100644 --- a/addon/index.js +++ b/addon/index.js @@ -1,6 +1,6 @@ import { assert } from '@ember/debug'; -import { resolveValidator } from './-private/validators'; +import resolveValidator from './-private/resolve-validator'; import { addValidationFor } from './-private/validations-for'; import { hasExtension as hasValidationExtension, diff --git a/addon/types.js b/addon/types.js index 997b98e..b242408 100644 --- a/addon/types.js +++ b/addon/types.js @@ -1,5 +1,7 @@ import unionOf from './-private/types/union-of'; +export { default as Any } from './-private/validators/any'; + export { default as arrayOf } from './-private/types/array-of'; export { default as optional } from './-private/types/optional'; export { default as oneOf } from './-private/types/one-of'; diff --git a/index.js b/index.js index 9b2f610..43b9c79 100644 --- a/index.js +++ b/index.js @@ -63,6 +63,7 @@ module.exports = { imports: { '@ember-decorators/argument': ['argument'], '@ember-decorators/argument/types': [ + 'Any', 'arrayOf', 'optional', 'oneOf', diff --git a/tests/dummy/app/components/kitchen-sink.js b/tests/dummy/app/components/kitchen-sink.js index 38298e8..9b23b44 100644 --- a/tests/dummy/app/components/kitchen-sink.js +++ b/tests/dummy/app/components/kitchen-sink.js @@ -1,6 +1,7 @@ import Component from '@ember/component'; import { argument } from '@ember-decorators/argument'; import { + Any, arrayOf, optional, oneOf, @@ -17,6 +18,9 @@ import template from '../templates/components/kitchen-sink'; @layout(template) export default class KitchenSinkComponent extends Component { + @argument(Any) + anything; + @argument('string') someString; diff --git a/tests/unit/argument-test.js b/tests/unit/argument-test.js index d9f878b..9076916 100644 --- a/tests/unit/argument-test.js +++ b/tests/unit/argument-test.js @@ -195,7 +195,6 @@ module('Unit | @argument', function() { test('typed value can be provided by alias', function(assert) { class Foo extends EmberObject { @argument('number') prop; - j; } class Bar extends Foo { diff --git a/tests/unit/types/array-of-test.js b/tests/unit/types/array-of-test.js index 9454eeb..6cfa1a4 100644 --- a/tests/unit/types/array-of-test.js +++ b/tests/unit/types/array-of-test.js @@ -22,7 +22,7 @@ module('Unit | types | arrayOf', function() { } Foo.create({ bar: ['baz', 2] }); - }, /Foo#bar expected value of type arrayOf\(string\) during 'init', but received: baz,2/); + }, /Foo#bar expected value of type arrayOf\(string\) during 'init', but received: \['baz', 2\]/); }); test('it throws if type does not match', function(assert) { diff --git a/tests/unit/types/constructor-test.js b/tests/unit/types/constructor-test.js new file mode 100644 index 0000000..d2c6a7c --- /dev/null +++ b/tests/unit/types/constructor-test.js @@ -0,0 +1,50 @@ +import { test, module } from 'qunit'; +import EmberObject from '@ember/object'; + +import { argument } from '@ember-decorators/argument'; +import { arrayOf } from '@ember-decorators/argument/types'; + +class Thing {} +class OtherThing {} + +module('Unit | types | constructor', function() { + test('mathing against a class instance', function(assert) { + class Foo extends EmberObject { + @argument(Thing) bar; + } + + Foo.create({ bar: new Thing() }); + + assert.throws(function() { + Foo.create({ bar: new OtherThing() }); + }, /Foo#bar expected value of type `Thing` during 'init', but received: an instance of `OtherThing`/); + }); + + test('matching against a built-in constructor', function(assert) { + class Foo extends EmberObject { + @argument(Boolean) bar; + } + + Foo.create({ bar: new Boolean(false) }); + + assert.throws(function() { + Foo.create({ bar: 'test' }); + }, /Foo#bar expected value of type `Boolean` during 'init', but received: 'test'/); + }); + + test('working with helpers', function(assert) { + class Foo extends EmberObject { + @argument(arrayOf(Thing)) bar; + } + + Foo.create({ bar: [new Thing()] }); + + assert.throws(function() { + Foo.create({ bar: new Thing() }); + }, "Foo#bar expected value of type arrayOf(`Thing`) during 'init', but received: an instance of `Thing`"); + + assert.throws(function() { + Foo.create({ bar: [new OtherThing()] }); + }, "Foo#bar expected value of type arrayOf(`Thing`) during 'init', but received: [an instance of `OtherThing`]"); + }); +}); diff --git a/tests/unit/types/optional-test.js b/tests/unit/types/optional-test.js index 238381f..aa4c098 100644 --- a/tests/unit/types/optional-test.js +++ b/tests/unit/types/optional-test.js @@ -1,3 +1,5 @@ +/* eslint-disable no-unused-vars */ + import { test, module } from 'qunit'; import EmberObject from '@ember/object'; @@ -89,8 +91,6 @@ module('Unit | types | optional', function() { @argument(optional(null)) bar; } - - Foo.create({ bar: 2 }); }, /Passsing 'null' to the 'optional' helper does not make sense./); assert.throws(() => { @@ -98,8 +98,6 @@ module('Unit | types | optional', function() { @argument(optional(undefined)) bar; } - - Foo.create({ bar: 2 }); }, /Passsing 'undefined' to the 'optional' helper does not make sense./); }); }); diff --git a/tests/unit/types/shape-of-test.js b/tests/unit/types/shape-of-test.js index 1b7a7b3..2c8e9bb 100644 --- a/tests/unit/types/shape-of-test.js +++ b/tests/unit/types/shape-of-test.js @@ -35,7 +35,7 @@ module('Unit | types | shapeOf', function() { } Foo.create({ bar: { qux: 'baz' } }); - }, /Foo#bar expected value of type shapeOf\({foo:string}\) during 'init', but received: \[object Object\]/); + }, /Foo#bar expected value of type shapeOf\({foo:string}\) during 'init', but received: an instance of `Object`/); }); test('it throws if type does not match', function(assert) {