iridium
Version:
A custom lightweight ORM for MongoDB designed for power-users
388 lines (342 loc) • 16.6 kB
text/typescript
import {Core} from './Core';
import {Model} from './Model';
import {Plugin} from './Plugins';
import {CacheDirector} from './CacheDirector';
import * as General from './General';
import * as ModelInterfaces from './ModelInterfaces';
import * as Index from './Index';
import {Schema} from './Schema';
import {Transforms} from './Transforms';
import _ = require('lodash');
import MongoDB = require('mongodb');
import Bluebird = require('bluebird');
import Skmatc = require('skmatc');
/**
* The default Iridium Instance implementation which provides methods for saving, refreshing and
* removing the wrapped document from the collection, as well as integrating with Omnom, our
* built in document diff processor which allows clean, atomic, document updates to be performed
* without needing to write the update queries yourself.
*
* @param TDocument The interface representing the structure of the documents in the collection.
* @param TInstance The type of instance which wraps the documents, generally the subclass of this class.
*
* This class will be subclassed automatically by Iridium to create a model specific instance
* which takes advantage of some of v8's optimizations to boost performance significantly.
* The instance returned by the model, and all of this instance's methods, will be of type
* TInstance - which should represent the merger of TSchema and IInstance for best results.
*/
export class Instance<TDocument extends { _id?: any }, TInstance> {
/**
* Creates a new instance which represents the given document as a type of model
* @param model The model that dictates the collection the document originated from as well as how validations are performed.
* @param document The document which should be wrapped by this instance
* @param isNew Whether the document is new (doesn't exist in the database) or not
* @param isPartial Whether the document has only a subset of its fields populated
*
*/
constructor(model: Model<TDocument, TInstance>, document: TDocument, isNew: boolean = true, isPartial: boolean = false) {
this._model = model;
this._isNew = !!isNew;
this._isPartial = isPartial;
this._original = document;
this._modified = _.cloneDeep<TDocument>(document);
_.each(model.core.plugins,(plugin: Plugin) => {
if (plugin.newInstance) plugin.newInstance(this, model);
});
}
private _isNew: boolean;
private _isPartial: boolean;
private _model: Model<TDocument, TInstance>;
private _original: TDocument;
private _modified: TDocument;
/**
* Gets the underlying document representation of this instance
*/
get document(): TDocument {
return this._modified;
}
[name: string]: any;
/**
* A function which is called whenever a new document is in the process of being inserted into the database.
* @param document The document which will be inserted into the database.
*/
static onCreating: (document: { _id?: any }) => void;
/**
* A function which is called whenever a document of this type is received from the database, prior to it being
* wrapped by an Instance object.
* @param document The document that was retrieved from the database.
*/
static onRetrieved: (document: { _id?: any }) => void;
/**
* A function which is called whenever a new instance has been created to wrap a document.
* @param instance The instance which has been created.
*/
static onReady: (instance: Instance<{ _id?: any }, Instance<{ _id?: any }, any>>) => void;
/**
* A function which is called whenever an instance's save() method is called to allow you to interrogate and/or manipulate
* the changes which are being made.
*
* @param instance The instance to which the changes are being made
* @param changes The MongoDB change object describing the changes being made to the document.
*/
static onSaving: (instance: Instance<{ _id?: any }, Instance<{ _id?: any }, any>>, changes: any) => void;
/**
* The name of the collection into which documents of this type are stored.
*/
static collection: string;
/**
* The schema used to validate documents of this type before being stored in the database.
*/
static schema: Schema = {
_id: false
};
/**
* Additional which should be made available for use in the schema definition for this instance.
*/
static validators: Skmatc.Validator[] = [
Skmatc.create(schema => schema === MongoDB.ObjectID, function(schema, data) {
return this.assert(!data || data instanceof MongoDB.ObjectID || (data._bsontype === 'ObjectID' && data.id));
}, { name: 'ObjectID validation' })
];
/**
* The transformations which should be applied to properties of documents of this type.
*/
static transforms: Transforms = {
};
/**
* The cache director used to derive unique cache keys for documents of this type.
*/
static cache: CacheDirector;
/**
* The indexes which should be managed by Iridium for the collection used by this type.
*/
static indexes: (Index.Index | Index.IndexSpecification)[] = [];
/**
* Saves any changes to this instance, using the built in diff algorithm to write the update query.
* @param {function(Error, IInstance)} callback A callback which is triggered when the save operation completes
* @returns {Promise<TInstance>}
*/
save(callback?: General.Callback<TInstance>): Bluebird<TInstance>;
/**
* Saves the given changes to this instance and updates the instance to match the latest database document.
* @param {Object} changes The MongoDB changes object to be used when updating this instance
* @param {function(Error, IInstance)} callback A callback which is triggered when the save operation completes
* @returns {Promise<TInstance>}
*/
save(changes: Object, callback?: General.Callback<TInstance>): Bluebird<TInstance>;
/**
* Saves the given changes to this instance and updates the instance to match the latest database document.
* @param {Object} conditions The conditions under which the update will take place - these will be merged with an _id query
* @param {Object} changes The MongoDB changes object to be used when updating this instance
* @param {function(Error, IInstance)} callback A callback which is triggered when the save operation completes
* @returns {Promise<TInstance>}
*/
save(conditions: Object, changes: Object, callback?: General.Callback<TInstance>): Bluebird<TInstance>;
save(...args: any[]): Bluebird<TInstance> {
var callback: General.Callback<any> = null;
var changes: any = null;
var conditions: any = {};
Array.prototype.slice.call(args, 0).reverse().forEach((arg) => {
if (typeof arg == 'function') callback = arg;
else if (typeof arg == 'object') {
if (!changes) changes = arg;
else conditions = arg;
}
});
return Bluebird.resolve().then(() => {
conditions = _.cloneDeep(conditions);
_.merge(conditions, { _id: this._modified._id });
if (!changes) {
var validation = this._model.helpers.validate(this._modified);
if (validation.failed) return Bluebird.reject(validation.error).bind(this).nodeify(callback);
var original = _.cloneDeep(this._original);
var modified = _.cloneDeep(this._modified);
changes = this._model.helpers.diff(original, modified);
}
if (!_.keys(changes).length) return null;
return changes;
}).then((changes) => {
if (!changes && !this._isNew) return changes;
return this._model.handlers.savingDocument(<TInstance><any>this, changes).then(() => changes);
}).then((changes) => {
if (!changes && !this._isNew) return false;
if (this._isNew) {
return new Bluebird<boolean>((resolve, reject) => {
this._model.collection.insertOne(this._modified, { w: 'majority' }, (err, doc) => {
if (err) return reject(err);
return resolve(<any>!!doc);
});
});
} else {
return new Bluebird<boolean>((resolve: (changed: boolean) => void, reject) => {
this._model.collection.updateOne(conditions, changes, { w: 'majority' }, (err: Error, changed: boolean) => {
if(err) {
err['conditions'] = conditions;
err['changes'] = changes;
return reject(err);
}
return resolve(changed);
});
});
}
}).catch(err => {
err['original'] = this._original;
err['modified'] = this._modified;
return Bluebird.reject(err);
}).then((changed: boolean) => {
conditions = { _id: this._modified._id };
if (!changed) return this._modified;
return new Bluebird<TDocument>((resolve, reject) => {
this._model.collection.findOne(conditions, (err: Error, latest) => {
if (err) return reject(err);
return resolve(latest);
});
});
}).then((latest: TDocument) => {
if(!latest) {
this._isNew = true;
this._original = _.cloneDeep(this._modified);
return Bluebird.resolve(<TInstance><any>this);
}
return this._model.handlers.documentReceived(conditions, latest, (value) => {
this._isPartial = false;
this._isNew = false;
this._modified = value;
this._original = _.cloneDeep(value);
return <TInstance><any>this;
});
}).nodeify(callback);
}
/**
* Updates this instance to match the latest document available in the backing collection
* @param {function(Error, IInstance)} callback A callback which is triggered when the update completes
* @returns {Promise<TInstance>}
*/
update(callback?: General.Callback<TInstance>): Bluebird<TInstance> {
return this.refresh(callback);
}
/**
* Updates this instance to match the latest document available in the backing collection
* @param {function(Error, IInstance)} callback A callback which is triggered when the update completes
* @returns {Promise<TInstance>}
*/
refresh(callback?: General.Callback<TInstance>): Bluebird<TInstance> {
var conditions = { _id: this._original._id };
return Bluebird.resolve().then(() => {
return new Bluebird<TDocument>((resolve, reject) => {
this._model.collection.findOne(conditions,(err: Error, doc: any) => {
if (err) return reject(err);
return resolve(doc);
});
});
}).then((newDocument) => {
if (!newDocument) {
this._isPartial = true;
this._isNew = true;
this._original = _.cloneDeep<TDocument>(this._modified);
return <Bluebird<TInstance>><any>this;
}
return this._model.handlers.documentReceived(conditions, newDocument, (doc) => {
this._isNew = false;
this._isPartial = false;
this._original = doc;
this._modified = _.cloneDeep<TDocument>(doc);
return <TInstance><any>this;
});
}).nodeify(callback);
}
/**
* Removes this instance's document from the backing collection
* @param {function(Error, IInstance)} callback A callback which is triggered when the operation completes
* @returns {Promise<TInstance>}
*/
delete(callback?: General.Callback<TInstance>): Bluebird<TInstance> {
return this.remove(callback);
}
/**
* Removes this instance's document from the backing collection
* @param {function(Error, IInstance)} callback A callback which is triggered when the operation completes
* @returns {Promise<TInstance>}
*/
remove(callback?: General.Callback<TInstance>): Bluebird<TInstance> {
var conditions = { _id: this._original._id };
return Bluebird.resolve().then(() => {
if (this._isNew) return 0;
return new Bluebird<number>((resolve, reject) => {
this._model.collection.remove(conditions, { w: 'majority' },(err: Error, removed?: any) => {
if (err) return reject(err);
return resolve(removed);
});
});
}).then((removed) => {
if (removed) return this._model.cache.clear(conditions);
return false;
}).then(() => {
this._isNew = true;
return <TInstance><any>this;
}).nodeify(callback);
}
/**
* Retrieves the first element in an enumerable collection which matches the predicate
* @param collection The collection from which to retrieve the element
* @param predicate The function which determines whether to select an element
* @returns The first element in the array which matched the predicate.
*/
first<T>(collection: T[], predicate: General.Predicate<T>): T;
/**
* Retrieves the first element in an enumerable collection which matches the predicate
* @param collection The collection from which to retrieve the element
* @param predicate The function which determines whether to select an element
* @returns The first element in the object which matched the predicate.
*/
first<T>(collection: { [key: string]: T }, predicate: General.Predicate<T>): T;
first<T>(collection: T[]| { [key: string]: T }, predicate: General.Predicate<T>): T {
var result = null;
_.each(collection,(value: T, key) => {
if (predicate.call(this, value, key)) {
result = value;
return false;
}
});
return result;
}
/**
* Retrieves a number of elements from an enumerable collection which match the predicate
* @param collection The collection from which elements will be plucked
* @param predicate The function which determines the elements to be plucked
* @returns A new array containing the elements in the array which matched the predicate.
*/
select<T>(collection: T[], predicate: General.Predicate<T>): T[];
/**
* Retrieves a number of elements from an enumerable collection which match the predicate
* @param collection The collection from which elements will be plucked
* @param predicate The function which determines the elements to be plucked
* @returns An object with the properties from the collection which matched the predicate.
*/
select<T>(collection: { [key: string]: T }, predicate: General.Predicate<T>): { [key: string]: T };
select<T>(collection: T[]| { [key: string]: T }, predicate: General.Predicate<T>): any {
var isArray = Array.isArray(collection);
var results: any = isArray ? [] : {};
_.each(collection,(value: T, key) => {
if (predicate.call(this, value, key)) {
if (isArray) results.push(value);
else results[key] = value;
}
});
return results;
}
/**
* Gets the JSON representation of this instance
* @returns {TDocument}
*/
toJSON(): any {
return this.document;
}
/**
* Gets a string representation of this instance
* @returns {String}
*/
toString(): string {
return JSON.stringify(this.document, null, 2);
}
}