fashion-model
Version:
JavaScript library for defining types and their properties with support for wrapping/unwrapping and serialization/deserialization.
1,090 lines (914 loc) • 28.3 kB
JavaScript
/* eslint camelcase: ["off"] */
const inherit = require('./util/inherit');
let ArrayType;
let primitives;
let Model;
function _emptyObject () {
return Object.create(null);
}
const NOT_INSTANCE = {};
const EMPTY_PROPERTIES = _emptyObject();
function _forProperty (property, options, work) {
options = _toOptions(options);
const origProperty = options.property;
options.property = property;
work(options);
options.property = origProperty;
return options;
}
function _notifySet (model, oldValue, newValue, property) {
const Type = model.constructor;
if (Type._onSet) {
const event = {
model: model,
propertyName: property.getName(),
oldValue: oldValue,
newValue: newValue,
property: property
};
const len = Type._onSet.length;
for (let i = 0; i < len; i++) {
Type._onSet[i](model, event);
}
}
}
function _set (model, data, property, value, options) {
const Type = property.getType();
// this the value that we return (it may be the result of wrapping/coercing the original value)
const wrapped = Type.isWrapped();
if (Model.isModel(value) && Type.isInstance(value)) {
// value is expected type
// store raw data in this model's data
} else if (!wrapped && Type.coerce) {
// Only call coerce if this type is not wrapped
// (otherwise we'd call coerce twice which is wasteful)
// The coerce function needs some context in the options
options = _forProperty(property, options, function (options) {
value = Type.coerce(value, options);
});
}
const propertyKey = property.getKey();
const oldValue = data[propertyKey];
const setter = property.getSetter();
if (setter) {
// setter will do all of the work
setter.call(model, value, property);
// get the new value
value = data[propertyKey];
}
if (wrapped) {
options = _forProperty(property, options, function (options) {
// recursively call setters
// The return value is the wrapped value
value = Type.wrap(value, options);
});
}
if (oldValue !== value) {
data[propertyKey] = value;
_notifySet(model, oldValue, value, property);
}
return value;
}
function _generateGetter (property) {
const propertyKey = property.getKey();
const getter = property.getGetter();
if (getter) {
return function (options) {
return getter.call(this, property);
};
} else {
return function (options) {
return this.data[propertyKey];
};
}
}
function _generateSetter (property) {
return function (value, options) {
return _set(this, this.data, property, value, options);
};
}
// This function will be used to make sure an array value
// exists for the given property
function _ensureArray (model, property) {
const propertyKey = property.getKey();
let array = model.data[propertyKey];
if (!array) {
model.data[propertyKey] = array = [];
ArrayType.flagAsArrayType(array);
}
return array;
}
function _generateAddValueTo (property) {
const items = property.items;
const ItemType = items && items.type;
let coerce;
if (ItemType) {
coerce = ItemType.coerce;
}
if (ItemType && ItemType.isWrapped()) {
// the addTo<Property> function will need to wrap each item
// when adding it to the array
return function (value, options) {
const array = _ensureArray(this, property);
array.push(ItemType.wrap(value, options));
};
} else {
// the addTo<Property> function should add raw value to array
// (call the type coercion function if it exists)
return function (value, options) {
const array = _ensureArray(this, property);
array.push((coerce) ? coerce.call(ItemType, value, options) : value);
};
}
}
function _initialUpperCase (str) {
return str.charAt(0).toUpperCase() + str.substring(1);
}
function _convertArray (array, options) {
return ArrayType.convertArrayItems(array, this, options);
}
// This is the constructor that gets called when ever a Model (or derived type)
// is created
module.exports = Model = function Model (rawData, options) {
const Type = this.constructor;
if (Type.constructable === false) {
throw new Error('Instances of this type cannot be created. data: ' + rawData);
}
};
Model.typeName = 'Model';
// This is a internal helper function that will do some work in the context
// of a property. The options.property value will be temporarily updated to
// reflect the given property and then options.property will be restored
// to its original value after the work is executed.
Model._forProperty = _forProperty;
Model.isModel = function (obj) {
return obj && obj.Model;
};
Model.cleanArray = function (array, options) {
let i = array.length;
const newArray = new Array(i);
while (--i >= 0) {
newArray[i] = Model.clean(array[i], options);
}
return newArray;
};
Model.clean = function (obj, options) {
options = _toOptions(options);
if (obj == null) {
return obj;
} else if (Array.isArray(obj)) {
return Model.cleanArray(obj, options);
} else if (obj.Model) {
// obj is an instance of a Model
return obj.clean(options);
} else {
// obj is not associated with a model instance...
// If the obj we were given is a complex object then return a
// deep clone...
// NOTE: Don't clone Date object to clean it since we assume it
// is already clean.
if ((obj.constructor !== Date) && (typeof obj === 'object')) {
const clean = {};
const keys = Object.keys(obj);
const len = keys.length;
for (let i = 0; i < len; i++) {
const key = keys[i];
const value = Model.clean(obj[key], options);
if (value !== undefined) {
clean[key] = value;
}
}
return clean;
} else {
return obj;
}
}
};
Model.unwrap = function (obj) {
if (obj == null) {
return obj;
}
if (obj.Model) {
return obj.data || obj;
} else {
return obj;
}
};
Model.hasProperties = function () {
return (this.properties !== EMPTY_PROPERTIES) || (this.additionalProperties === true);
};
Model.hasProperty = function (propertyName) {
return !!this.properties[propertyName];
};
Model.getProperties = function () {
return this.properties;
};
Model.getProperty = function (propertyName) {
return this.properties[propertyName];
};
Model.forEachProperty = function (options, callback) {
if (!this.Properties) {
return;
}
if (arguments.length === 1) {
callback = arguments[0];
options = _emptyObject();
}
let proto = this.Properties.prototype;
const inherited = (options.inherited !== false);
const seen = _emptyObject();
do {
for (let key in proto) {
// Make sure we haven't already handled the given property
if (!seen[key] && proto.hasOwnProperty(key)) {
const property = proto[key];
if (property.constructor === Property) {
if (key === property.getName()) {
callback(property);
}
}
seen[key] = true;
}
}
if (!inherited) {
break;
}
// Move up the prototype chain if we care about inherited properties
} while (((proto = Object.getPrototypeOf(proto)) != null));
};
Model.preventConstruction = function () {
this.constructable = false;
};
Model.isCompatibleWith = function (other) {
let cur = this;
do {
if (cur === other) {
return true;
}
} while ((cur = (cur.$super)));
return false;
};
Model.isInstance = function (value) {
return (value instanceof this);
};
Model.isPrimitive = function () {
return false;
};
Model.coercionError = function (value, options, errorMessage) {
let message = '';
if (options && options.property && options.property.getName) {
message += options.property.getName() + ': ';
}
message += 'Invalid value: ' + value;
if (errorMessage) {
message += ' - ' + errorMessage;
}
if (options && options.errors) {
options.errors.push(message);
} else {
const err = new Error(message);
err.source = Model;
throw err;
}
};
Model.stringify = function (obj, pretty) {
return JSON.stringify(Model.clean(obj), null, pretty ? ' ' : undefined);
};
const Model_proto = Model.prototype;
Model_proto.unwrap = function () {
return this.data || this;
};
/**
* Creates a deep clone of the data stored in this object with all temporary
* and non-persisted values removed.
*/
Model_proto.clean = function (options) {
options = _toOptions(options);
const Type = this.Model;
if (Type.clean) {
// call "clean" function provided by type
return Type.clean(this, options);
}
// The "properties" object is a map that we can use to lookup
// all property definitions
const properties = Type.properties;
let data = this.data;
if (Type.hasProperties()) {
const clone = {};
const keys = Object.keys(data);
const len = keys.length;
for (let i = 0; i < len; i++) {
const key = keys[i];
const property = options.property = properties[key];
let value = data[key];
if (property && (property.isPersisted())) {
// no need to clean null/undefined values
if (value == null) {
if (value !== undefined) {
clone[key] = value;
}
} else {
const propertyType = property.type;
const propertyClean = property.clean;
const clean = propertyType.clean;
const oldProperty = options.property;
options.property = property;
if (propertyClean) {
// call the clean method on the property
value = propertyClean(value, options);
} else if (clean) {
// call the clean function provided by model
value = propertyType.clean(value, options);
} else if (value.Model || propertyType.isWrapped()) {
// value is a Model instance or it is something
// that could be wrapped.
// Use the default clean function...
value = Model.clean(value, options);
}
// restore the old property
options.property = oldProperty;
if (value !== undefined) {
// put the cleaned value into the clone
clone[key] = value;
}
}
} else if (Type.additionalProperties) {
if (value.Model) {
value = value.clean(options);
}
// simply copy the additional property
if (value !== undefined) {
clone[key] = value;
}
} else if (options.errors) {
options.errors.push('Unrecognized property: ' + key);
}
}
data = clone;
}
if (Type.afterClean) {
const result = Type.afterClean(data, options);
if (result !== undefined) {
data = result;
}
}
// Model does not have properties so simply return the raw data
// (there should be no wrapper)
return data;
};
function _getProperty (model, propertyName, errors) {
const Type = model.constructor;
const properties = Type.properties;
const property = properties[propertyName];
if (!property) {
if (!Type.additionalProperties) {
const err = new Error('Invalid property: ' + propertyName);
if (errors) {
errors.push(err);
} else {
throw err;
}
}
}
return property;
}
/**
* Set value of property with given propertyName to given value.
* @param {String} propertyName the property name
* @param {Object} value the new value
* @param {Object|Array} options an optional object that specifies options
* or an array which will have any errors added to it
*/
Model_proto.set = function (propertyName, value, options) {
const modelData = this.data;
const property = _getProperty(this, propertyName, options);
if (property) {
_set(this, modelData, property, value, options);
} else {
const oldValue = modelData[propertyName];
if (oldValue !== value) {
modelData[propertyName] = value;
_notifySet(this, propertyName, oldValue, value);
}
}
};
/**
* Get value of property with given propertyName.
* @param {String} propertyName the property name
* @param {Object|Array} options an optional object that specifies options
* or an array which will have any errors added to it
*/
Model_proto.get = function (propertyName, options) {
const Type = this.constructor;
const property = Type.properties[propertyName];
if (property) {
const getter = property.getGetter();
if (getter) {
return getter.call(this, propertyName, property);
}
}
return this.data[propertyName];
};
Model_proto.stringify = function (pretty) {
return Model.stringify(this.data, pretty);
};
function Property (config) {
for (let key in config) {
if (config.hasOwnProperty(key)) {
this[key] = config[key];
}
}
}
const Property_proto = Property.prototype;
Property_proto.getName = function () {
return this.name;
};
Property_proto.getKey = Property_proto.toString = function () {
return this.key;
};
Property_proto.getType = function () {
return this.type;
};
Property_proto.getItems = function () {
return this.items;
};
Property_proto.getGetter = function () {
return this.get;
};
Property_proto.getSetter = function () {
return this.set;
};
Property_proto.isPersisted = function () {
return (this.persist !== false);
};
function Items (owner) {
this.owner = owner;
}
const Items_proto = Items.prototype;
Items_proto.getName = function () {
return this.owner.getName();
};
function _parseType (type) {
if (type.Model) {
// type is derived from Model
return type;
}
switch (type) {
case Date:
return primitives.date;
case Number:
return primitives.number;
case Boolean:
return primitives.boolean;
case String:
return primitives.string;
case Object:
return primitives.object;
case Function:
return primitives.function;
case Array:
return ArrayType;
}
if ((typeof type === 'object') && (Object.keys(type).length === 0)) {
return primitives.any;
}
return null;
}
function _parseTypeStr (typeStr, propertyConfig, resolver, Type) {
const len = typeStr.length;
if ((typeStr.charAt(len - 2) === '[') && (typeStr.charAt(len - 1) === ']')) {
// array type
propertyConfig.type = ArrayType;
propertyConfig.items = _emptyObject();
_parseTypeStr(typeStr.substring(0, len - 2), propertyConfig.items, resolver, Type);
} else {
propertyConfig.type = _resolve(typeStr, resolver, Type);
}
}
function _resolve (typeName, resolver, Type) {
let type = primitives[typeName];
if (type) {
return type;
}
if (typeName === 'self') {
return Type;
}
if (resolver) {
if ((type = resolver(typeName))) {
return type;
}
}
throw new Error('Invalid type: ' + typeName);
}
function _parseTypeConfig (propertyName, propertyConfig, resolver, Type) {
if (Array.isArray(propertyConfig)) {
propertyConfig = {
type: propertyConfig
};
} else if ((typeof propertyConfig) !== 'object') {
propertyConfig = {
type: propertyConfig
};
}
const type = propertyConfig.type;
if (type) {
if (Array.isArray(type)) {
// handle short-hand notation for Array types
propertyConfig.type = ArrayType;
if (type.length) {
const items = type[0];
if (items != null) {
propertyConfig.items = _parseTypeConfig(propertyName, items, resolver, Type);
}
}
} else if (type.constructor === String) {
_parseTypeStr(type, propertyConfig, resolver, Type);
} else {
// handle normal notation for types
propertyConfig.type = _parseType(type);
if (!propertyConfig.type) {
throw new Error('Unrecognized type ' + JSON.stringify(type) + ' for property "' + propertyName + '". Expected type derived from Model or primitive type.');
}
// Convert the subtype to special type if necessary
if (propertyConfig.items) {
propertyConfig.items = _parseTypeConfig(propertyName, propertyConfig.items, resolver, Type);
}
}
} else {
propertyConfig.type = primitives.any;
}
return propertyConfig;
}
function _toProperty (name, propertyConfig, resolver, Type) {
propertyConfig = _parseTypeConfig(name, propertyConfig, resolver, Type);
propertyConfig.name = name;
propertyConfig.key = propertyConfig.key || name;
return new Property(propertyConfig);
}
const SPECIAL_PROPERTIES = {
init: 1,
wrap: 1,
coerce: 1,
properties: 1,
prototype: 1,
mixins: 1
};
function _copyNonSpecialPropertiesToType (config, Type) {
for (let key in config) {
if (config.hasOwnProperty(key) && !SPECIAL_PROPERTIES[key]) {
Type[key] = config[key];
}
}
}
function _toOptions (options) {
if (options == null) {
return _emptyObject();
}
if (Array.isArray(options)) {
return {
errors: options
};
}
return options;
}
function _addToArray (obj, propertyName, value) {
if (!value) {
return;
}
const arr = obj[propertyName] || (obj[propertyName] = []);
arr.push(value);
}
function _concatToArray (obj, propertyName, otherArr) {
if (!otherArr) {
return;
}
const arr = obj[propertyName] || (obj[propertyName] = []);
for (let i = 0; i < otherArr.length; i++) {
arr.push(otherArr[i]);
}
}
function _installMixin (mixin, Type, Base, existingProperties) {
if (mixin.initType) {
mixin.initType(Type);
}
let key;
if (mixin.id) {
key = '_mixin_' + mixin.id;
if ((Base.properties && Base.properties[key]) || Type.Properties.prototype[key]) {
// this mixin is already installed
return;
}
Type.Properties.prototype[key] = true;
}
const mixinPrototype = mixin.prototype;
if (mixinPrototype) {
for (key in mixinPrototype) {
if (mixinPrototype.hasOwnProperty(key)) {
Type.prototype[key] = mixinPrototype[key];
}
}
}
let mixinProperties;
if ((mixinProperties = mixin.properties)) {
for (key in mixinProperties) {
if (mixinProperties.hasOwnProperty(key) && (existingProperties[key] === undefined)) {
existingProperties[key] = mixinProperties[key];
}
}
}
_addToArray(Type, '_init', mixin.init);
_addToArray(Type, '_onSet', mixin.onSet);
let mixins;
if ((mixins = mixin.mixins)) {
for (const mixin of mixins) {
_installMixin(mixin, Type, Base, existingProperties);
}
}
}
function _checkInstance (obj, wrap, Type) {
return wrap && Type.isInstance(obj) ? obj : NOT_INSTANCE;
}
function _extend (Base, config, resolver) {
config = config || _emptyObject();
const init = config.init;
let wrap = config.wrap;
const coerce = config.coerce;
let properties = config.properties;
const prototype = config.prototype;
const mixins = config.mixins;
let Data;
function Type (rawData, options) {
if (Data) {
if (this.data === undefined) {
this.data = new Data(this, rawData, options);
}
} else {
this.data = rawData;
}
// Call the super constructor
Type.$super.call(this, rawData, options);
// Call initialization functions provided by mixins (if any)
const initArr = Type._init;
if (initArr) {
for (let i = 0; i < initArr.length; i++) {
initArr[i].call(this, rawData, options);
}
}
// Call the user-provided "constructor" function
if (init) {
init.call(this, rawData, options);
}
}
// Selectively copy properties from Model to Type
for (const property of [
'getProperty',
'getProperties',
'hasProperty',
'hasProperties',
'preventConstruction',
'unwrap',
'coercionError',
'forEachProperty',
'isCompatibleWith',
'isInstance',
'isPrimitive'
]) {
Type[property] = Model[property];
}
Type.convertArray = _convertArray;
// Now copy any properties from config to Type that might
// override any of the special prpoerties that were copied above
_copyNonSpecialPropertiesToType(config, Type);
if (Base.additionalProperties) {
// the additionalProperties flag should trickle down if true
Type.additionalProperties = true;
}
_concatToArray(Type, '_onSet', Base._onSet);
// Store reference to Model
Object.defineProperty(Type, 'Model', {
enumerable: false,
configurable: false,
writable: false,
value: Model
});
if (coerce) {
// Create a proxy coerce function that guarantees that options
// argument will be provided.
Type.coerce = function (value, options) {
return coerce.call(Type, value, _toOptions(options));
};
}
// provide method to extend this model
Type.extend = function (config) {
return _extend(Type, config);
};
Type.isWrapped = function () {
return (wrap !== false);
};
let factory;
if (wrap && wrap.constructor === Function) {
factory = wrap;
} else {
wrap = (wrap !== false);
factory = function (data, options) {
if (wrap && (arguments.length === 0)) {
return new Type();
}
let instance;
// see if the data is already an instance
if ((data != null) && (instance = _checkInstance(data, wrap, Type)) !== NOT_INSTANCE) {
// we already have instance of correct type so return it
return instance;
}
if (coerce) {
data = coerce.call(Type, data, (options = _toOptions(options)));
// If the coerce function returns null/undefined then not
// much more we can do so simply return that value
if (data == null) {
return data;
}
// Do we have the correct type after coercion?
if ((instance = _checkInstance(data, wrap, Type)) !== NOT_INSTANCE) {
// coercion return instance of correct type so return it
return instance;
}
} else if (data == null) {
return data;
}
data = Model.unwrap(data);
if (!wrap) {
// if we're not wrapping or data is null then simply return the raw value
return data;
}
if (Array.isArray(data)) {
return Type.convertArray(data, options);
}
// return new model instance
return new Type(data, options);
};
}
Type.wrap = factory;
if (!Type.create) {
Type.create = factory;
}
inherit(Type, Base);
const classPrototype = Type.prototype;
classPrototype.Model = Type;
let propertyNames;
if ((properties && (propertyNames = Object.keys(properties)).length > 0) || mixins) {
// Use prototype chaining to create property map
Type.Properties = function () {
// nothing to do here
};
if (Base.Properties) {
inherit(Type.Properties, Base.Properties);
} else {
Type.Properties.prototype = {};
}
if (mixins) {
const mixinProperties = _emptyObject();
for (const mixin of mixins) {
_installMixin(mixin, Type, Base, mixinProperties);
}
const mixinPropertyNames = Object.keys(mixinProperties);
if (mixinPropertyNames.length) {
if (properties) {
// combine mixin properties with properties provided for Type
// but give precedence to the Type properties.
for (let i = 0; i < mixinPropertyNames.length; i++) {
const propertyName = mixinPropertyNames[i];
if (!properties.hasOwnProperty(propertyName)) {
properties[propertyName] = mixinProperties[propertyName];
propertyNames.push(propertyName);
}
}
} else {
// use properties from mixin
properties = mixinProperties;
propertyNames = mixinPropertyNames;
}
}
}
if (properties) {
const propertiesPrototype = Type.Properties.prototype;
for (const propertyName of propertyNames) {
const property = _toProperty(propertyName, properties[propertyName], resolver, Type);
const propertyKey = property.getKey();
// Put the properties in the prototype by name and property
propertiesPrototype[propertyName] = property;
if (propertyName !== propertyKey) {
propertiesPrototype[propertyKey] = property;
}
let funcName;
const funcSuffix = _initialUpperCase(propertyName);
// generate getter
if (property.getGetter() !== null) {
funcName = 'get' + funcSuffix;
classPrototype[funcName] = _generateGetter(property);
}
// generate setter
if (property.getSetter() !== null) {
funcName = 'set' + funcSuffix;
classPrototype[funcName] = _generateSetter(property);
}
// generate addTo<Property> if property is Array type
if (property.getType() === ArrayType) {
funcName = 'addTo' + funcSuffix;
classPrototype[funcName] = _generateAddValueTo(property);
}
}
}
Type.properties = new Type.Properties();
} else {
Type.Properties = Base.Properties;
Type.properties = Base.properties || EMPTY_PROPERTIES;
}
if (Type.hasProperties()) {
const _allProperties = [];
Type.forEachProperty(function (property) {
_allProperties.push(property.getKey());
});
// We define a constructor function for the "data" that this
// Model stores which will allow the JavaScript Engine to create
// "hidden classes" for the purpose of optimization.
Type.Data = Data = function (model, rawData, options) {
const properties = Type.properties;
let allProperties;
let len;
let key;
let i;
allProperties = _allProperties;
len = allProperties.length;
if (rawData == null) {
// No raw data so set every property value to undefined
// as part of our constructor
// We loop over all properties in a consistent order
// for the purpose of JavaScript engine optimization.
// See http://www.html5rocks.com/en/tutorials/speed/v8/
for (i = 0; i < len; i++) {
key = allProperties[i];
this[key] = undefined;
}
} else {
let errors;
if (options) {
if (Array.isArray(options)) {
// since options is an array we treat as the output array
// for errors
options = {
errors: (errors = options)
};
} else {
errors = options.errors;
}
}
const modelData = model.data = _emptyObject();
let additionalData;
if (Type.additionalProperties) {
additionalData = _emptyObject();
}
// use setters to make sure values get properly coerced
for (key in rawData) {
if ((rawData.hasOwnProperty && rawData.hasOwnProperty(key)) || rawData[key] !== undefined) {
const property = properties[key];
if (property) {
_set(model, modelData, property, rawData[key], options);
} else if (additionalData) {
additionalData[key] = rawData[key];
} else if (errors) {
errors.push('Unrecognized property: ' + key);
}
}
}
// We loop over all properties in a consistent order
// for the purpose of JavaScript engine optimization.
// See http://www.html5rocks.com/en/tutorials/speed/v8/
for (i = 0; i < len; i++) {
key = allProperties[i];
this[key] = modelData[key];
}
// If there are additional properties then we add those
// after the known properties. These will propbably
// de-optimize this instance since the order might be
// inconsistent but not much we can do about that.
if (additionalData) {
for (key in additionalData) {
this[key] = additionalData[key];
}
}
}
};
Data.prototype = null;
}
if (prototype) {
Object.keys(prototype).forEach(function (key) {
classPrototype[key] = prototype[key];
});
}
return Type;
}
Model.extend = function (config, resolver) {
return _extend(Model, config, resolver);
};
primitives = require('./primitives');
ArrayType = primitives.array;