draft
Version:
Construct Object schemas and models
900 lines (763 loc) • 22.4 kB
JavaScript
/**
* Module dependencies
*/
var define = Object.defineProperty
, freeze = Object.freeze
, isFrozen = Object.isFrozen
, isArray = Array.isArray
, toString = Object.prototype.toString
/**
* Exports
*/
module.exports = draft;
draft.Draft = Draft;
draft.Schema = Schema
draft.Tree = Tree;
draft.Type = Type;
draft.Model = Model;
/**
* Merges two or more objects together. Also performs deep merging
*
* @see http://stackoverflow.com/a/383245/1408668
* @api private
* @param {Object} object
* @param {Object} objectN
*/
function merge(obj1, obj2) {
for (var p in obj2) {
try {
// Property in destination object set; update its value.
if ( obj2[p].constructor==Object ) {
obj1[p] = merge(obj1[p], obj2[p]);
} else {
obj1[p] = obj2[p];
}
} catch(e) {
// Property in destination object not set; create it and set its value.
obj1[p] = obj2[p];
}
}
return obj1;
}
/**
* Checks whether the input is a plain object
*
* @api private
* @param {Mixed} input
*/
function isPlainObject (input) {
if (input !== null && typeof input === 'object' && input.constructor === Object) return true;
else return false;
}
/**
* Checks whether the input is a function
*
* @api private
* @param {Mixed} input
*/
function isFunction (input) {
return (typeof input === 'function');
}
/**
* Checks whether the input is a boolean
*
* @api private
* @param {Mixed} input
*/
function isBoolean (input) {
return (typeof input === 'boolean');
}
/**
* Checks whether the input is a undefined
*
* @api private
* @param {Mixed} input
*/
function isUndefined (input) {
return (typeof input === 'undefined');
}
/**
* Checks whether the input is a null
*
* @api private
* @param {Mixed} input
*/
function isNull (input) {
return (input === null);
}
/**
* Checks whether the input is a string
*
* @api private
* @param {Mixed} input
*/
function isString (input) {
return (typeof input === 'string');
}
/**
* Checks whether the input is a NaN
*
* @api private
* @param {Mixed} input
*/
function isTrueNaN (input) {
return (isNaN(input) && input !== NaN && typeof input === 'number');
}
/**
* Converts an array like object to an array
*
* @api private
* @param {Mixed} input
*/
function toArray (input) {
return Array.prototype.slice.call(arguments, 0);
}
/**
* CHecks whether a given input is in an array
*
* @api private
* @param {Array} array
* @param {Mixed} needle
*/
function inArray (array, needle) {
return !!~array.indexOf(needle);
}
/**
* Draft object
*
* @api public
* @constructor Draft
* @param {Object} descriptor
* @param {Object} options
*/
function Draft (descriptor, options) {
this.schema = new Schema(descriptor, options);
this.Model = this.schema.createModel();
}
/**
* @namespace draft
* @api public
* @function draft
* @param {Object} descriptor
* @param {Object} options
*/
function draft (descriptor, options) {
return (new Draft(descriptor, options)).Model;
}
/**
* Creates a schema
*
* @api public
* @function draft.createSchema
* @param {Object} descriptor
* @param {Object} options
*/
draft.createSchema = function (descriptor, options) {
return new Schema(descriptor, options);
}
/**
* Creates a model from a schema
*
* @api public
* @function draft.createModel
* @param {Schema} schema
* @param {Object} options
*/
draft.createModel = function (schema, options) {
if (! (schema instanceof Schema))
throw new TypeError("draft.createModel expects an instance of Schema. Got '"+ typeof schema +"'");
else return schema.createModel(options);
}
/**
* Creats an object schema
*
* @constructor Schema
* @api public
* @param {Object} descriptor
* @param {Object} options
*/
function Schema (descriptor, options) {
var self = this
// we must use plain objects
if (typeof descriptor !== 'undefined' && !isPlainObject(descriptor))
throw new TypeError("Schema only expects an object as a descriptor. Got '"+ typeof descriptor +"'");
// create tree instance with an empty object
this.tree = new Tree({});
// add descriptor to tree
this.add(descriptor);
// attach options
this.options = merge({ strict : true}, isPlainObject(options)? options : {});
}
/**
* Adds an object to the schema tree
*
* @api public
* @function Schema#add
* @see Tree#add
*/
Schema.prototype.add = function () {
this.tree.add.apply(this.tree, arguments);
};
/**
* Creates a static function for the created model
*
* @api public
* @function Schema#.static
* @param {String} name
* @param {Function} func
*/
Schema.prototype.static = function (name, func) {
if (isPlainObject(name)) {
for (func in name) {
this.static(func, name[func]);
}
}
else {
if (!isString(name)) throw new TypeError("Schema#static exepects a string identifier as a function name");
else if (!isFunction(func)) throw new TypeError("Schema#static exepects a function as a handle");
this.add(name, { type: Function, static: true, value: func });
}
};
/**
* Creates a constructor from the defined schema
*
* @api public
* @function Schema#createModel
* @param {Object} options
*/
Schema.prototype.createModel = Schema.prototype.toModel = function (options, proto) {
var self = this
options = (isPlainObject(options))? options : {};
var instances = []
/**
* Private implementation of model
*/
function InstanceModel () {
instances.push(this);
return Model.apply(this, arguments);
}
// set incoming prototype first
if (typeof proto === 'object') {
InstanceModel.prototype = proto;
InstanceModel.prototype.__proto__ = Object.create(Model.prototype);
}
else {
InstanceModel.prototype = Object.create(Model.prototype);
}
// reset constructor
InstanceModel.prototype.constructor = InstanceModel;
// attach schema instance
InstanceModel.prototype.schema = this;
// attach instances
InstanceModel.prototype.instances = instances;
// sugar to not use the 'new' operator
InstanceModel.create = function (data, schema) { return new this(data, schema); }.bind(InstanceModel);
// only scan top level
for (var item in this.tree) {
// prevent overrides
if (!isUndefined(InstanceModel[item])) continue;
// it must be defined and have a valid function value
if (this.tree[item].static === true && !isUndefined(this.tree[item].value)) {
InstanceModel[item] = (isFunction(this.tree[item].value))? this.tree[item].value.bind(InstanceModel) : this.tree[item].value;
}
}
// if the user wants to alloq modifications
if (options.freeze !== false) freeze(InstanceModel);
return InstanceModel
};
/**
* Accepts an object of data and passes it to the
* Model constructor from the Schema instance
*
* @api public
* @function Schema#new
* @param {Object} data
*/
Schema.prototype.new = function (data) {
var model = this.createModel();
return new model(data);
};
/**
* Creates an object tree for a schema.
* This is used for aggregating types
*
* @constructor Tree
* @api public
* @param {Object} descriptor
* @param {Object} options
*/
function Tree (descriptor, options) {
var self = this
// ensure we have an object
if (!isArray(descriptor) && descriptor !== undefined && descriptor !== null && !isPlainObject(descriptor))
throw new TypeError("Tree only expects a descriptor");
else this.add(descriptor);
if (isPlainObject(options) && options.array === true) {
var array = []
array.__proto__ = this;
array.type = new Type(options.type)
return array;
}
}
/**
* Adds a key to the tree on a given parent tree.
* Defaults to 'this' as the parent if one is not provided.
*
* @api public
* @function Tree#add
* @param {Tree} parent
* @param {String} key
* @param {Object} descriptor
*/
define(Tree.prototype, 'add', {
enumerable: false,
value: function (parent, key, descriptor) {
// are they just passing in an object as one big descriptor?
if (typeof parent === 'object' && arguments.length === 1) {
for (var prop in parent) {
this.add(this, prop, parent[prop]);
}
}
else {
parent = (parent instanceof Tree || isString(parent))? parent : this;
// is this a reference to a child tree?
if (parent instanceof Tree) {
if (isPlainObject(descriptor)) {
if (isFunction(descriptor.type)) {
parent[key] = new Type(descriptor.type, descriptor);
}
else {
parent[key] = new Tree(descriptor);
}
}
else if (isFunction(descriptor)) {
parent[key] = new Type(descriptor);
}
else if (isArray(descriptor)) {
if (descriptor.length && isFunction(descriptor[0])) {
parent[key] = new Tree(null, { array: true, type: descriptor[0] });
}
else {
parent[key] = [];
}
}
}
else if (isString(parent) && key) {
descriptor = key
key = parent;
this.add(this, key, descriptor);
}
}
}
});
/**
* Creates a Type used in a Tree instance for a
* Schema instance. It is meant to provide methods
* for validation and coercion.
*
* @constructor Type
* @api public
* @param {Function} Constructor
*/
function Type (Constructor, descriptor) {
// ensure creation of Type
if (!(this instanceof Type)) return new Type(Constructor, descriptor);
// ensure descriptor object
descriptor = (typeof descriptor === 'object')? descriptor : {};
if (!isFunction(Constructor)) throw new TypeError("Type only expects a function");
// set the constructor for reference
this.Constructor = Constructor;
// remove type property from the descriptor if it was set there
delete descriptor.type;
// check for getter
if (isFunction(descriptor.get)) (this.get = descriptor.get) && delete descriptor.get;
// check for setter
if (isFunction(descriptor.set)) (this.set = descriptor.set) && delete descriptor.set;
// check if the values of this property are enumerable
if (isArray(descriptor.enum)) (this.enum = descriptor.enum) && delete descriptor.enum;
// check if strict mode
if (isBoolean(descriptor.strict)) (this.strict = descriptor.strict) && delete descriptor.strict;
// check if static
if (isBoolean(descriptor.static)) (this.static = descriptor.static) && delete descriptor.static;
// check if has set value
if (descriptor.value) (this.value = descriptor.value) && delete descriptor.value;
// check if has validator
if (isFunction(descriptor.validator)) (this.validator = descriptor.validator) && delete descriptor.validator;
// check if has default
if (descriptor.default) {
if (Constructor !== Function && 'function' === typeof descriptor.default) {
this.default = descriptor.default();
} else {
this.default = descriptor.default;
}
delete descriptor.default;
}
}
/**
* Returns a string representation of a Type instance
*/
Type.prototype.toString = function () {
return '[object Type]';
};
/**
* Return original constructor let it handle valueOf
*/
Type.prototype.valueOf = function () {
return this.Constructor.valueOf();
};
/**
* Default getter that coerces a value
*
* @api public
* @function Type#get
* @param {Mixed} value
*/
Type.prototype.get = function (value) {
return this.coerce(value);
};
/**
* Default setter that coerces a value
*
* @api public
* @function Type#set
* @param {Mixed} value
*/
Type.prototype.set = function (value) {
return this.coerce(value);
};
/**
* Validates a defined type.
* It performs instance of checks on values that are not primitive.
* Primitive inputs are validated with a 'typeof' check
*
* @api public
* @function Type#validate
* @param {Mixed} input
*/
Type.prototype.validate = function (input) {
var Constructor
// validate with validator first if present
if (isFunction(this.validator) && !this.validator(input)) return false;
// if not strict mode then we don't need to validate anything
if (this.strict === false) return true;
// if its an object and the type constructor is
// not an object then validate that it is an
// actual instance of the type constructor
if (typeof input === 'object' && this.Constructor !== Object)
return (input instanceof this.Constructor);
// check for enumerated values
if (isArray(this.enum)) {
return inArray(this.enum, input);
}
// check input for primitive types
switch (typeof input) {
case 'string': Constructor = String; break;
case 'function': Constructor = Function; break;
case 'boolean': Constructor = Boolean; break;
case 'object': Constructor = Object; break;
case 'number': Constructor = Number; break;
}
// compare Type Constructor with input Constructor
return this.Constructor === Constructor;
};
/**
* Coerces a given input with the set Constructor type
*
* @api public
* @function Type#coerce
* @param {Mixed} input
*/
Type.prototype.coerce = function (input) {
try { return this.Constructor(input); }
catch (e) { return input; }
};
/**
* Base constructor for all created Model instances
*
* @constructor Model
* @api public
* @param {Object} data
*/
function Model (data, schema) {
if (! (this instanceof Model)) return new Model(data, schema);
define(this, 'schema', {
enumerable : false,
writable : false,
configurable : false,
value: (schema instanceof Schema)? schema : this.schema
});
var self = this
/** internal memory **/
var table = {};
// ensure an object if not undefined
if (data !== undefined && typeof data !== 'object') throw new TypeError("Model expects an object. Got '"+ typeof data +"'");
// ensure the schema set
if (!this.schema || !(this.schema instanceof Schema)) throw new TypeError(".schema hasn't been set");
var build = function (data, tree, object) {
tree = (tree instanceof Tree)? tree : self.schema.tree;
object = (typeof object === 'object')? object : this;
for (var prop in data) {
// encapsulate each iteration in a scope
!function (prop) {
// if not in tree, return and continue on
if (!tree[prop]) return;
// if the property is an object, check if the tree property
// is a Tree instance object too
if (typeof data[prop] === 'object' && tree[prop] instanceof Tree) {
// define setter for object
define(data[prop], 'set', {
writable : false,
enumerable : false,
configurable : false,
value : function (value) {
object[prop] = value;
}
});
// define getter for object
define(data[prop], 'get', {
writable : false,
enumerable : false,
configurable : false,
value : function () { return object[prop] }
});
build(data[prop], tree[prop], object[prop]);
}
// we've reached some kind of scalar value
// that exists in the schema tree and the object
else {
object[prop] = data[prop];
}
}.call(this, prop);
}
}.bind(this);
// overload refresh method on prototype
var refresh = function () {
if (isFrozen(this)) return false;
var defineFromTree = function (tree, scope, table) {
var item
for (item in tree) {
!function (item) {
if (tree[item] === null || typeof tree[item] !== 'object') return;
// we don't want this as a possible field
if (tree[item].static) return
// it must be an instance of Type
if (tree[item] instanceof Type) {
// only set on plain objects
if (!isArray(scope)) {
// if it doesn't exist in the internal table
// then set it to undefined
table[item] = table[item] || undefined;
// create descriptor for property item on scope
// from tree descriptor
define(scope, item, {
configurable : false,
enumerable : true,
get : function () {
return table[item]? tree[item].get(table[item]) : undefined;
},
set : function (value) {
if (isFunction(tree[item].validate) && tree[item].validate(value)) {
table[item] = tree[item].set(value);
return table[item];
} else {
return false;
}
}
});
}
if (!isUndefined(tree[item].default)) {
scope[item] = tree[item].default;
}
}
// if it is a tree instance then we need
// to do a recursive call to define the
// descriptors needed for the object
else if (tree[item] instanceof Tree || isArray(tree[item])) {
table[item] = isArray(tree[item])? [] : {};
if (isArray(tree[item])) {
define(scope, item, {
configurable: false,
enumerable : true,
writable: false,
value: table[item]
});
}
else {
define(scope, item, {
configurable: false,
enumerable : true,
writable : false,
value : {}
});
}
define(scope[item], 'get', {
configurable: false,
enumerable: false,
writable: false,
value: function (key) { return table[item][key]; }
});
define(scope[item], 'set', {
configurable: false,
enumerable: false,
writable: false,
value: function (value) {
table[item][key] = value;
return table[item];
}
});
// overload array methods
if (isArray(tree[item])) {
[
'concat', 'every', 'filter', 'forEach', 'indexOf', 'join', 'lastIndexOf', 'map', 'pop',
'push', 'reduce', 'reduceRight', 'reverse', 'shift', 'slice', 'some', 'sort', 'splice',
'toString', 'unshift', 'valueOf'
].map(function (method) {
if (isFunction([][method])) {
define(scope[item], method, {
configurable: false,
enumerable: false,
writable: false,
value: function (value) {
if (value !== undefined) {
if (tree[item].type instanceof Type && isFunction(tree[item].type.validate)) {
if (tree[item].type.validate(value)) {
return [][method].apply(table[item], arguments)
}
else {
return false;
}
}
else {
return [][method].apply(table[item], arguments)
}
}
else {
return [][method].apply(table[item], arguments);
}
}.bind(null)
});
}
});
}
// recursive call to define descriptors
defineFromTree.call(self, tree[item], scope[item], table[item]);
}
}.call(self, item);
}
// if (this.schema.options.strict && scope !== this)
// freeze(scope)
}.bind(this);
// define
defineFromTree(self.schema.tree, this, table);
// free if in strict mode
if (this.schema.options.strict) freeze(this);
};
// overload set method on prototype
define(this, 'set', {
configurable : false,
enumerable : false,
writable : true,
value : function (key, value) {
return this[key] = value;
}
});
define(this, 'get', {
configurable : false,
enumerable : false,
writable : false,
value : function (key) {
return table[key]
}
});
define(this, 'refresh', {
configurable : false,
enumerable : false,
writable : false,
value : refresh
});
define(this, 'toObject', {
configurable: false,
enumerable : false,
writable : false,
value : function () {
return JSON.parse(JSON.stringify(table));
}
});
define(this, 'toJSON', {
configurable: false,
enumerable : false,
writable : false,
value : function () {
return JSON.parse(JSON.stringify(table));
}
});
// call a refresh to init schema
this.refresh();
// set data
data && build(data);
// handle incoming arrays
if ('object' == typeof data) {
for (var prop in data) {
if (isArray(data[prop]) && isArray(this[prop])) {
data[prop].forEach(function (v) {
this.push(v);
}, this[prop]);
}
}
}
}
/**
* A reference to the schema instance for the model
*
* @api public
* @property {Schema} schema
*/
Model.prototype.schema;
/**
* Refreshes the state of the model based on its schema
*
* @api public
* @function Model#refresh
* @interface
*/
Model.prototype.refresh = function () {};
/**
* Sets data on the model based on the schema
*
* @api public
* @function Model#set
* @interface
*/
Model.prototype.set = function () {};
/**
* Returns a plain object representation of the model
*
* @api public
* @function Model#toObject
* @interface
*/
Model.prototype.toObject = function () {};
/**
* Called with JSON.stringify
*
* @api public
* @function Model#toJSON
* @interface
*/
Model.prototype.toJSON = function () {};
/**
* Returns a string representation of a Model instance
*
* @api public
* @function Model#toString
* @interface
*/
Model.prototype.toString = function () { return '[object Model]'; };
/**
* Returns a value representation of a Model instance
*
* @api public
* @function Model#valueOf
* @interface
*/
Model.prototype.valueOf = function () { return this.toObject(); };