var format = require('util').format var assign = require('object-assign'); var cloneDeep = require('lodash.clonedeep'); var Q = require('q'); /** * OKResource! * TODO Would be nicer to compose this with an existing resource * module, but haven't found a good fit. Should keep an eye on * 'resource' by bigcompany for an update. */ function OKResource(options) { if (!(this instanceof OKResource)) return new OKResource(options); options = options || {}; if (!options.type) throw new Error('No resource type provided to OKResource') if (!options.schema) throw new Error('No schema provided to OKResource'); if (!options.db) throw new Error('No DB provided to OKResource'); var schema = options.schema; var spec = schema.spec; var type = options.type; var hasForeignKey = false this._db = options.db; this._schema = schema; var foreignKeys = Object.keys(spec).reduce(function(acc, field) { var fieldSpec = spec[field] if (fieldSpec.type === 'foreign-key') { hasForeignKey = true acc[field] = fieldSpec.key } return acc }, {}) // Will store references to other resources referenced via foreign keys this._foreignKeyedResources = {} // Define properties which are part of the API // Should be treated as read-only Object.defineProperty(this, 'foreignKeys', { get: function() { return foreignKeys } }) Object.defineProperty(this, 'hasForeignKey', { get: function() { return hasForeignKey } }) Object.defineProperty(this, 'spec', { get: function() { return schema.spec; }, enumerable: true }); Object.defineProperty(this, 'type', { value: type, writable: false, enumerable: true }); // Whether this resource represents a specific data point // or a whole class of data Object.defineProperty(this, 'bound', { value: false, writable: false, enumerable: true }); } OKResource.prototype._linkForeignKey = function(field, resource) { this._foreignKeyedResources[field] = resource } /** * Fetch all related resources for the given field */ OKResource.prototype.related = function(field) { var resource = this._foreignKeyedResources[field] return Q.promise(function(resolve, reject) { if (!resource) { return error(reject, new Error(format( "No related resource for field '%s'", field))) } resource.all().then(resolve).fail(reject) }) function error(reject, err) { setTimeout(function() { reject(err) }, 0) } } /** * Throws an error if data does not conform to schema */ OKResource.prototype.assertValid = function(data) { this._schema.assertValid(data); }; OKResource.prototype.all = function() { return this._db.all(this.type); }; OKResource.prototype.getID = function(data) { data = data || {}; return data[this._schema.idField]; }; OKResource.prototype.create = function(data) { var type = this.type; var db = this._db; return Q.promise(function(resolve, reject) { if (!data) { reject(new Error('No data provided')); } else { db.insert(type, data).then(resolve).fail(reject); } }); }; OKResource.prototype.destroy = function(id) { var db = this._db; var type = this.type; return Q.promise(function(resolve, reject) { if (!id) { reject(new Error('No ID provided')); } else { db.remove(type, id).then(resolve).fail(reject); } }); }; OKResource.prototype.find = function(query) { query = query || {}; var db = this._db; var type = this.type; return Q.promise(function(resolve, reject) { if (!query) { throw new Error('No query provided'); } else { db.find(type, query).then(resolve).fail(reject); } }); }; OKResource.prototype.get = function(id) { var db = this._db; var type = this.type; return Q.promise(function(resolve, reject) { if (!id) { throw new Error('No ID provided'); } else { db.get(type, id).then(resolve).fail(reject); } }); }; OKResource.prototype.update = function(id, data) { var db = this._db; var type = this.type; return Q.promise(function(resolve, reject) { if (!id) { reject(new Error('No resource ID provided')); } else if (!data) { reject(new Error('No data provided')); } else { db.update(type, id, data).then(resolve).fail(reject);; } }); }; OKResource.prototype.updateBatch = function(ids, datas) { var self = this; var db = this._db; var type = this.type; return Q.promise(function(resolve, reject) { if (!ids || !ids.length || !datas || !datas.length || ids.length !== datas.length) { reject(new Error('Bad input')); } else { db.updateBatch(type, ids, datas).then(resolve).fail(reject); } }); } /** * Get all documents in collection sorted by property, * optionally in descending order */ OKResource.prototype.sortBy = function(prop, descend) { return this._db.sortBy(this.type, prop, descend); }; OKResource.prototype.updateOrCreate = function(id, data) { data = data || {}; var type = this.type; var db = this._db; return Q.promise(function(resolve, reject) { if (!id) { reject(new Error('No ID provided')); } else { db.get(type, id).then(function(persisted) { if (persisted) { db.update(type, id, data).then(resolve).fail(reject); } else { db.insert(type, data).then(resolve).fail(reject); } }).fail(reject); } }); }; /** * Create special resource which is bound to particular data point * and has custom validation and query properties. */ OKResource.prototype.instance = function(options) { return new OKResourceInstance(this, { static: options.static }); }; /** * TODO This class is such bullshit. Refactor out */ function OKResourceInstance(resource, options) { if (!(this instanceof OKResourceInstance)) return new OKResourceInstance(options); // Only support static data instances for now if (!options.static) throw new Error( 'Cannot create OKResourceInstance without static data'); // Resources with static data are a special case. They exist // conceptually at all times since they are derived from app // configuration, but may not actually be present // in the database and need custom logic to handle this. var staticData = cloneDeep(options.static); var id = resource.getID(staticData); if (!id) throw new Error( 'Cannot create static OKResourceInstance without an ID field'); this.getID = function() { return id; }; /** * Ensure that static data is provided on get */ this.get = function() { return Q.promise(function(resolve, reject) { resource.get(id).then(function(data) { // Note the assign call. Don't expose private references! if (data) { resolve(assign(data, cloneDeep(staticData))); } else { resolve(cloneDeep(staticData)); } }).fail(reject); }); }; this.update = function(id, data) { return Q.promise(function(resolve, reject) { var valid = Object.keys(staticData).every(function(prop) { return staticData[prop] === data[prop]; }); if (!valid) { reject(new Error('Cannot update resource\'s static data')); } else { // When updating static resources, we create them if // they don't actually exist in the DB resource.updateOrCreate(id, data).then(resolve).fail(reject); } }); }; this.updateOrCreate = function(id, data) { return Q.promise(function(resolve, reject) { reject(new Error('Cannot updateOrCreate static resource')); }); }; this.destroy = function(id) { return Q.promise(function(resolve, reject) { reject(new Error('Cannot destroy static resource')); }); }; this.all = function(id) { return Q.promise(function(resolve, reject) { reject(new Error('Cannot get all for static resource')); }); }; this.create = function(id) { return Q.promise(function(resolve, reject) { reject(new Error('Cannot create static resource')); }); }; this.find = function(id) { return Q.promise(function(resolve, reject) { reject(new Error('Cannot perform find on static resource')); }); }; this.assertValid = function(data) { data = data || {}; Object.keys(staticData).forEach(function(prop) { if (staticData[prop] !== data[prop]) { // Validation error is in mschema error format throw [{ property: prop, constraint: 'static', expected: staticData[prop], actual: data[prop], message: 'Data does not match static data' }]; } }); resource.assertValid(data); }; Object.defineProperty(this, 'foreignKeys', { get: function() { return [] } }) Object.defineProperty(this, 'hasForeignKey', { get: function() { return false } }) Object.defineProperty(this, 'parent', { value: resource, writable: false, enumerable: true }); Object.defineProperty(this, 'spec', { get: function() { return resource.spec }, enumerable: true }); Object.defineProperty(this, 'type', { value: resource.type, writable: false, enumerable: true }); Object.defineProperty(this, 'bound', { value: true, writable: false, enumerable: true }); } module.exports = OKResource;