From 9c505de6f137dd08c0fc29e7878671261245d0a4 Mon Sep 17 00:00:00 2001 From: Benjamin Pannell Date: Tue, 21 Apr 2015 19:18:19 +0200 Subject: [PATCH] Fixed some plugin stuff and the UserModel example --- example/UserModel.ts | 8 +++++--- lib/Model.ts | 2 +- lib/Plugins.ts | 2 +- 3 files changed, 7 insertions(+), 5 deletions(-) diff --git a/example/UserModel.ts b/example/UserModel.ts index c7f3f57..980fbc8 100644 --- a/example/UserModel.ts +++ b/example/UserModel.ts @@ -5,6 +5,8 @@ import Iridium = require('../index'); import Concoction = require('concoction'); import Promise = require('bluebird'); +var settings: any = {}; + export interface UserDocument { username: string; fullname: string; @@ -101,7 +103,7 @@ export class User extends Iridium.Instance implements UserDo var passwordTest = /(?=^.{8,}$)((?=.*\d)(?=.*[A-Z])(?=.*[a-z])|(?=.*\d)(?=.*[^A-Za-z0-9])(?=.*[a-z])|(?=.*[^A-Za-z0-9])(?=.*[A-Z])(?=.*[a-z])|(?=.*\d)(?=.*[A-Z])(?=.*[^A-Za-z0-9]))^.*/; if (!passwordTest.test(newPassword || '')) return callback(new Error('Password didn\'t meet the minimum safe password requirements. Passwords should be at least 8 characters long, and contain at least 3 of the following categories: lowercase letters, uppercase letters, numbers, characters')); - var hashed = require('crypto').createHash('sha512').update(core.settings.security.salt).update(newPassword).digest('hex'); + var hashed = require('crypto').createHash('sha512').update(settings.security.salt).update(newPassword).digest('hex'); this.password = hashed; this.save(callback); } @@ -110,7 +112,7 @@ export class User extends Iridium.Instance implements UserDo /// The password to validate against the user's password hash. /// - var hashed = require('crypto').createHash('sha512').update(core.settings.security.salt).update(password).digest('hex'); + var hashed = require('crypto').createHash('sha512').update(settings.security.salt).update(password).digest('hex'); return hashed == this.password; } addFriend(friend: string, callback: (err?: Error, user?: User) => void) { @@ -181,7 +183,7 @@ export function Users(core: Iridium.Core): Iridium.Model { if (!passwordTest.test(item.password || '')) return Promise.reject(new Error('Password didn\'t meet the minimum safe password requirements. Passwords should be at least 8 characters long, and contain at least 3 of the following categories: lowercase letters, uppercase letters, numbers, characters')); - item.password = require('crypto').createHash('sha512').update(core.settings.security.salt).update(item.password).digest('hex'); + item.password = require('crypto').createHash('sha512').update(settings.security.salt).update(item.password).digest('hex'); _.defaults(item, { type: "Player", diff --git a/lib/Model.ts b/lib/Model.ts index 471b8fa..9d0d0be 100644 --- a/lib/Model.ts +++ b/lib/Model.ts @@ -1 +1 @@ -/// /// /// /// /// /// /// import MongoDB = require('mongodb'); import Skmatc = require('skmatc'); import Concoction = require('concoction'); import Promise = require('bluebird'); import util = require('util'); import Iridium = require('./Core'); import instance = require('./Instance'); import ISchema = require('./Schema'); import hooks = require('./Hooks'); import IPlugin = require('./Plugins'); import cache = require('./Cache'); import cacheDirector = require('./CacheDirector'); import general = require('./General'); import noOpCache = require('./caches/NoOpCache'); import memoryCache = require('./caches/MemoryCache'); import idCacheController = require('./cacheControllers/IDDirector'); import Omnom = require('./utils/Omnom'); /** * An Iridium Model which represents a structured MongoDB collection * @class */ export class Model implements IModel { /** * Creates a new Iridium model representing a given ISchema and backed by a collection whose name is specified * @param {Iridium} core The Iridium core that this model should use for database access * @param {String} collection The name of the collection within the database which should be used by this model * @param {schema} schema The schema defining the data validations to be performed on the model * @param {IModelOptions} options The options dictating the behaviour of the model * @returns {Model} * @constructor */ constructor(core: Iridium, instanceType, collection: string, schema: ISchema, options: IModelOptions = {}) { // Allow instantiation doing `require('iridium').Model(db, 'collection', {})` if (!(this instanceof Model)) return new Model(core, instanceType, collection, schema, options); options = options || {}; _.defaults(options, >{ hooks: {}, transforms: [ Concoction.Rename({ _id: 'id' }), Concoction.Convert({ id: { apply: function (value) { return (value && value.id) ? new MongoDB.ObjectID(value.id).toHexString() : value; }, reverse: function (value) { if (value === null || value === undefined) return undefined; if (value && /^[a-f0-9]{24}$/.test(value)) return MongoDB.ObjectID.createFromHexString(value); return value; } } }) ], cache: new idCacheController() }); this._core = core; this._collection = collection; this._schema = schema; this._options = options; core.plugins.forEach(function (plugin: IPlugin) { if (plugin.newModel) plugin.newModel(this); }); this._cache = options.cache; this._Instance = new ModelSpecificInstance(this, instanceType); this._helpers = new ModelHelpers(this); this._handlers = new ModelHandlers(this); } private _options: IModelOptions; /** * Gets the options provided when instantiating this model * @public * @returns {IModelOptions} * @description * This is intended to be consumed by plugins which require any configuration * options. Changes made to this object after the {plugin.newModel} hook are * called will not have any effect on this model. */ get options(): IModelOptions { return this._options; } private _helpers: ModelHelpers; /** * Provides helper methods used by Iridium for common tasks * @returns {ModelHelpers} */ get helpers(): ModelHelpers { return this._helpers; } private _handlers: ModelHandlers; /** * Provides helper methods used by Iridium for hook delegation and common processes * @returns {ModelHandlers} */ get handlers(): ModelHandlers { return this._handlers; } private _schema: ISchema; /** * Gets the ISchema dictating the data structure represented by this model * @public * @returns {schema} */ get schema(): ISchema { return this._schema; } private _core: Iridium; /** * Gets the Iridium core that this model is associated with * @public * @returns {Iridium} */ get core(): Iridium { return this._core; } private _collection: string; /** * Gets the underlying MongoDB collection from which this model's documents are retrieved * @public * @returns {Collection} */ get collection(): MongoDB.Collection { return this.core.connection.collection(this._collection); } private _cache: cacheDirector; /** * Gets the cache controller which dictates which queries will be cached, and under which key * @public * @returns {cacheDirector} */ get cache(): cacheDirector { return this._cache; } private _Instance: ModelSpecificInstance; /** * Gets the constructor used to create instances for this model * @public * @returns {function(Object): Instance} * @constructor */ Instance(document: TDocument, isNew: boolean = true, isPartial: boolean = false): TInstance { return this._Instance.build(document, isNew, isPartial); } /** * Retrieves all documents in the collection and wraps them as instances * @param {function(Error, TInstance[])} callback An optional callback which will be triggered when results are available * @returns {Promise} */ find(callback?: general.Callback): Promise; /** * Returns all documents in the collection which match the conditions and wraps them as instances * @param {Object} conditions The MongoDB query dictating which documents to return * @param {function(Error, TInstance[])} callback An optional callback which will be triggered when results are available * @returns {Promise} */ find(conditions: any, callback?: general.Callback): Promise; /** * Returns all documents in the collection which match the conditions * @param {Object} conditions The MongoDB query dictating which documents to return * @param {QueryOptions} options The options dictating how this function behaves * @param {function(Error, TInstance[])} callback An optional callback which will be triggered when results are available * @returns {Promise} */ find(conditions: any, options: QueryOptions, callback?: general.Callback): Promise; find(conditions?: any, options?: QueryOptions, callback?: general.Callback): Promise { if (typeof options == 'function') { callback = >options; options = {}; } if (typeof conditions == 'function') { callback = >conditions; conditions = {}; options = {}; } conditions = conditions || {}; options = options || {}; _.defaults(options, { }); var $this = this; return Promise.resolve().then(function () { if (options.fields) $this.helpers.transform.reverse(options.fields); if (!_.isPlainObject(conditions)) conditions = $this.helpers.selectOneDownstream(conditions); $this.helpers.transform.reverse(conditions); var cursor = $this.collection.find(conditions, { limit: options.limit, sort: options.sort, skip: options.skip, fields: options.fields }); return Promise.promisify(function (callback) { cursor.toArray(callback); })(); }).then(function (results: TDocument[]) { if (!results || !results.length) return Promise.resolve([]); return $this.handlers.documentsReceived(conditions, results, $this.helpers.wrapDocument, options); }).nodeify(callback); } /** * Retrieves a single document from the collection and wraps it as an instance * @param {function(Error, TInstance)} callback An optional callback which will be triggered when a result is available * @returns {Promise} */ get(callback?: general.Callback): Promise; /** * Retrieves a single document from the collection with the given ID and wraps it as an instance * @param {any} id The document's unique _id field value in downstream format * @param {function(Error, TInstance)} callback An optional callback which will be triggered when a result is available * @returns {Promise} */ get(id: any, callback?: general.Callback): Promise; /** * Retrieves a single document from the collection which matches the conditions * @param {Object} conditions The MongoDB query dictating which document to return * @param {function(Error, TInstance)} callback An optional callback which will be triggered when a result is available * @returns {Promise} */ get(conditions: { [key: string]: any }, callback?: general.Callback): Promise; /** * Retrieves a single document from the collection with the given ID and wraps it as an instance * @param {any} id The document's unique _id field value in downstream format * @param {QueryOptions} options The options dictating how this function behaves * @param {function(Error, TInstance)} callback An optional callback which will be triggered when a result is available * @returns {Promise} */ get(id: any, options: QueryOptions, callback?: general.Callback): Promise; /** * Retrieves a single document from the collection which matches the conditions * @param {Object} conditions The MongoDB query dictating which document to return * @param {QueryOptions} options The options dictating how this function behaves * @param {function(Error, TInstance)} callback An optional callback which will be triggered when a result is available * @returns {Promise} */ get(conditions: { [key: string]: any }, options: QueryOptions, callback?: general.Callback): Promise; get(...args: any[]): Promise { return this.get.apply(this, args); } /** * Retrieves a single document from the collection and wraps it as an instance * @param {function(Error, TInstance)} callback An optional callback which will be triggered when a result is available * @returns {Promise} */ findOne(callback?: general.Callback): Promise; /** * Retrieves a single document from the collection with the given ID and wraps it as an instance * @param {any} id The document's unique _id field value in downstream format * @param {function(Error, TInstance)} callback An optional callback which will be triggered when a result is available * @returns {Promise} */ findOne(id: any, callback?: general.Callback): Promise; /** * Retrieves a single document from the collection which matches the conditions * @param {Object} conditions The MongoDB query dictating which document to return * @param {function(Error, TInstance)} callback An optional callback which will be triggered when a result is available * @returns {Promise} */ findOne(conditions: { [key: string]: any }, callback?: general.Callback): Promise; /** * Retrieves a single document from the collection with the given ID and wraps it as an instance * @param {any} id The document's unique _id field value in downstream format * @param {QueryOptions} options The options dictating how this function behaves * @param {function(Error, TInstance)} callback An optional callback which will be triggered when a result is available * @returns {Promise} */ findOne(id: any, options: QueryOptions, callback?: general.Callback): Promise; /** * Retrieves a single document from the collection which matches the conditions * @param {Object} conditions The MongoDB query dictating which document to return * @param {QueryOptions} options The options dictating how this function behaves * @param {function(Error, TInstance)} callback An optional callback which will be triggered when a result is available * @returns {Promise} */ findOne(conditions: { [key: string]: any }, options: QueryOptions, callback?: general.Callback): Promise; findOne(...args: any[]): Promise { var conditions: { [key: string]: any } = null; var options: QueryOptions = null; var callback: general.Callback = null; for (var argI = 0; argI < args.length; argI++) { if (typeof args[argI] == 'function') callback = callback || args[argI]; else if (_.isPlainObject(args[argI])) { if (conditions) options = args[argI]; else conditions = args[argI]; } else conditions = this.helpers.selectOneDownstream(args[argI]); } _.defaults(options, { wrap: true, cache: true }); var $this = this; return Promise.resolve().bind(this).then(function () { $this.helpers.transform.reverse(conditions); if (options.fields) $this.helpers.transform.reverse(options.fields); if (options.cache && $this.cache && $this.core.cache && $this.cache.valid(conditions)) return $this.core.cache.get($this.cache.buildKey(conditions)); return null; }).then(function (cachedDocument: TDocument) { if (cachedDocument) return cachedDocument; return null; }).then(function (document: TDocument) { if (!document) return null; return $this.handlers.documentsReceived(conditions, [document], $this.helpers.wrapDocument, options).then(function (documents) { return documents[0]; }); }).nodeify(callback); } create(objects: TDocument, callback?: general.Callback): Promise; create(objects: TDocument, options: CreateOptions, callback?: general.Callback): Promise; create(objects: TDocument[], callback?: general.Callback): Promise; create(objects: TDocument[], options: CreateOptions, callback?: general.Callback): Promise; create(...args: any[]): Promise { return this.insert.apply(this, args); } insert(objects: TDocument, callback?: general.Callback): Promise; insert(objects: TDocument, options: CreateOptions, callback?: general.Callback): Promise; insert(objects: TDocument[], callback?: general.Callback): Promise; insert(objects: TDocument[], options: CreateOptions, callback?: general.Callback): Promise; insert(objs: TDocument | TDocument[], ...args: any[]): Promise { var objects: TDocument[]; var options: CreateOptions = {}; var callback: general.Callback = null; if (typeof args[0] == 'function') callback = args[0]; else { options = args[0]; callback = args[1]; } var returnArray: boolean = false; if (Array.isArray(objs)) returnArray = true; else objects = [objs]; _.defaults(options, { wrap: true, w: 1 }); var $this = this; return Promise.resolve().then(function () { var queryOptions = { w: options.w, upsert: options.upsert, new: true }; if (options.upsert) return $this.handlers.creatingDocuments(objects).map(function (object: { _id: any; }) { return new Promise(function (resolve, reject) { $this.collection.findAndModify({ _id: object._id }, ["_id"], object, queryOptions, function (err, result) { if (err) return reject(err); return resolve(result); }); }); }); else return $this.handlers.creatingDocuments(objects).then(function (objects) { return new Promise(function (resolve, reject) { $this.collection.insert(objects, queryOptions, function (err, results) { if (err) return reject(err); return resolve(results); }); }); }); }).then(function (inserted: any[]) { return $this.handlers.documentsReceived(null, inserted, $this.helpers.wrapDocument, { cache: options.cache }); }).then(function (results: TInstance[]) { if (objs instanceof Array) return results; return results[0]; }).nodeify(callback); } update(conditions: any, changes: any, callback?: general.Callback): Promise; update(conditions: any, changes: any, options: UpdateOptions, callback?: general.Callback): Promise; update(conditions: any, changes: any, options?: UpdateOptions, callback?: general.Callback): Promise { if (typeof options == 'function') { callback = >options; options = {}; } _.defaults(options, { w: 1, multi: true }); this.helpers.transform.reverse(conditions); var $this = this; return new Promise(function (resolve, reject) { $this.collection.update(conditions, changes, options, function (err, changes) { if (err) return reject(err); return resolve(changes); }); }).nodeify(callback); } count(callback?: general.Callback): Promise; count(conditions: any, callback?: general.Callback): Promise; count(conditions?: any, callback?: general.Callback): Promise { if (typeof conditions == 'function') { callback = >conditions; conditions = {}; } var $this = this; return new Promise(function (resolve, reject) { $this.collection.count(conditions, function (err, results) { if (err) return reject(err); return resolve(results); }); }).nodeify(callback); } remove(callback?: general.Callback): Promise; remove(conditions: any, callback?: general.Callback): Promise; remove(conditions?: any, callback?: general.Callback): Promise { if (typeof conditions == 'function') { callback = >conditions; conditions = {}; } var $this = this; return new Promise(function (resolve, reject) { $this.collection.remove(conditions, function (err, results) { if (err) return reject(err); return resolve(results); }); }).then(function (count) { if ($this.cache && $this.core.cache && $this.cache.valid(conditions)) return $this.core.cache.clear($this.cache.buildKey(conditions)).then(function () { return count; }); return Promise.resolve(count); }).nodeify(callback); } ensureIndex(specification: IndexSpecification, callback?: general.Callback): Promise; ensureIndex(specification: IndexSpecification, options: MongoDB.IndexOptions, callback?: general.Callback): Promise; ensureIndex(specification: IndexSpecification, options?: MongoDB.IndexOptions, callback?: general.Callback): Promise { if (typeof options == 'function') { callback = >options; options = {}; } var $this = this; return new Promise(function (resolve, reject) { $this.collection.ensureIndex(specification, options, function (err, name: any) { if (err) return reject(err); return resolve(name); }); }).nodeify(callback); } ensureIndices(callback?: general.Callback): Promise { var $this = this; return Promise.resolve(this.options.indices).map(function (index: Index | IndexSpecification) { return $this.ensureIndex((index).spec || index,(index).options || {}); }).nodeify(callback); } } export interface IModelBase { collection: MongoDB.Collection; core: Iridium; schema: ISchema; cache: cacheDirector; } export interface IModelFactory { (core: Iridium): IModel; } export interface IModelOptions { hooks?: hooks.IHooks; validators?: SkmatcCore.IValidator[]; transforms?: Concoction.Ingredient[]; cache?: cacheDirector; indices?: (Index | IndexSpecification)[]; properties?: { [key: string]: (general.PropertyGetter | general.Property) }; } export interface IModel extends IModelBase { Instance: (doc: TDocument, isNew?: boolean, isPartial?: boolean) => TInstance; } export class ModelSpecificInstance { constructor(model: Model, instanceConstructor) { this.Constructor = function (document: TDocument, isNew?: boolean, isPartial?: boolean) { instanceConstructor.call(this, model, document, isNew, isPartial); }; _.each(model.schema, function (property, key) { Object.defineProperty(this.Constructor.prototype, key, { get: function () { return this._modified[key]; }, set: function (value) { this._modified[key] = value; }, enumerable: true }); }, this); } private Constructor: (document: TDocument, isNew?: boolean, isPartial?: boolean) => void; build(document: TDocument, isNew?: boolean, isPartial?: boolean): TInstance { return new this.Constructor(document, isNew, isPartial); } } export class ModelHelpers { constructor(model: Model) { this._model = model; this._validator = new Skmatc(model.schema); this._transform = new concoction(model.options.transforms); } private _model: Model; private _transform: concoction; /** * Gets the Concoction transforms defined for this model * @returns {Concoction} */ get transform(): Concoction { return this._transform; } private _validator: Skmatc; /** * Validates a document to ensure that it matches the model's ISchema requirements * @param {any} document The document to validate against the ISchema * @returns {SkmatcCore.IResult} The result of the validation */ validate(document: TDocument): SkmatcCore.IResult { return this._validator.validate(document); } /** * Creates a selector based on the document's unique _id field * @param {object} document The document to render the unique selector for * @returns {{_id: any}} A database selector which can be used to return only this document */ selectOne(document: TDocument): { _id: any } { var testDoc: any = _.cloneDeep(document); this.transform.reverse(testDoc); return { _id: testDoc._id }; } /** * Gets the field used in the ISchema to represent the document _id */ get identifierField(): string { var id = new String(""); var testDoc = { _id: id }; this.transform.apply(testDoc); var idField = null; for (var k in testDoc) if (testDoc[k] === id) { idField = k; break; } return idField; } /** * Creates a selector based on the document's unique _id field in downstream format * @param {any} id The downstream identifier to use when creating the selector * @returns {object} A database selector which can be used to return only this document in downstream form */ selectOneDownstream(id: TDocument): any { var conditions = {}; conditions[this.identifierField] = id; return conditions; } /** * Wraps the given document in an instance wrapper for use throughout the application * @param {any} document The document to be wrapped as an instance * @param {Boolean} isNew Whether the instance originated from the database or was created by the application * @param {Boolean} isPartial Whether the document supplied contains all information present in the database * @returns {any} An instance which wraps this document */ wrapDocument(document: TDocument, isNew?: boolean, isPartial?: boolean): TInstance { return this._model.Instance(document, isNew, isPartial); } /** * Performs a diff operation between two documents and creates a MongoDB changes object to represent the differences * @param {any} original The original document prior to changes being made * @param {any} modified The document after changes were made */ diff(original: TDocument, modified: TDocument): any { var omnom = new Omnom(); omnom.diff(original, modified); return omnom.changes; } } export class ModelHandlers { constructor(model: Model) { this._model = model; } private _model: Model; get model(): Model { return this._model; } documentsReceived(conditions: any, results: TDocument[], wrapper: (document: TDocument, isNew?: boolean, isPartial?: boolean) => TResult, options: QueryOptions = {}): Promise { _.defaults(options, { cache: true, partial: false }); return Promise.resolve(results).map(function (target: any) { return >Promise.resolve().then(function () { // Trigger the received hook if (this.model.hooks.retrieved) return this.model.hooks.retrieved(target); }).then(function () { // Cache the document if caching is enabled if (this.model.core.cache && options.cache && !options.fields) { var cacheDoc = _.cloneDeep(target); return this.cache.store(conditions, cacheDoc); } }).then(function () { // Wrap the document and trigger the ready hook var wrapped: TResult = wrapper(target, false, !!options.fields); if (this.model.hooks.ready) return Promise.resolve(this.model.hooks.ready(wrapped)).then(() => wrapped); return wrapped; }); }); } creatingDocuments(documents: TDocument[]): Promise { return Promise.resolve(documents).map(function (document: any) { return Promise.resolve().then(function () { if (this.model.hooks.retrieved) return this.model.hooks.creating(document); }).then(function () { var validation: SkmatcCore.IResult = this.model.helpers.validate(document); if (validation.failed) return Promise.reject(validation.error); this.model.helpers.transform.reverse(document); return document; }); }) } savingDocument(instance: TInstance, changes: any): Promise { return Promise.resolve().then(function () { if (this.model.hooks.saving) return this.model.hooks.saving(instance, changes); }).then(() => instance); } } export interface QueryOptions { cache?: boolean; fields?: any; limit?: number; skip?: number; sort?: IndexSpecification; } export interface CreateOptions { w?: any; upsert?: boolean; cache?: boolean; } export interface UpdateOptions { w?: any; multi?: boolean; } export interface IndexSpecification { [key: string]: number; } export interface Index { spec: IndexSpecification; options?: MongoDB.IndexOptions; } class ModelCache { constructor(model: IModelBase) { this._model = model; } private _model: IModelBase; get model(): IModelBase { return this._model; } set(conditions: any, value: T): Promise { if (!this.model.cache.valid(conditions)) return Promise.resolve(value); return this.model.core.cache.set(this.model.cache.buildKey(conditions), value); } get(conditions: any): Promise { if (!this.model.cache.valid(conditions)) return Promise.resolve(null); return this.model.core.cache.get(this.model.cache.buildKey(conditions)); } clear(conditions: any): Promise { if (!this.model.cache.valid(conditions)) return Promise.resolve(false); return this.model.core.cache.clear(this.model.cache.buildKey(conditions)); } } +import MongoDB = require('mongodb'); import Skmatc = require('skmatc'); import Concoction = require('concoction'); import Promise = require('bluebird'); import util = require('util'); import Iridium = require('./Core'); import instance = require('./Instance'); import ISchema = require('./Schema'); import hooks = require('./Hooks'); import IPlugin = require('./Plugins'); import cache = require('./Cache'); import cacheDirector = require('./CacheDirector'); import general = require('./General'); import noOpCache = require('./caches/NoOpCache'); import memoryCache = require('./caches/MemoryCache'); import idCacheController = require('./cacheControllers/IDDirector'); import Omnom = require('./utils/Omnom'); /** * An Iridium Model which represents a structured MongoDB collection * @class */ export class Model implements IModel { /** * Creates a new Iridium model representing a given ISchema and backed by a collection whose name is specified * @param {Iridium} core The Iridium core that this model should use for database access * @param {String} collection The name of the collection within the database which should be used by this model * @param {schema} schema The schema defining the data validations to be performed on the model * @param {IModelOptions} options The options dictating the behaviour of the model * @returns {Model} * @constructor */ constructor(core: Iridium, instanceType, collection: string, schema: ISchema, options: IModelOptions = {}) { // Allow instantiation doing `require('iridium').Model(db, 'collection', {})` if (!(this instanceof Model)) return new Model(core, instanceType, collection, schema, options); options = options || {}; _.defaults(options, >{ hooks: {}, transforms: [ Concoction.Rename({ _id: 'id' }), Concoction.Convert({ id: { apply: function (value) { return (value && value.id) ? new MongoDB.ObjectID(value.id).toHexString() : value; }, reverse: function (value) { if (value === null || value === undefined) return undefined; if (value && /^[a-f0-9]{24}$/.test(value)) return MongoDB.ObjectID.createFromHexString(value); return value; } } }) ], cache: new idCacheController() }); this._core = core; this._collection = collection; this._schema = schema; this._options = options; core.plugins.forEach(function (plugin: IPlugin) { if (plugin.newModel) plugin.newModel(this); }); this._cache = options.cache; this._Instance = new ModelSpecificInstance(this, instanceType); this._helpers = new ModelHelpers(this); this._handlers = new ModelHandlers(this); } private _options: IModelOptions; /** * Gets the options provided when instantiating this model * @public * @returns {IModelOptions} * @description * This is intended to be consumed by plugins which require any configuration * options. Changes made to this object after the {plugin.newModel} hook are * called will not have any effect on this model. */ get options(): IModelOptions { return this._options; } private _helpers: ModelHelpers; /** * Provides helper methods used by Iridium for common tasks * @returns {ModelHelpers} */ get helpers(): ModelHelpers { return this._helpers; } private _handlers: ModelHandlers; /** * Provides helper methods used by Iridium for hook delegation and common processes * @returns {ModelHandlers} */ get handlers(): ModelHandlers { return this._handlers; } private _schema: ISchema; /** * Gets the ISchema dictating the data structure represented by this model * @public * @returns {schema} */ get schema(): ISchema { return this._schema; } private _core: Iridium; /** * Gets the Iridium core that this model is associated with * @public * @returns {Iridium} */ get core(): Iridium { return this._core; } private _collection: string; /** * Gets the underlying MongoDB collection from which this model's documents are retrieved * @public * @returns {Collection} */ get collection(): MongoDB.Collection { return this.core.connection.collection(this._collection); } /** * Gets the name of the underlying MongoDB collection from which this model's documents are retrieved * @public */ get collectionName(): string { return this._collection; } /** * Sets the name of the underlying MongoDB collection from which this model's documents are retrieved * @public */ set collectionName(value: string) { this._collection = value; } private _cache: cacheDirector; /** * Gets the cache controller which dictates which queries will be cached, and under which key * @public * @returns {cacheDirector} */ get cache(): cacheDirector { return this._cache; } private _Instance: ModelSpecificInstance; /** * Gets the constructor used to create instances for this model * @public * @returns {function(Object): Instance} * @constructor */ Instance(document: TDocument, isNew: boolean = true, isPartial: boolean = false): TInstance { return this._Instance.build(document, isNew, isPartial); } /** * Retrieves all documents in the collection and wraps them as instances * @param {function(Error, TInstance[])} callback An optional callback which will be triggered when results are available * @returns {Promise} */ find(callback?: general.Callback): Promise; /** * Returns all documents in the collection which match the conditions and wraps them as instances * @param {Object} conditions The MongoDB query dictating which documents to return * @param {function(Error, TInstance[])} callback An optional callback which will be triggered when results are available * @returns {Promise} */ find(conditions: any, callback?: general.Callback): Promise; /** * Returns all documents in the collection which match the conditions * @param {Object} conditions The MongoDB query dictating which documents to return * @param {QueryOptions} options The options dictating how this function behaves * @param {function(Error, TInstance[])} callback An optional callback which will be triggered when results are available * @returns {Promise} */ find(conditions: any, options: QueryOptions, callback?: general.Callback): Promise; find(conditions?: any, options?: QueryOptions, callback?: general.Callback): Promise { if (typeof options == 'function') { callback = >options; options = {}; } if (typeof conditions == 'function') { callback = >conditions; conditions = {}; options = {}; } conditions = conditions || {}; options = options || {}; _.defaults(options, { }); var $this = this; return Promise.resolve().then(function () { if (options.fields) $this.helpers.transform.reverse(options.fields); if (!_.isPlainObject(conditions)) conditions = $this.helpers.selectOneDownstream(conditions); $this.helpers.transform.reverse(conditions); var cursor = $this.collection.find(conditions, { limit: options.limit, sort: options.sort, skip: options.skip, fields: options.fields }); return Promise.promisify(function (callback) { cursor.toArray(callback); })(); }).then(function (results: TDocument[]) { if (!results || !results.length) return Promise.resolve([]); return $this.handlers.documentsReceived(conditions, results, $this.helpers.wrapDocument, options); }).nodeify(callback); } /** * Retrieves a single document from the collection and wraps it as an instance * @param {function(Error, TInstance)} callback An optional callback which will be triggered when a result is available * @returns {Promise} */ get(callback?: general.Callback): Promise; /** * Retrieves a single document from the collection with the given ID and wraps it as an instance * @param {any} id The document's unique _id field value in downstream format * @param {function(Error, TInstance)} callback An optional callback which will be triggered when a result is available * @returns {Promise} */ get(id: any, callback?: general.Callback): Promise; /** * Retrieves a single document from the collection which matches the conditions * @param {Object} conditions The MongoDB query dictating which document to return * @param {function(Error, TInstance)} callback An optional callback which will be triggered when a result is available * @returns {Promise} */ get(conditions: { [key: string]: any }, callback?: general.Callback): Promise; /** * Retrieves a single document from the collection with the given ID and wraps it as an instance * @param {any} id The document's unique _id field value in downstream format * @param {QueryOptions} options The options dictating how this function behaves * @param {function(Error, TInstance)} callback An optional callback which will be triggered when a result is available * @returns {Promise} */ get(id: any, options: QueryOptions, callback?: general.Callback): Promise; /** * Retrieves a single document from the collection which matches the conditions * @param {Object} conditions The MongoDB query dictating which document to return * @param {QueryOptions} options The options dictating how this function behaves * @param {function(Error, TInstance)} callback An optional callback which will be triggered when a result is available * @returns {Promise} */ get(conditions: { [key: string]: any }, options: QueryOptions, callback?: general.Callback): Promise; get(...args: any[]): Promise { return this.get.apply(this, args); } /** * Retrieves a single document from the collection and wraps it as an instance * @param {function(Error, TInstance)} callback An optional callback which will be triggered when a result is available * @returns {Promise} */ findOne(callback?: general.Callback): Promise; /** * Retrieves a single document from the collection with the given ID and wraps it as an instance * @param {any} id The document's unique _id field value in downstream format * @param {function(Error, TInstance)} callback An optional callback which will be triggered when a result is available * @returns {Promise} */ findOne(id: any, callback?: general.Callback): Promise; /** * Retrieves a single document from the collection which matches the conditions * @param {Object} conditions The MongoDB query dictating which document to return * @param {function(Error, TInstance)} callback An optional callback which will be triggered when a result is available * @returns {Promise} */ findOne(conditions: { [key: string]: any }, callback?: general.Callback): Promise; /** * Retrieves a single document from the collection with the given ID and wraps it as an instance * @param {any} id The document's unique _id field value in downstream format * @param {QueryOptions} options The options dictating how this function behaves * @param {function(Error, TInstance)} callback An optional callback which will be triggered when a result is available * @returns {Promise} */ findOne(id: any, options: QueryOptions, callback?: general.Callback): Promise; /** * Retrieves a single document from the collection which matches the conditions * @param {Object} conditions The MongoDB query dictating which document to return * @param {QueryOptions} options The options dictating how this function behaves * @param {function(Error, TInstance)} callback An optional callback which will be triggered when a result is available * @returns {Promise} */ findOne(conditions: { [key: string]: any }, options: QueryOptions, callback?: general.Callback): Promise; findOne(...args: any[]): Promise { var conditions: { [key: string]: any } = null; var options: QueryOptions = null; var callback: general.Callback = null; for (var argI = 0; argI < args.length; argI++) { if (typeof args[argI] == 'function') callback = callback || args[argI]; else if (_.isPlainObject(args[argI])) { if (conditions) options = args[argI]; else conditions = args[argI]; } else conditions = this.helpers.selectOneDownstream(args[argI]); } _.defaults(options, { wrap: true, cache: true }); var $this = this; return Promise.resolve().bind(this).then(function () { $this.helpers.transform.reverse(conditions); if (options.fields) $this.helpers.transform.reverse(options.fields); if (options.cache && $this.cache && $this.core.cache && $this.cache.valid(conditions)) return $this.core.cache.get($this.cache.buildKey(conditions)); return null; }).then(function (cachedDocument: TDocument) { if (cachedDocument) return cachedDocument; return null; }).then(function (document: TDocument) { if (!document) return null; return $this.handlers.documentsReceived(conditions, [document], $this.helpers.wrapDocument, options).then(function (documents) { return documents[0]; }); }).nodeify(callback); } create(objects: TDocument, callback?: general.Callback): Promise; create(objects: TDocument, options: CreateOptions, callback?: general.Callback): Promise; create(objects: TDocument[], callback?: general.Callback): Promise; create(objects: TDocument[], options: CreateOptions, callback?: general.Callback): Promise; create(...args: any[]): Promise { return this.insert.apply(this, args); } insert(objects: TDocument, callback?: general.Callback): Promise; insert(objects: TDocument, options: CreateOptions, callback?: general.Callback): Promise; insert(objects: TDocument[], callback?: general.Callback): Promise; insert(objects: TDocument[], options: CreateOptions, callback?: general.Callback): Promise; insert(objs: TDocument | TDocument[], ...args: any[]): Promise { var objects: TDocument[]; var options: CreateOptions = {}; var callback: general.Callback = null; if (typeof args[0] == 'function') callback = args[0]; else { options = args[0]; callback = args[1]; } var returnArray: boolean = false; if (Array.isArray(objs)) returnArray = true; else objects = [objs]; _.defaults(options, { wrap: true, w: 1 }); var $this = this; return Promise.resolve().then(function () { var queryOptions = { w: options.w, upsert: options.upsert, new: true }; if (options.upsert) return $this.handlers.creatingDocuments(objects).map(function (object: { _id: any; }) { return new Promise(function (resolve, reject) { $this.collection.findAndModify({ _id: object._id }, ["_id"], object, queryOptions, function (err, result) { if (err) return reject(err); return resolve(result); }); }); }); else return $this.handlers.creatingDocuments(objects).then(function (objects) { return new Promise(function (resolve, reject) { $this.collection.insert(objects, queryOptions, function (err, results) { if (err) return reject(err); return resolve(results); }); }); }); }).then(function (inserted: any[]) { return $this.handlers.documentsReceived(null, inserted, $this.helpers.wrapDocument, { cache: options.cache }); }).then(function (results: TInstance[]) { if (objs instanceof Array) return results; return results[0]; }).nodeify(callback); } update(conditions: any, changes: any, callback?: general.Callback): Promise; update(conditions: any, changes: any, options: UpdateOptions, callback?: general.Callback): Promise; update(conditions: any, changes: any, options?: UpdateOptions, callback?: general.Callback): Promise { if (typeof options == 'function') { callback = >options; options = {}; } _.defaults(options, { w: 1, multi: true }); this.helpers.transform.reverse(conditions); var $this = this; return new Promise(function (resolve, reject) { $this.collection.update(conditions, changes, options, function (err, changes) { if (err) return reject(err); return resolve(changes); }); }).nodeify(callback); } count(callback?: general.Callback): Promise; count(conditions: any, callback?: general.Callback): Promise; count(conditions?: any, callback?: general.Callback): Promise { if (typeof conditions == 'function') { callback = >conditions; conditions = {}; } var $this = this; return new Promise(function (resolve, reject) { $this.collection.count(conditions, function (err, results) { if (err) return reject(err); return resolve(results); }); }).nodeify(callback); } remove(callback?: general.Callback): Promise; remove(conditions: any, callback?: general.Callback): Promise; remove(conditions?: any, callback?: general.Callback): Promise { if (typeof conditions == 'function') { callback = >conditions; conditions = {}; } var $this = this; return new Promise(function (resolve, reject) { $this.collection.remove(conditions, function (err, results) { if (err) return reject(err); return resolve(results); }); }).then(function (count) { if ($this.cache && $this.core.cache && $this.cache.valid(conditions)) return $this.core.cache.clear($this.cache.buildKey(conditions)).then(function () { return count; }); return Promise.resolve(count); }).nodeify(callback); } ensureIndex(specification: IndexSpecification, callback?: general.Callback): Promise; ensureIndex(specification: IndexSpecification, options: MongoDB.IndexOptions, callback?: general.Callback): Promise; ensureIndex(specification: IndexSpecification, options?: MongoDB.IndexOptions, callback?: general.Callback): Promise { if (typeof options == 'function') { callback = >options; options = {}; } var $this = this; return new Promise(function (resolve, reject) { $this.collection.ensureIndex(specification, options, function (err, name: any) { if (err) return reject(err); return resolve(name); }); }).nodeify(callback); } ensureIndices(callback?: general.Callback): Promise { var $this = this; return Promise.resolve(this.options.indices).map(function (index: Index | IndexSpecification) { return $this.ensureIndex((index).spec || index,(index).options || {}); }).nodeify(callback); } } export interface IModelBase { collection: MongoDB.Collection; collectionName: string; core: Iridium; schema: ISchema; cache: cacheDirector; } export interface IModelFactory { (core: Iridium): IModel; } export interface IModelOptions { hooks?: hooks.IHooks; validators?: SkmatcCore.IValidator[]; transforms?: Concoction.Ingredient[]; cache?: cacheDirector; indices?: (Index | IndexSpecification)[]; properties?: { [key: string]: (general.PropertyGetter | general.Property) }; } export interface IModel extends IModelBase { Instance: (doc: TDocument, isNew?: boolean, isPartial?: boolean) => TInstance; } export class ModelSpecificInstance { constructor(model: Model, instanceConstructor) { this.Constructor = function (document: TDocument, isNew?: boolean, isPartial?: boolean) { instanceConstructor.call(this, model, document, isNew, isPartial); }; _.each(model.schema, function (property, key) { Object.defineProperty(this.Constructor.prototype, key, { get: function () { return this._modified[key]; }, set: function (value) { this._modified[key] = value; }, enumerable: true }); }, this); } private Constructor: (document: TDocument, isNew?: boolean, isPartial?: boolean) => void; build(document: TDocument, isNew?: boolean, isPartial?: boolean): TInstance { return new this.Constructor(document, isNew, isPartial); } } export class ModelHelpers { constructor(model: Model) { this._model = model; this._validator = new Skmatc(model.schema); this._transform = new concoction(model.options.transforms); } private _model: Model; private _transform: concoction; /** * Gets the Concoction transforms defined for this model * @returns {Concoction} */ get transform(): Concoction { return this._transform; } private _validator: Skmatc; /** * Validates a document to ensure that it matches the model's ISchema requirements * @param {any} document The document to validate against the ISchema * @returns {SkmatcCore.IResult} The result of the validation */ validate(document: TDocument): SkmatcCore.IResult { return this._validator.validate(document); } /** * Creates a selector based on the document's unique _id field * @param {object} document The document to render the unique selector for * @returns {{_id: any}} A database selector which can be used to return only this document */ selectOne(document: TDocument): { _id: any } { var testDoc: any = _.cloneDeep(document); this.transform.reverse(testDoc); return { _id: testDoc._id }; } /** * Gets the field used in the ISchema to represent the document _id */ get identifierField(): string { var id = new String(""); var testDoc = { _id: id }; this.transform.apply(testDoc); var idField = null; for (var k in testDoc) if (testDoc[k] === id) { idField = k; break; } return idField; } /** * Creates a selector based on the document's unique _id field in downstream format * @param {any} id The downstream identifier to use when creating the selector * @returns {object} A database selector which can be used to return only this document in downstream form */ selectOneDownstream(id: TDocument): any { var conditions = {}; conditions[this.identifierField] = id; return conditions; } /** * Wraps the given document in an instance wrapper for use throughout the application * @param {any} document The document to be wrapped as an instance * @param {Boolean} isNew Whether the instance originated from the database or was created by the application * @param {Boolean} isPartial Whether the document supplied contains all information present in the database * @returns {any} An instance which wraps this document */ wrapDocument(document: TDocument, isNew?: boolean, isPartial?: boolean): TInstance { return this._model.Instance(document, isNew, isPartial); } /** * Performs a diff operation between two documents and creates a MongoDB changes object to represent the differences * @param {any} original The original document prior to changes being made * @param {any} modified The document after changes were made */ diff(original: TDocument, modified: TDocument): any { var omnom = new Omnom(); omnom.diff(original, modified); return omnom.changes; } } export class ModelHandlers { constructor(model: Model) { this._model = model; } private _model: Model; get model(): Model { return this._model; } documentsReceived(conditions: any, results: TDocument[], wrapper: (document: TDocument, isNew?: boolean, isPartial?: boolean) => TResult, options: QueryOptions = {}): Promise { _.defaults(options, { cache: true, partial: false }); return Promise.resolve(results).map(function (target: any) { return >Promise.resolve().then(function () { // Trigger the received hook if (this.model.hooks.retrieved) return this.model.hooks.retrieved(target); }).then(function () { // Cache the document if caching is enabled if (this.model.core.cache && options.cache && !options.fields) { var cacheDoc = _.cloneDeep(target); return this.cache.store(conditions, cacheDoc); } }).then(function () { // Wrap the document and trigger the ready hook var wrapped: TResult = wrapper(target, false, !!options.fields); if (this.model.hooks.ready) return Promise.resolve(this.model.hooks.ready(wrapped)).then(() => wrapped); return wrapped; }); }); } creatingDocuments(documents: TDocument[]): Promise { return Promise.resolve(documents).map(function (document: any) { return Promise.resolve().then(function () { if (this.model.hooks.retrieved) return this.model.hooks.creating(document); }).then(function () { var validation: SkmatcCore.IResult = this.model.helpers.validate(document); if (validation.failed) return Promise.reject(validation.error); this.model.helpers.transform.reverse(document); return document; }); }) } savingDocument(instance: TInstance, changes: any): Promise { return Promise.resolve().then(function () { if (this.model.hooks.saving) return this.model.hooks.saving(instance, changes); }).then(() => instance); } } export interface QueryOptions { cache?: boolean; fields?: any; limit?: number; skip?: number; sort?: IndexSpecification; } export interface CreateOptions { w?: any; upsert?: boolean; cache?: boolean; } export interface UpdateOptions { w?: any; multi?: boolean; } export interface IndexSpecification { [key: string]: number; } export interface Index { spec: IndexSpecification; options?: MongoDB.IndexOptions; } class ModelCache { constructor(model: IModelBase) { this._model = model; } private _model: IModelBase; get model(): IModelBase { return this._model; } set(conditions: any, value: T): Promise { if (!this.model.cache.valid(conditions)) return Promise.resolve(value); return this.model.core.cache.set(this.model.cache.buildKey(conditions), value); } get(conditions: any): Promise { if (!this.model.cache.valid(conditions)) return Promise.resolve(null); return this.model.core.cache.get(this.model.cache.buildKey(conditions)); } clear(conditions: any): Promise { if (!this.model.cache.valid(conditions)) return Promise.resolve(false); return this.model.core.cache.clear(this.model.cache.buildKey(conditions)); } } diff --git a/lib/Plugins.ts b/lib/Plugins.ts index 91d72e7..02101e1 100644 --- a/lib/Plugins.ts +++ b/lib/Plugins.ts @@ -9,5 +9,5 @@ export = IPlugin; interface IPlugin { newModel?(model: model.IModel); newInstance? (instance: any, model: model.IModelBase); - validate? : SkmatcCore.IValidationHandler | SkmatcCore.IValidationHandler[]; + validate?: SkmatcCore.IValidator | SkmatcCore.IValidator[]; } \ No newline at end of file