diff --git a/addon/-private/extensions/with-validation.js b/addon/-private/extensions/with-validation.js index d3b9b51..55597a9 100644 --- a/addon/-private/extensions/with-validation.js +++ b/addon/-private/extensions/with-validation.js @@ -1,4 +1,9 @@ -import { wrapField } from '../wrap-field'; +import Ember from 'ember'; + +import { + isComputedProperty, + wrapComputedProperty +} from '../wrap-computed-property'; import { getValidationsFor } from '../validations-for'; const HAS_VALIDATION = new WeakSet(); @@ -21,10 +26,19 @@ export function withExtension(klass) { init(...args) { super.init(...args); - const validations = getValidationsFor(this.constructor); + const { constructor } = this; + + const validations = getValidationsFor(constructor); + const meta = Ember.meta(this); for (let key in validations) { - wrapField(this.constructor, this, validations, key); + const validation = validations[key]; + + if (isComputedProperty(meta, key)) { + wrapComputedProperty(constructor, this, meta, validation, key); + } + + validation.run(constructor, key, this[key], 'init'); } } }; diff --git a/addon/-private/utils/computed.js b/addon/-private/utils/computed.js deleted file mode 100644 index 0839224..0000000 --- a/addon/-private/utils/computed.js +++ /dev/null @@ -1,25 +0,0 @@ -export function isMandatorySetter(setter) { - return setter && setter.toString().match('You must use .*set()') !== null; -} - -/** - * Recognizes objects that are Ember property descriptors - * - * @parom {object} maybeDesc - * @return {boolean} - */ -export function isEmberDescriptor(maybeDesc) { - return ( - maybeDesc !== null && - typeof maybeDesc === 'object' && - maybeDesc.isDescriptor - ); -} - -export function isDescriptorTrap(maybeDesc) { - return ( - maybeDesc !== null && - typeof maybeDesc === 'object' && - !!maybeDesc.__DESCRIPTOR__ - ); -} diff --git a/addon/-private/utils/object.js b/addon/-private/utils/object.js index 8605b7d..78b5476 100644 --- a/addon/-private/utils/object.js +++ b/addon/-private/utils/object.js @@ -1,20 +1,3 @@ -/** - * Walk up the prototype chain and find the property descriptor for the - * given property - * - * @param {object} target - * @param {string} property - * @return {Descriptor|undefined} - */ -export function getPropertyDescriptor(target, property) { - if (target === undefined || target === null) return; - - return ( - Object.getOwnPropertyDescriptor(target, property) || - getPropertyDescriptor(Object.getPrototypeOf(target), property) - ); -} - export function isExtensionOf(childClass, parentClass) { return childClass.prototype instanceof parentClass; } diff --git a/addon/-private/wrap-computed-property.js b/addon/-private/wrap-computed-property.js new file mode 100644 index 0000000..69d0578 --- /dev/null +++ b/addon/-private/wrap-computed-property.js @@ -0,0 +1,80 @@ +function guardBind(fn, ...args) { + if (typeof fn === 'function') { + return fn.bind(...args); + } +} + +class ComputedValidatedProperty { + constructor(desc, klass, originalValue, typeValidators) { + this.isDescriptor = true; + + this.desc = desc; + this.klass = klass; + this.originalValue = originalValue; + this.typeValidators = typeValidators; + + this.setup = guardBind(desc.setup, desc); + this.teardown = guardBind(desc.teardown, desc); + this.willChange = guardBind(desc.willChange, desc); + this.didChange = guardBind(desc.didChange, desc); + this.willWatch = guardBind(desc.willWatch, desc); + this.didUnwatch = guardBind(desc.didUnwatch, desc); + } + + get(obj, keyName) { + let { klass, typeValidators } = this; + let newValue = this.desc.get(obj, keyName); + + if (typeValidators) { + typeValidators.run(klass, keyName, newValue, 'get'); + } + + return newValue; + } + + set(obj, keyName, value) { + let { klass, typeValidators } = this; + let newValue = this.desc.set(obj, keyName, value); + + if (typeValidators) { + typeValidators.run(klass, keyName, newValue, 'set'); + } + + return newValue; + } +} + +export function isComputedProperty(meta, key) { + const possibleDesc = meta.peekDescriptors(key); + + return possibleDesc && possibleDesc.isDescriptor; +} + +export function wrapComputedProperty( + klass, + instance, + meta, + typeValidators, + key +) { + const desc = meta.peekDescriptors(key); + + let originalValue = desc.get(instance, key); + + let validatedProperty = new ComputedValidatedProperty( + desc, + klass, + originalValue, + typeValidators + ); + + Object.defineProperty(instance, key, { + configurable: true, + enumerable: true, + get() { + return validatedProperty.get(instance, key); + } + }); + + meta.writeDescriptors(key, validatedProperty); +} diff --git a/addon/-private/wrap-descriptor.js b/addon/-private/wrap-descriptor.js new file mode 100644 index 0000000..b15a1c0 --- /dev/null +++ b/addon/-private/wrap-descriptor.js @@ -0,0 +1,98 @@ +/** + * Transforms a `field` descriptor into a property `getter/setter` that + * ensures that the value matches the validators. + * + * @param {Object} element + * @param {Function} validator + */ +function wrapFieldDescriptor( + { descriptor, initializer, key, placement }, + validator +) { + const { ...descriptorToApply } = descriptor; + delete descriptorToApply.writable; + + let initialized = false; + let cachedValue; + + const newElement = { + key, + placement, + kind: 'method', + descriptor: { + ...descriptorToApply, + get() { + if (!initialized) { + initialized = true; + cachedValue = initializer ? initializer.call(this) : undefined; + } + + return cachedValue; + }, + set(newValue) { + initialized = true; + + validator.run(this.constructor, key, newValue, 'set'); + + cachedValue = newValue; + } + } + }; + + return newElement; +} + +/** + * Wraps a property `getter/setter` such that the value must match the + * validator. + * + * @param {Object} element + * @param {Function} validator + */ +function wrapMethodDescriptor({ descriptor, key, ...rest }, validator) { + const { get, set, ...restOfDescriptor } = descriptor; + + const newElement = { + ...rest, + key, + descriptor: { + ...restOfDescriptor, + get() { + const value = get.call(this); + + validator.run(this.constructor, key, value, 'get'); + + return value; + }, + set(newValue) { + const setterReturnedValue = set.call(this, newValue); + + validator.run(this.constructor, key, this[key], 'set'); + + return setterReturnedValue; + } + } + }; + + return newElement; +} + +/** + * Wrap a property in a `getter/setter` that validates it is the right + * type. + * + * @param {Object} element + * @param {Function} validator validator function for new values + */ +export default function wrapDescriptor(element, validator) { + switch (element.kind) { + case 'field': + return wrapFieldDescriptor(element, validator); + case 'method': + return wrapMethodDescriptor(element, validator); + default: + throw new Error( + '`@argument` must be applied to a `field` or property accessor' + ); + } +} diff --git a/addon/-private/wrap-field.js b/addon/-private/wrap-field.js deleted file mode 100644 index 5a766be..0000000 --- a/addon/-private/wrap-field.js +++ /dev/null @@ -1,187 +0,0 @@ -import Ember from 'ember'; - -import { - isMandatorySetter, - isEmberDescriptor, - isDescriptorTrap -} from './utils/computed'; -import { getPropertyDescriptor } from './utils/object'; - -const notifyPropertyChange = - Ember.notifyPropertyChange || Ember.propertyDidChange; - -function guardBind(fn, ...args) { - if (typeof fn === 'function') { - return fn.bind(...args); - } -} - -class ValidatedProperty { - constructor({ originalValue, klass, keyName, typeValidators }) { - this.isDescriptor = true; - - this.klass = klass; - this.originalValue = originalValue; - this.typeValidators = typeValidators; - - typeValidators.run(klass, keyName, originalValue, 'init'); - } - - get(obj, keyName) { - let { klass, typeValidators } = this; - let newValue = this._get(obj, keyName); - - if (typeValidators) { - typeValidators.run(klass, keyName, newValue, 'get'); - } - - return newValue; - } - - set(obj, keyName, value) { - let { klass, typeValidators } = this; - let newValue = this._set(obj, keyName, value); - - if (typeValidators) { - typeValidators.run(klass, keyName, newValue, 'set'); - } - - return newValue; - } -} - -class StandardValidatedProperty extends ValidatedProperty { - constructor({ originalValue }) { - super(...arguments); - - this.cachedValue = originalValue; - } - - _get() { - return this.cachedValue; - } - - _set(obj, keyName, value) { - if (value === this.cachedValue) return value; - - this.cachedValue = value; - - notifyPropertyChange(obj, keyName); - - return this.cachedValue; - } -} - -class NativeComputedValidatedProperty extends ValidatedProperty { - constructor({ desc }) { - super(...arguments); - - this.desc = desc; - } - - _get(obj) { - return this.desc.get.call(obj); - } - - _set(obj, keyName, value) { - // By default Ember.get will check to see if the value has changed before setting - // and calling propertyDidChange. In order to not change behavior, we must do the same - let currentValue = this._get(obj); - - if (value === currentValue) return value; - - this.desc.set.call(obj, value); - - notifyPropertyChange(obj, keyName); - - return this._get(obj); - } -} - -class ComputedValidatedProperty extends ValidatedProperty { - constructor({ desc }) { - super(...arguments); - - this.desc = desc; - - this.setup = guardBind(desc.setup, desc); - this.teardown = guardBind(desc.teardown, desc); - this.willChange = guardBind(desc.willChange, desc); - this.didChange = guardBind(desc.didChange, desc); - this.willWatch = guardBind(desc.willWatch, desc); - this.didUnwatch = guardBind(desc.didUnwatch, desc); - } - - _get(obj, keyName) { - return this.desc.get(obj, keyName); - } - - _set(obj, keyName, value) { - return this.desc.set(obj, keyName, value); - } -} - -export function wrapField(klass, instance, validations, keyName) { - const typeValidators = validations[keyName]; - - let originalValue = instance[keyName]; - let meta = Ember.meta(instance); - let possibleDesc = meta.peekDescriptors(keyName); - - if (possibleDesc !== undefined) { - originalValue = possibleDesc; - } - - if (isDescriptorTrap(originalValue)) { - originalValue = originalValue.__DESCRIPTOR__; - } - - let validatedProperty; - - if (isEmberDescriptor(originalValue)) { - let desc = originalValue; - - originalValue = desc.get(instance, keyName); - - validatedProperty = new ComputedValidatedProperty({ - desc, - keyName, - klass, - originalValue, - typeValidators - }); - } else { - let desc = getPropertyDescriptor(instance, keyName); - - if ( - typeof desc === 'object' && - (typeof desc.get === 'function' || typeof desc.set === 'function') && - !isMandatorySetter(desc.set) - ) { - validatedProperty = new NativeComputedValidatedProperty({ - desc, - keyName, - klass, - originalValue, - typeValidators - }); - } else { - validatedProperty = new StandardValidatedProperty({ - keyName, - klass, - originalValue, - typeValidators - }); - } - } - - Object.defineProperty(instance, keyName, { - configurable: true, - enumerable: true, - get() { - return validatedProperty.get(this, keyName); - } - }); - - meta.writeDescriptors(keyName, validatedProperty); -} diff --git a/addon/index.js b/addon/index.js index a136faf..8f0cbd2 100644 --- a/addon/index.js +++ b/addon/index.js @@ -2,6 +2,7 @@ import { assert } from '@ember/debug'; import resolveValidator from './-private/resolve-validator'; import { addValidationFor } from './-private/validations-for'; +import wrapDescriptor from './-private/wrap-descriptor'; import { hasExtension as hasValidationExtension, withExtension as withValidationExtension @@ -29,8 +30,10 @@ export function argument(typeDefinition) { const validator = resolveValidator(typeDefinition); return desc => { + const descriptorWithValidation = wrapDescriptor(desc, validator); + return { - ...desc, + ...descriptorWithValidation, finisher(klass) { addValidationFor(klass, desc.key, validator); diff --git a/tests/integration/component-behavior-test.js b/tests/integration/component-behavior-test.js index ade59f4..cccb599 100644 --- a/tests/integration/component-behavior-test.js +++ b/tests/integration/component-behavior-test.js @@ -31,7 +31,7 @@ module('Integration | Component Behavior', function(hooks) { assert.equal( error.message, - "FooComponent#foo expected value of type string during 'init', but received: 123" + "FooComponent#foo expected value of type string during 'set', but received: 123" ); }); diff --git a/tests/unit/argument-test.js b/tests/unit/argument-test.js index 9076916..a7628f3 100644 --- a/tests/unit/argument-test.js +++ b/tests/unit/argument-test.js @@ -26,7 +26,7 @@ module('Unit | @argument', function() { assert.throws(function() { Foo.create({ prop: 'wrong type' }); - }, /Foo#prop expected value of type number during 'init', but received: 'wrong type'/); + }, /Foo#prop expected value of type number during 'set', but received: 'wrong type'/); }); test('when the default value does not match', function(assert) { @@ -70,11 +70,11 @@ module('Unit | @argument', function() { assert.throws(() => { Quix.create({ prop: 2 }); - }, /Quix#prop expected value of type string during 'init', but received: 2/); + }, /Quix#prop expected value of type string during 'set', but received: 2/); assert.throws(() => { Quix.create({ prop: 'val', anotherProp: 'val' }); - }, /Quix#anotherProp expected value of type number during 'init', but received: 'val'/); + }, /Quix#anotherProp expected value of type number during 'set', but received: 'val'/); }); test('preventing overriding type in subclass', function(assert) { diff --git a/tests/unit/types/array-of-test.js b/tests/unit/types/array-of-test.js index 6cfa1a4..6d5c734 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 'set', but received: \['baz', 2\]/); }); test('it throws if type does not match', function(assert) { @@ -32,7 +32,7 @@ module('Unit | types | arrayOf', function() { } Foo.create({ bar: 2 }); - }, /Foo#bar expected value of type arrayOf\(string\) during 'init', but received: 2/); + }, /Foo#bar expected value of type arrayOf\(string\) during 'set', but received: 2/); }); test('it throws if incorrect number of items passed in', function(assert) { diff --git a/tests/unit/types/constructor-test.js b/tests/unit/types/constructor-test.js index d2c6a7c..a5b5f50 100644 --- a/tests/unit/types/constructor-test.js +++ b/tests/unit/types/constructor-test.js @@ -17,7 +17,7 @@ module('Unit | types | constructor', function() { assert.throws(function() { Foo.create({ bar: new OtherThing() }); - }, /Foo#bar expected value of type `Thing` during 'init', but received: an instance of `OtherThing`/); + }, /Foo#bar expected value of type `Thing` during 'set', but received: an instance of `OtherThing`/); }); test('matching against a built-in constructor', function(assert) { @@ -29,7 +29,7 @@ module('Unit | types | constructor', function() { assert.throws(function() { Foo.create({ bar: 'test' }); - }, /Foo#bar expected value of type `Boolean` during 'init', but received: 'test'/); + }, /Foo#bar expected value of type `Boolean` during 'set', but received: 'test'/); }); test('working with helpers', function(assert) { diff --git a/tests/unit/types/one-of-test.js b/tests/unit/types/one-of-test.js index e4be7ad..c291cfb 100644 --- a/tests/unit/types/one-of-test.js +++ b/tests/unit/types/one-of-test.js @@ -24,7 +24,7 @@ module('Unit | types | oneOf', function() { } Foo.create({ bar: 'magenta' }); - }, /Foo#bar expected value of type oneOf\(red,blue,green\) during 'init', but received: 'magenta'/); + }, /Foo#bar expected value of type oneOf\(red,blue,green\) during 'set', but received: 'magenta'/); }); test('it throws if non-string passed in', function(assert) { diff --git a/tests/unit/types/optional-test.js b/tests/unit/types/optional-test.js index aa4c098..f13d780 100644 --- a/tests/unit/types/optional-test.js +++ b/tests/unit/types/optional-test.js @@ -29,11 +29,11 @@ module('Unit | types | optional', function() { assert.throws(() => { Foo.create({ bar: 2 }); - }, /Foo#bar expected value of type optional\(string\) during 'init', but received: 2/); + }, /Foo#bar expected value of type optional\(string\) during 'set', but received: 2/); assert.throws(() => { Foo.create({ bar: new Date() }); - }, /Foo#bar expected value of type optional\(string\) during 'init', but received/); + }, /Foo#bar expected value of type optional\(string\) during 'set', but received/); }); test('it requires primitive types or classes', function(assert) { diff --git a/tests/unit/types/shape-of-test.js b/tests/unit/types/shape-of-test.js index 2c8e9bb..c209b76 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: an instance of `Object`/); + }, /Foo#bar expected value of type shapeOf\({foo:string}\) during 'set', but received: an instance of `Object`/); }); test('it throws if type does not match', function(assert) { @@ -46,7 +46,7 @@ module('Unit | types | shapeOf', function() { } Foo.create({ bar: 2 }); - }, /Foo#bar expected value of type shapeOf\({foo:string}\) during 'init', but received: 2/); + }, /Foo#bar expected value of type shapeOf\({foo:string}\) during 'set', but received: 2/); }); test('it throws if non-object passed in', function(assert) { diff --git a/tests/unit/types/union-of-test.js b/tests/unit/types/union-of-test.js index eb37c42..744f840 100644 --- a/tests/unit/types/union-of-test.js +++ b/tests/unit/types/union-of-test.js @@ -26,11 +26,11 @@ module('Unit | types | unionOf', function() { assert.throws(() => { Foo.create({ bar: null }); - }, /Foo#bar expected value of type unionOf\(string,undefined\) during 'init', but received: null/); + }, /Foo#bar expected value of type unionOf\(string,undefined\) during 'set', but received: null/); assert.throws(() => { Foo.create({ bar: new Date() }); - }, /Foo#bar expected value of type unionOf\(string,undefined\) during 'init', but received/); + }, /Foo#bar expected value of type unionOf\(string,undefined\) during 'set', but received/); }); test('it requires primitive types or classes', function(assert) {