/**
* @version 1.3
* @author Boris GBAHOUE
* @file abstraction layer to encapsulate DB accesses and isolate our backend code from DB choice
* @module amiwo/db
*/
// =======================================================================
// BASIC SETUP
// =======================================================================
// modules
const debug = require('debug')('amiwo:db');
const util = require('util');
const u = require('../util');
const when = require('when');
// objects
const DBObjectFactory = require('./DBObjectFactory');
const GenericError = require('../error/GenericError');
const DuplicateObjectError = require('../error/DuplicateObjectError');
const ConcurrencyDBError = require('../error/ConcurrencyDBError');
// Class constants
const OCC_MAX_RETRIES = 100;
const OCC_DELAY = 35; // magic number
// =======================================================================
// CONSTRUCTOR
// =======================================================================
/**
* Subclasses should set the following property in the constructor:
* - '$name' (string): it will be used to lookup the property which will be used as the "name" of the objects (e.g., in findByName())
*
* @classdesc Abstraction layer to encapsulate DB accesses and isolate our backend code from DB choice
* @class
* @abstract
* @param {Array} linkedObjects: objects to be linked to this one i.e. removed when this object is removed
* @param {Object} [object]: to init this DBObject with
*/
function DBObject(linkedObjects, object) {
// Arguments processing
if (u.isObject(linkedObjects) && (object === undefined)) { // will return false if linkedObjects is nully
object = linkedObjects;
linkedObjects = [];
}
this.$name = "override me";
this.$linkedObjects = linkedObjects || [];
this.$verbose = false;
if (object == null) {
return this;
} else {
this.fill(object);
}
}
// =======================================================================
// DATABASE FUNCTIONS TO OVERRIDE
// =======================================================================
/**
* Find object with id 'id' and update its values based on 'query'.
* Subclasses must override this method
*
* @param {string} id.id : id to lookup
* @param {string} query
* @param {object} [options]
*
* @return {Promise}
*
* @protected
*/
DBObject.prototype.dbFindOneAndUpdate = function(id, query, options) {
return when.reject(null);
};
/**
* Find object matching 'query'
* Subclasses must override this method
*
* @param {object} query
* @param {Object} [options]
*
* @return {Promise}
*
* @protected
*/
DBObject.prototype.dbFind = function(query, options) {
return when.reject(null);
};
/**
* Find object which id is "id"
* Subclasses must override this method
*
* @param {object} id
* @param {Object} [options]
*
* @return {Promise}
*
* @protected
*/
DBObject.prototype.dbFindById = function(id, options) {
return when.reject(null);
};
/**
* Finds a matching document, removes it, returning the found document (if any)
* Subclasses must override this method
*
* @param {Object|Number|String} id
* @param {Object} [options]
*
* @return {Promise}
*
* @protected
*/
DBObject.prototype.dbFindByIdAndRemove = function(id, options) {
return when.reject(null);
};
/**
* Remove object matching 'query'
* Subclasses must override this method
*
* @param {Object} query
* @param {Object} [options]
*
* @return {Promise}
*
* @protected
*/
DBObject.prototype.dbRemove = function(query, options) {
return when.reject(null);
};
/**
* Save object
* Subclasses must override this method
*
* @return {Promise}
*
* @protected
*/
DBObject.prototype.dbSave = function(options) {
return when.reject(null);
};
/**
* Adds a value to an array unless the value is already present, in which case it does nothing to that array. The underlying DBObject is saved
* Subclasses must override this method
*
* @return {Promise}
*
* @protected
*/
DBObject.prototype.dbAddToSet = function() {
return when.reject(null);
};
/**
* Replaces all document references by the actual DBObject
* Subclasses must override this method
*
* @param {Array|String} [path] path or array of path to populate, is nully populate all available references
* @return {Promise}
*
* @protected
*/
DBObject.prototype.dbPopulate = function(path) {
return when.reject(null);
}
// =======================================================================
// PUBLIC METHODS
// =======================================================================
/**
* Copy 'object' into the current 'DBObject', excluding 'ignoreKeys'
* @param {Object} object : object to clone
* @param {String[]} ignoreKeys : array of String containing the keys to ignore
* @public
*/
DBObject.prototype.fill = function(object, ignoreKeys) {
if (u.isEmpty(object)) return;
ignoreKeys = (ignoreKeys) ? ignoreKeys : [];
for (var key in object) {
if (object.hasOwnProperty(key) && (object[key] != null) && (ignoreKeys.indexOf(key) == -1))
this[key] = object[key];
}
}
/**
* Create a lean (and cloned) version of this object i.e. keeping the data but without the DBObject magic
* Goes recursively into nested Objects
*
* @return {Object}
*/
DBObject.prototype.lean = function() {
function $lean(obj) {
if (u.isEmpty(obj)) return obj;
if (obj instanceof DBObject) {
return obj.lean();
} else if (u.typeOf(obj) == "array") {
var clone = [];
for (var i = 0; i < obj.length; i++) {
clone.push($lean(obj[i]));
}
return clone;
} else if (u.isObject(obj)) {
var clone = {};
for (var key in obj) {
if (obj.hasOwnProperty(key)) {
clone[key] = $lean(obj[key]);
}
}
return clone;
} else {
return u.clone(obj);
}
}
var result = {};
for (var key in this) {
if (key.indexOf("$") == 0) continue; // Property starting with '$' => skip
if (!this.hasOwnProperty(key)) continue; // e.g. function
result[key] = $lean(this[key]);
};
// Make sure the ID is set no matter what we have done above
if (this._id) result._id = this._id;
return result;
};
/**
* Check if the associated DBObject is empty
*
* @returns {boolean}
*/
DBObject.prototype.isEmpty = function() {
return u.isEmpty(this.lean());
}
/**
* Returns the name associated with this object.
*
* @returns {String}
* @public
*/
DBObject.prototype.getName = function() {
return this.$name;
}
/**
* Method to find an Object based on its name
* @param {String} name
* @param {Object} options supports lean, populateFields, limit, sort
*
* @return {Promise} wrapping an array of Objects
*
* @public
*/
DBObject.prototype.findByName = function(name, options) {
var self = this;
if (self.getName() == null) return when.reject(new GenericError("::AMIWO::".self.constructor.name.toUpperCase() + "::FINDBYNAME::ERROR name is null, can't findByName"));
var query = {};
query[self.getName()] = name;
return self.dbFind(query, options).then(function(results) {
if (self.$verbose) debug("::AMIWO::%s::FINDBYNAME::DEBUG %d results : %s", self.constructor.name.toUpperCase(), results.length, util.inspect(results));
return when.resolve(self._convert(results));
}).catch(function(err) {
debug("::AMIWO::%s::FINDBYNAME::ERROR Unexpected error looking up for '%s' => err=%s, trace=%s", self.constructor.name.toUpperCase(), name, err, err.stack);
return when.reject(GenericError.create("::AMIWO::" + self.constructor.name.toUpperCase() + "::FINDBYNAME::ERROR Unexpected Error", err));
});
};
/**
* Method to find an Object using a query
* @param {Object} query
* @param {Object} options supports lean, populateFields, limit, sort
*
* @return {Promise} wrapping an array of Objects
* @public
*/
DBObject.prototype.find = function(query, options) {
var self = this;
return self.dbFind(query, options).then(function(results) {
if (self.$verbose) debug("::AMIWO::%s::FIND::DEBUG %d results found : %s", self.constructor.name.toUpperCase(), results.length, JSON.stringify(results, null, 4));
return when.resolve(self._convert(results));
}).catch(function(err) {
debug("::AMIWO::%s::FIND::ERROR Error = ", self.constructor.name.toUpperCase(), err);
return when.reject(GenericError.create("::AMIWO::" + self.constructor.name.toUpperCase() + "::FIND::ERROR Unexpected error", err));
});
};
/**
* Method to find a Object based on its ID
* @param {String} id: id of the object to find
* @param {Object} options supports lean, populateFields, limit, sort
*
* @return {Promise} wrapping an Object (or null)
* @public
*/
DBObject.prototype.findById = function(id, options) {
var self = this;
if (u.isEmpty(id)) return when.reject(new GenericError("::AMIWO::" + self.constructor.name.toUpperCase() + "::FINDBYID::ERROR nully 'id' to look for"));
return self.dbFindById(id, options).then(function(result) {
if (self.$verbose) debug("::AMIWO::%s::FINDBYID::DEBUG Result : %s", self.constructor.name.toUpperCase(), result);
return when.resolve(self._convert(result));
}).catch(function(err) {
debug("::AMIWO::%s::FINDBYID::ERROR Error = Unexpected error looking up %s => err=%s\nTrace=%s", self.constructor.name.toUpperCase(), id, err, err.stack);
return when.reject(GenericError.create("::AMIWO::" + self.constructor.name.toUpperCase() + "::FINDBYID::ERROR Unexpected Error", err));
});
};
/**
* Method to remove an object. This._id must be set (at least)<br>
* Since 1.3 Added an option to remove() to temporarily add nested objects to linkedObjects list<br>
* Since 1.2 remove() method is now called recursively on nested DBObject / arrays of DBObject (instead of dbRemove())
*
* @param {Object} [options]
* @param {Object} [options.delete=null] if null, execute a hard delete (i.e. actually remove DBObject from the DB)
* @param {Object} [options.delete.method] 'hard' or 'soft'
* @param {Object} [options.delete.user] used only if options.delete.method == 'soft'
* @param {String[]} [options.delete.unlink] additional objects to be removed (temporary add the content of the list to the object's linkedObjects' property; method == 'hard' only)
*
* @returns {Promise} wrapping a fully populated removed object
*
* @public
*/
DBObject.prototype.remove = function(options) {
var self = this;
// Options processing
if (u.isEmpty(options)) options = {};
if (u.isEmpty(options.delete)) {
options.delete = { method: "hard" };
} else if (options.delete.method.toLowerCase() !== "hard") {
if (u.isEmpty(options.delete.user)) return when.reject(new GenericError("::AMIWO::" + self.constructor.name.toUpperCase() + "::REMOVE::ERROR Unable to find user object in options => valid username is required for soft delete"));
}
if (u.isEmpty(options.delete.unlink)) options.delete.unlink = [];
var deletedObject;
// 1. Lookup the object to delete
return self.dbFindById(self._id).then(function(result) {
if (u.isEmpty(result)) {
return when.reject(new GenericError("::AMIWO::" + self.constructor.name.toUpperCase() + "::REMOVE::ERROR Unable to find object with ID '"+self._id+"'", null));
}
if (Array.isArray(result) && (result.length > 1)) {
return when.reject(new GenericError("::AMIWO::" + self.constructor.name.toUpperCase() + "::REMOVE::ERROR Invalid state: "+result.length+" objects found with ID '"+self._id+"'", null));
}
deletedObject = self._convert( Array.isArray(result) ? result[0] : result );
// 2. Populate all deletion candidate as we need to remove the nested objects
return deletedObject.populate();
}).then(function(populatedObject) {
deletedObject = populatedObject;
// 3. Perform the hard or soft delete
// 3.1 Ensure a soft remove is possible
if ((options.delete.method.toLowerCase() == "soft") && (deletedObject.status == null)) {
// soft remove is not implemented => revert to hard remove
debug("::AMIWO::%s::REMOVE::IMPORTANT Soft remove impossible for DBObject of class '%s' => reverting to hard remove", self.constructor.name.toUpperCase(), self.constructor.name);
options.delete.method = "hard";
}
// 3.2 Perform remove operation
// HARD REMOVE
if (options.delete.method.toLowerCase() === "hard") {
// Remove all hard linked Objects
var promiseArray = [];
const $linkedObjects = deletedObject.$linkedObjects.concat(options.delete.unlink);
for (var i = 0; i < $linkedObjects.length; i++) {
var property = deletedObject[$linkedObjects[i]];
if (u.isEmpty(property)) continue;
if (property instanceof DBObject) {
// Nested DBObject => remove it if not empty
if (!property.isEmpty()) promiseArray.push(property.remove());
} else if (Array.isArray(property)) {
// Check all array elements
for (var j = 0; j < property.length; j++) {
if (property[j] instanceof DBObject) {
if (!property[j].isEmpty()) promiseArray.push(property[j].remove());
} else {
debug("::AMIWO::%s::REMOVE::ERROR Unable to remove nested object %s[%d] since it was not an instance of DBObject (typeof = %s)", self.constructor.name.toUpperCase(), $linkedObjects[i], j, (typeof property[j]));
}
}
} else {
debug("::AMIWO::%s::REMOVE::ERROR Unable to remove nested object %s since it was not an instance of DBObject (typeof = %s)", self.constructor.name.toUpperCase(), $linkedObjects[i], (typeof property));
}
}
return when.all(promiseArray).then(function(array) {
return deletedObject.dbRemove();
}).then(function(writeResult) {
return when.resolve(deletedObject);
})
} else {
// SOFT REMOVE
var deleteData = (deletedObject.deleteData) ? deletedObject.deleteData : DBObjectFactory.createObject("DeleteData"); // let's assume we can soft delete an Event already soft-deleted
deleteData.deletor = options.delete.user._id;
deleteData.date = new Date();
return deleteData.save().then(function(deleteData) {
deletedObject.status = DBObject.STATUS.SOFT_DELETED;
deletedObject.deleteData = deleteData;
return deletedObject.save();
}).then(function(savedObject) {
return savedObject.populate(); // Return a fully populated Object
})
}
}).catch(function(err) {
debug("::AMIWO::%s::REMOVE::ERROR Error removing object id '%s' => err=%s, trace=%s", self.constructor.name.toUpperCase(), self._id, err, err.stack);
return when.reject(GenericError.create("::AMIWO::" + self.constructor.name.toUpperCase() + "::REMOVE::ERROR Unexpected error", err));
});
};
/**
* Method to save an Object into DB.<br>
* Since v1.2 save() don't automatically populate saved Object. Use options.populate = true if needed
*
* @param {Object} [options]
* @param {boolean|String|String[]} [options.populate=false] set to populate saved object (true => populate all fields, String/String[] will be passed as argument to populate())
* @param {boolean} [options.occ=false] set to true to force a concurrency check using Optimistic Concurrency Control
*
* @return {Promise} wrapping the saved Object
*
* @public
*/
DBObject.prototype.save = function(options) {
const self = this;
// options processing
if (options == null) options = {};
// - options.populate
if (options.populate == null) options.populate = false;
// - options.occ
if (options.occ == null) options.occ = false;
return self.dbSave(options)
.then(function(result) {
if (result == null) return when.resolve(null);
if ((Array.isArray(result)) && (result.length > 0))
result = result[0]; // expects result to be an array of [object, nbChange]
if (options.populate === false) {
return when.resolve(self._convert(result));
} else {
var params = (options.populate === true) ? null : options.populate;
return when.resolve(self._convert(result).populate(params));
}
}).catch(function(err) {
// DuplicateObjectError
if ((err.name === "MongoError") && (err.code === 11000)) {
if (typeof err.toJSON === 'function') {
var objName = self.constructor.name.replace($DB_IMPLEM, "");
var err2 = err.toJSON();
delete err2.op;
// Get duplicate object from DB
return self.findByName(self[self.getName()]).then(function(duplicates) {
return when.reject(new DuplicateObjectError(objName+" object already exists", duplicates, err2));
})
}
// ConcurrencyDBError
} else if (err instanceof ConcurrencyDBError) {
// Since we don't know what was saved, we pass on the ConcurrencyDBError to the caller and let it handle it
// Weave in the OCC_DELAY to not propagate magic numbers everywhere
var defer = when.defer();
err.object = self._convert(err.object);
when.resolve("wait and pass on error").delay(OCC_DELAY).done(function() {
defer.reject(err);
});
return defer.promise;
}
// Other Errors
var message = err.message || "::AMIWO::" + self.constructor.name.toUpperCase() + "::SAVE::ERROR Unexpected error";
return when.reject(GenericError.create(message, err));
});
};
/**
* Adds a value to an array unless the value is already present, in which case it does nothing to that array. The underlying DBObject is saved
*
* @param {String} property: name of the array property
* @param {any} value
* @param {Object} options
* @param {boolean} [options.upsert=true] set to false to only add 'value' to set at path 'property' if it exists (and is an Array)
* @param {boolean} [options.each=true] set to true to add all elements of 'value' separately. If set to false and 'value' is an array adds the Array
* @param {boolean} [options.occ=true] set to false to disable concurrency check (using Optimistic Concurrency Control) => addToSet will return a rejected Promise
*
* @return {Promise} wrapping the value which were added
* @throws {ConcurrencyDBError}
*/
DBObject.prototype.addToSet = function(property, value, options, retries) {
const self = this;
if (retries === undefined) retries = OCC_MAX_RETRIES;
// options processing
if (options == null) options = {};
// - options.each
options.each = (""+options.each !== "false");
if (options.each && !Array.isArray(value)) options.each = false;
// - options.occ
if (options.occ == null) options.occ = true;
// - options.upsert
if (options.upsert == null) options.upsert = true;
// Arguments processing
// - check that self has an _id
if (u.isEmpty(self._id)) return when.reject(GenericError.create(util.format("::AMIWO::%s::ADDTOSET::ERROR Invalid DBObject, no _id can be found (make sure your DBObject has been saved first)", self.constructor.name.toUpperCase())));
// - property
var $property = u.getProperty(self, property);
// if 'property' exists it must be an Array ...
// ... else it depends on options.upsert
if (options.upsert == false) {
if ($property === undefined) return when.reject(GenericError.create(util.format("::AMIWO::%s::ADDTOSET::ERROR Invalid property, '%s' doesn't exist", self.constructor.name.toUpperCase(), property)));
if (!Array.isArray($property)) return when.reject(GenericError.create(util.format("::AMIWO::%s::ADDTOSET::ERROR Invalid property, %s is a %s not an Array", self.constructor.name.toUpperCase(), property, u.typeOf($property))));
} else {
if (($property != null) && !Array.isArray($property)) {
return when.reject(GenericError.create(util.format("::AMIWO::%s::ADDTOSET::ERROR Invalid property, %s exists and is not an Array (type = %s)", self.constructor.name.toUpperCase(), property, u.typeOf($property))));
}
}
// Actual logic
return self.dbAddToSet(property, value, options)
.then(function(addedValues) {
return when.resolve(self._convert(addedValues));
}).catch(function(err) {
if (err instanceof ConcurrencyDBError) {
err.object = self._convert(err.object);
if (retries > 0) {
return when.resolve("wait before retrying").delay(OCC_DELAY).then(function() {
return err.object.addToSet(property, value, options, retries-1);
});
} else {
err.message += " => max number of retries reached, failing";
}
}
// else
var message = err.message || util.format("::AMIWO::%s::ADDTOSET::ERROR Unexpected error", self.constructor.name.toUpperCase());
return when.reject(GenericError.create(message, err));
});
}
/**
* Appends a specified value to an array. The underlying DBObject is saved
*
* @param {String} property: name of the array property
* @param {any} value
* @param {Object} options
* @param {boolean} [options.each=true] set to true to add all elements of 'value' separately. If set to false and 'value' is an array adds the Array
* @param {boolean} [options.occ=true] set to false to disable concurrency check (using Optimistic Concurrency Control) => push will return a rejected Promise
* @param {boolean} [options.upsert=true] set to false to only add 'value' to set at path 'property' if it exists (and is an Array)
*
* @return {Promise} wrapping the updated object
* @throws {ConcurrencyDBError}
*/
DBObject.prototype.push = function(property, value, options, retries) {
const self = this;
if (retries === undefined) retries = OCC_MAX_RETRIES;
// options processing
if (options == null) options = {};
// - options.each
options.each = (""+options.each !== "false");
if (options.each && !Array.isArray(value)) options.each = false;
// - options.occ
if (options.occ == null) options.occ = true;
// - options.upsert
if (options.upsert == null) options.upsert = true;
// test arguments
if (u.isEmpty(self._id)) return when.reject(GenericError.create(util.format("::AMIWO::%s::PUSH::ERROR Invalid DBObject, no _id can be found (make sure your DBObject has been saved first)", self.constructor.name.toUpperCase())));
if (u.isEmpty(value)) return when.resolve([]);
var $property = u.getProperty(self, property);
// if 'property' exists it must be an Array ...
// ... else it depends on options.upsert
if (options.upsert == false) {
if ($property === undefined) return when.reject(GenericError.create(util.format("::AMIWO::%s::PUSH::ERROR Invalid property, '%s' doesn't exist", self.constructor.name.toUpperCase(), property)));
if (!Array.isArray($property)) return when.reject(GenericError.create(util.format("::AMIWO::%s::PUSH::ERROR Invalid property, %s is a %s not an Array", self.constructor.name.toUpperCase(), property, u.typeOf($property))));
} else {
if (($property != null) && !Array.isArray($property)) {
return when.reject(GenericError.create(util.format("::AMIWO::%s::PUSH::ERROR Invalid property, %s exists and is not an Array (type = %s)", self.constructor.name.toUpperCase(), property, u.typeOf($property))));
}
}
// Actual logic
return self.dbPush(property, value, options)
.then(function(updatedObject) {
return when.resolve(self._convert(updatedObject));
}).catch(function(err) {
if (err instanceof ConcurrencyDBError) {
err.object = self._convert(err.object);
if (retries > 0) {
return when.resolve("wait before retrying").delay(OCC_DELAY).then(function() {
return err.object.push(property, value, options, retries-1);
});
} else {
err.message += " => max number of retries reached, failing";
}
}
// else
var message = err.message || util.format("::AMIWO::%s::PUSH::ERROR Unexpected error", self.constructor.name.toUpperCase());
return when.reject(GenericError.create(message, err));
});
}
/**
* Replaces all document references by the actual DBObject
*
* @param {Array|String} [path] path or array of path to populate, is nully populate all available references
* @return {Promise} wrapping the populated object
*
* @public
*/
DBObject.prototype.populate = function(path) {
var self = this;
return self.dbPopulate(path)
.then(function(result) {
return when.resolve(self._convert(result));
}).catch(function(err) {
debug("::AMIWO::%s::POPULATE::ERROR Error populating object id '%s' => err=%s, trace=%s", self.constructor.name.toUpperCase(), self._id, err, err.stack);
return when.reject(GenericError.create("::AMIWO::" + self.constructor.name.toUpperCase() + "::POPULATE::ERROR Unexpected error", err));
})
}
DBObject.prototype.setVerbose = function(bool) {
this.$verbose = bool;
}
// =======================================================================
// PROTECTED METHODS
// =======================================================================
/**
* Convert an Object into a proper DBObject
*
* @param {any} document
* @param {Object} [options]
* @param {Boolean} $recursiveCall: don't use
*
* @return {DBObject}
* @protected
*/
DBObject.prototype._convert = function(document, options, $recursiveCall) {
if (document == null) return document;
if (u.isEmpty(document)) return document;
// options processing (do it only once)
if ($recursiveCall != true) {
if (options == null) options = {};
}
// Specific tests
// - Arrays => array of converted items
if (Array.isArray(document)) {
var array = [];
for (var i=0; i < document.length; i++) {
array[i] = this._convert(document[i], options, true);
}
return array;
}
// - Native JSON => Object of converted items
if (u.isObject(document, true)) { // only native objects
var object;
if ($recursiveCall === true) {
object = {};
} else {
// Convert highest level Object into a DBObject from the same class as the current DBObject
var Constructor = this.constructor;
object = new Constructor();
}
for (var key in document) {
if (!document.hasOwnProperty(key)) continue; // e.g. function
object[key] = this._convert(document[key], options, true);
}
return object;
}
// - Native object
if (u.isNativeObject(document)) {
return document;
}
// - DBObject => convert properties
if (document instanceof DBObject) {
for (var key in document) {
if (!document.hasOwnProperty(key)) continue; // e.g. function
document[key] = this._convert(document[key], options, true);
}
return document;
}
// - Mongoose objects conversion
// . Document => DBObject
if (document.constructor.name == "model") {
// Convert overall Document into a DBObject
var Constructor = this.constructor;
var dbObj = new Constructor();
document = document.toObject();
for (var key in document) {
if (!document.hasOwnProperty(key)) continue; // e.g. function
dbObj[key] = this._convert(document[key], options, true);
}
return dbObj;
}
// . ObjectID => String
if (document.constructor.name == "ObjectID") {
return document.toString();
}
debug("::AMIWO::%s::_CONVERT::LOG Unable to convert %s (%s)", this.constructor.name.toUpperCase(), document.constructor.name, JSON.stringify(document));
return document;
}
// =======================================================================
// CLASS CONSTANTS
// =======================================================================
/**
* @constant
*/
DBObject.STATUS = {
ACTIVE: "active",
SOFT_DELETED: "deleted"
};
/**
* @constant
*/
DBObject.OCC_MAX_RETRIES = OCC_MAX_RETRIES;
module.exports = DBObject;