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

feat(observable): coercion, typed observable #623

Closed
wants to merge 5 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 51 additions & 0 deletions src/coerce-functions.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import * as LogManager from 'aurelia-logging';

export const coerceFunctions = {
none(a) {
return a;
},
number(a) {
const val = Number(a);
return !isNaN(val) && isFinite(val) ? val : 0;
Copy link
Contributor

Choose a reason for hiding this comment

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

I would expect either NaN or Infinity to be returned if not a valid number, not 0.

Copy link
Member Author

@bigopon bigopon Dec 12, 2017

Choose a reason for hiding this comment

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

NaN doesn't work nicely with === (two way binding scenarios), to handle it we need to sacrifice perf for more checks. With hundreds of bindings perf hit will be worse. I'm not sure what to do here. Infinity, on the other hand, still problematic but could be treated as is.

Copy link
Contributor

Choose a reason for hiding this comment

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

I see it is a problem with two-way binding. Still, as a developer I would not expect 0 to be returned

Copy link
Member Author

Choose a reason for hiding this comment

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

I think it could be application specific where NaN / Infinity are expected. For that case, we can always define another decorator via createTypedObservable:

import {
  coerceFunctions,
  createTypedObservable
} from 'aurelia-binding'

coerceFunctions.$number = function(value) {
  return Number(value);
};
createTypedObservable('$number');

// usage

class App {
  @observable.$number() numProp
}

},
string(a) {
return '' + a;
},
boolean(a) {
return !!a;
},
date(val) {
Copy link
Contributor

Choose a reason for hiding this comment

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

Since dates are stateful, should there be a check if it's already of type date?
I haven't thought this through, just want to make sure this case has been considered.

if (typeof val === 'date') {
  return val;
}

Copy link
Member Author

@bigopon bigopon Dec 12, 2017

Choose a reason for hiding this comment

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

This could be nice. I didn't think of it. Maybe we can reuse the instance if this ever gets merged ? @jdanyow / @EisenbergEffect

// Invalid date instances are quite problematic
// so we need to deal with it properly by default
if (val === null || val === undefined) {
return null;
}
const d = new Date(val);
return isNaN(d.getTime()) ? null : d;
}
};

export const coerceFunctionMap: Map<{new(): any}, string> = new Map([
[Number, 'number'],
[String, 'string'],
[Boolean, 'boolean'],
[Date, 'date']
]);

