Source: db/DBObject.js

/**
 * @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;