/**
* Map a class to a string for typescript property coerce
* @param type the property class to register
* @param strType the string that represents class in the lookup
* @param coerceFunction coerce function to register with param strType
*/
export function mapCoerceFunction(type: {new(): any, coerce?: (val: any) => any}, strType: string, coerceFunction: (val: any) => any) {
coerceFunction = coerceFunction || type.coerce;
if (typeof strType !== 'string' || typeof coerceFunction !== 'function') {
LogManager
.getLogger('map-coerce-function')
.warn(`Bad attempt at mapping coerce function for type: ${type.name} to: ${strType}`);
return;
}
coerceFunctions[strType] = coerceFunction;
coerceFunctionMap.set(type, strType);
}
147 changes: 138 additions & 9 deletions src/decorator-observable.js
Original file line number Diff line number Diff line change
@@ -1,14 +1,64 @@
export function observable(targetOrConfig: any, key: string, descriptor?: PropertyDescriptor) {
function deco(target, key, descriptor, config) { // eslint-disable-line no-shadow
// class decorator?
import { coerceFunctions, coerceFunctionMap } from './coerce-functions';
import { metadata } from 'aurelia-metadata';
import * as LogManager from 'aurelia-logging';

/**
* @typedef ObservableConfig
* @prop {string} name
* @prop {string} changeHandler
* @prop {string | {(val: any): any}} coerce
*/

const observableLogger = LogManager.getLogger('aurelia-observable-decorator');

export function observable(targetOrConfig: string | Function | ObservableConfig, key?: string, descriptor?: PropertyDescriptor) {
/**
* @param target The class decorated
* @param key The target class field of the decorator
* @param descriptor class field descriptor
* @param config user's config
*/
function deco(target: Function, key?: string, descriptor?: PropertyDescriptor & { initializer(): any }, config?: ObservableConfig) { // eslint-disable-line no-shadow
// Used to check if we should pickup the type from metadata
const userDidDefineCoerce = config !== undefined && config.coerce !== undefined;
let propType;
let coerceFunction;

if (userDidDefineCoerce) {
switch (typeof config.coerce) {
case 'string':
coerceFunction = coerceFunctions[config.coerce]; break;
case 'function':
coerceFunction = config.coerce; break;
default: break;
}
if (coerceFunction === undefined) {
observableLogger.warn(`Invalid coerce instruction. Should be either one of ${Object.keys(coerceFunctions)} or a function.`);
}
} else if (_usePropertyType) {
propType = metadata.getOwn(metadata.propertyType, target, key);
if (propType) {
coerceFunction = coerceFunctions[coerceFunctionMap.get(propType)];
if (coerceFunction === undefined) {
observableLogger.warn(`Unable to find coerce function for type ${propType.name}.`);
}
}
}

/**
* class decorator?
* @example
* @observable('firstName') MyClass {}
* @observable({ name: 'firstName' }) MyClass {}
*/
const isClassDecorator = key === undefined;
if (isClassDecorator) {
target = target.prototype;
key = typeof config === 'string' ? config : config.name;
}

// use a convention to compute the inner property name
let innerPropertyName = `_${key}`;
const innerPropertyName = `_${key}`;
const innerPropertyDescriptor: PropertyDescriptor = {
configurable: true,
enumerable: false,
Expand All @@ -22,8 +72,10 @@ export function observable(targetOrConfig: any, key: string, descriptor?: Proper
// babel passes in the property descriptor with a method to get the initial value.

// set the initial value of the property if it is defined.
// also make sure it's coerced
if (typeof descriptor.initializer === 'function') {
innerPropertyDescriptor.value = descriptor.initializer();
const initValue = descriptor.initializer();
innerPropertyDescriptor.value = coerceFunction === undefined ? initValue : coerceFunction(initValue);
}
} else {
// there is no descriptor if the target was a field in TS (although Babel provides one),
Expand All @@ -48,16 +100,17 @@ export function observable(targetOrConfig: any, key: string, descriptor?: Proper
descriptor.get = function() { return this[innerPropertyName]; };
descriptor.set = function(newValue) {
let oldValue = this[innerPropertyName];
if (newValue === oldValue) {
let coercedValue = coerceFunction === undefined ? newValue : coerceFunction(newValue);
if (coercedValue === oldValue) {
return;
}

// Add the inner property on the instance and make it nonenumerable.
this[innerPropertyName] = newValue;
this[innerPropertyName] = coercedValue;
Reflect.defineProperty(this, innerPropertyName, { enumerable: false });

if (this[callbackName]) {
this[callbackName](newValue, oldValue, key);
this[callbackName](coercedValue, oldValue, key);
}
};

Expand All @@ -72,10 +125,26 @@ export function observable(targetOrConfig: any, key: string, descriptor?: Proper
}
}

/**
* Decorating with parens
* @example
* @observable MyClass {} <----- this breaks, but will go into this condition
* @observable('firstName') MyClass {}
* @observable({ name: 'firstName' }) MyClass {}
* class MyClass {
* @observable() prop
* }
*/
if (key === undefined) {
// parens...
return (t, k, d) => deco(t, k, d, targetOrConfig);
}
/**
* Decorating on class field
* @example
* class MyClass {
* @observable prop
* }
*/
return deco(targetOrConfig, key, descriptor);
}

Expand All @@ -91,3 +160,63 @@ no parens | n/a | n/a
class | config | config
| target | target
*/

/**
* Internal flag to turn on / off auto pickup property type from metadata
*/
let _usePropertyType = false;

/**
* Toggle the flag for observable to auto pickup property type from metadata
* The reason is sometimes we may want to use prop type on bindable, but not observable
* and vice versa
*/
observable.usePropertyType = (shouldUsePropType: boolean) => {
_usePropertyType = shouldUsePropType;
};

/**
* Decorator: Creates a new observable decorator that can be used for fluent syntax purpose
* @param type the type name that will be assign to observable decorator. `createTypedObservable('point') -> observable.point`
*/
export function createTypeObservable(type: string) {
return observable[type] = function(targetOrConfig: string | Function | ObservableConfig, key?: string, descriptor?: PropertyDescriptor & {initializer():any}) {
if (targetOrConfig === undefined) {
/**
* MyClass {
* @observable.number() num
* }
*
* This will breaks so need to check for proper error
* @observable.number()
* class MyClass {}
*/
return observable({ coerce: type });
}
if (key === undefined) {
/**
* @observable.number('num')
* class MyClass {}
*
* @observable.number({...})
* class MyClass
*
* class MyClass {
* @observable.number({...})
* num
* }
*/
targetOrConfig = typeof targetOrConfig === 'string' ? { name: targetOrConfig } : targetOrConfig;
targetOrConfig.coerce = type;
return observable(targetOrConfig);
}
/**
* class MyClass {
* @observable.number num
* }
*/
return observable({ coerce: type })(targetOrConfig, key, descriptor);
};
}

['string', 'number', 'boolean', 'date'].forEach(createTypeObservable);
Loading