/** * Handle models (i.e. docs) * Serialization/deserialization * Copying */ var dateToJSON = function () { return { $$date: this.getTime() }; } , originalDateToJSON = Date.prototype.toJSON , util = require('util') , _ = require('underscore') , modifierFunctions = {} , matcherFunctions = {} ; /** * Check a key, throw an error if the key is non valid * @param {String} k key * @param {Model} v value, needed to treat the Date edge case * Non-treatable edge case here: if part of the object if of the form { $$date: number } * Its serialized-then-deserialized version it will transformed into a Date object * But you really need to want it to trigger such behaviour, even when warned not to use '$' at the beginning of the field names... */ function checkKey (k, v) { if (k[0] === '$' && !(k === '$$date' && typeof v === 'number')) { throw 'Field names cannot begin with the $ character'; } if (k.indexOf('.') !== -1) { throw 'Field names cannot contain a .'; } } /** * Check a DB object and throw an error if it's not valid * Works by applying the above checkKey function to all fields recursively */ function checkObject (obj) { if (util.isArray(obj)) { obj.forEach(function (o) { checkObject(o); }); } if (typeof obj === 'object') { Object.keys(obj).forEach(function (k) { checkKey(k, obj[k]); checkObject(obj[k]); }); } } /** * Serialize an object to be persisted to a one-line string * Accepted primitive types: Number, String, Boolean, Date, null * Accepted secondary types: Objects, Arrays */ function serialize (obj) { var res; // Keep track of the fact that this is a Date object Date.prototype.toJSON = dateToJSON; res = JSON.stringify(obj, function (k, v) { checkKey(k, v); if (typeof v === undefined) { return null; } if (typeof v === 'string' || typeof v === 'number' || typeof v === 'boolean' || v === null) { return v; } return v; }); // Return Date to its original state Date.prototype.toJSON = originalDateToJSON; return res; } /** * From a one-line representation of an object generate by the serialize function * Return the object itself */ function deserialize (rawData) { return JSON.parse(rawData, function (k, v) { if (k === '$$date') { return new Date(v); } if (typeof v === 'string' || typeof v === 'number' || typeof v === 'boolean' || v === null) { return v; } if (v && v.$$date) { return v.$$date; } return v; }); } /** * Deep copy a DB object */ function deepCopy (obj) { var res; if ( typeof obj === 'boolean' || typeof obj === 'number' || typeof obj === 'string' || obj === null || (obj && obj.constructor && obj.constructor.name === 'Date') ) { return obj; } if (obj instanceof Array) { res = []; obj.forEach(function (o) { res.push(o); }); return res; } if (typeof obj === 'object') { res = {}; Object.keys(obj).forEach(function (k) { res[k] = deepCopy(obj[k]); }); return res; } return undefined; // For now everything else is undefined. We should probably throw an error instead } // ============================================================== // Updating documents // ============================================================== /** * Set field to value in a model * Create it if it doesn't exist * @param {Object} obj The model to set a field for * @param {String} field Can contain dots, in that case that means we will set a subfield recursively * @param {Model} value */ modifierFunctions.$set = function (obj, field, value) { var fieldParts = typeof field === 'string' ? field.split('.') : field; if (fieldParts.length === 1) { obj[fieldParts[0]] = value; } else { obj[fieldParts[0]] = obj[fieldParts[0]] || {}; modifierFunctions.$set(obj[fieldParts[0]], fieldParts.slice(1), value); } }; /** * Increase (or decrease) a 'number' field * Create and initialize it if needed * @param {Object} obj The model to set a field for * @param {String} field Can contain dots, in that case that means we will set a subfield recursively * @param {Model} value */ modifierFunctions.$inc = function (obj, field, value) { var fieldParts = typeof field === 'string' ? field.split('.') : field; if (typeof value !== 'number') { throw value + " must be a number"; } if (fieldParts.length === 1) { if (typeof obj[fieldParts[0]] !== 'number') { if (!_.has(obj, fieldParts[0])) { obj[fieldParts[0]] = value; } else { throw "Don't use the $inc modifier on non-number fields"; } } else { obj[fieldParts[0]] += value; } } else { obj[fieldParts[0]] = obj[fieldParts[0]] || {}; modifierFunctions.$inc(obj[fieldParts[0]], fieldParts.slice(1), value); } }; /** * Modify a DB object according to an update query * For now the updateQuery only replaces the object */ function modify (obj, updateQuery) { var keys = Object.keys(updateQuery) , firstChars = _.map(keys, function (item) { return item[0]; }) , dollarFirstChars = _.filter(firstChars, function (c) { return c === '$'; }) , newDoc, modifiers ; if (keys.indexOf('_id') !== -1 && updateQuery._id !== obj._id) { throw "You cannot change a document's _id"; } if (dollarFirstChars.length !== 0 && dollarFirstChars.length !== firstChars.length) { throw "You cannot mix modifiers and normal fields"; } if (dollarFirstChars.length === 0) { // Simply replace the object with the update query contents newDoc = deepCopy(updateQuery); newDoc._id = obj._id; } else { // Apply modifiers modifiers = _.uniq(keys); newDoc = deepCopy(obj); modifiers.forEach(function (m) { var keys; if (!modifierFunctions[m]) { throw "Unknown modifier " + m; } try { keys = Object.keys(updateQuery[m]); } catch (e) { throw "Modifier " + m + "'s argument must be an object"; } keys.forEach(function (k) { modifierFunctions[m](newDoc, k, updateQuery[m][k]); }); }); } // Check result is valid and return it checkObject(newDoc); return newDoc; }; // ============================================================== // Finding documents // ============================================================== /** * Check whether 'things' are equal * Things are defined as any native types (string, number, boolean, null, date) and objects * In the case of object, we check deep equality * Returns true if they are, false otherwise */ function areThingsEqual (a, b) { var aKeys , bKeys , i; // Strings, booleans, numbers, null if (a === null || typeof a === 'string' || typeof a === 'boolean' || typeof a === 'number' || b === null || typeof b === 'string' || typeof b === 'boolean' || typeof b === 'number') { return a === b; } // Dates if (util.isDate(a) || util.isDate(b)) { return util.isDate(a) && util.isDate(b) && a.getTime() === b.getTime(); } // Arrays (no match since arrays are used as a $in) // undefined (no match since they mean field doesn't exist and can't be serialized) if (util.isArray(a) || util.isArray(b) || a === undefined || b === undefined) { return false; } // Objects (check for deep equality) // a and b should be objects at this point try { aKeys = Object.keys(a); bKeys = Object.keys(b); } catch (e) { return false; } if (aKeys.length !== bKeys.length) { return false; } for (i = 0; i < aKeys.length; i += 1) { if (bKeys.indexOf(aKeys[i]) === -1) { return false; } if (!areThingsEqual(a[aKeys[i]], b[aKeys[i]])) { return false; } } return true; } /** * Get a value from object with dot notation * @param {Object} obj * @param {String} field */ function getDotValue (obj, field) { var fieldParts = typeof field === 'string' ? field.split('.') : field; if (!obj) { return undefined; } // field cannot be empty so that means we should return undefined so that nothing can match if (fieldParts.length === 1) { return obj[fieldParts[0]]; } else { return getDotValue(obj[fieldParts[0]], fieldParts.slice(1)); } } /** * Test for field equality * @param {Object} objValue The value from the object to check * @param {Model} value The value from the query to check against */ matcherFunctions.$eq = function (objValue, value) { return areThingsEqual(objValue, value); }; /** * Match any of the subconditions */ matcherFunctions.$or = function (obj, query) { var i; if (!util.isArray(query)) { throw "$or operator used without an array"; } for (i = 0; i < query.length; i += 1) { if (match(obj, query[i])) { return true; } } return false; }; /** * Tell if a given document matches a query * @param {Object} obj Document to check * @param {Object} query */ function match (obj, query) { var queryKeys = Object.keys(query) , i ; for (i = 0; i < queryKeys.length; i += 1) { if (!matchQueryKey(obj, query, queryKeys[i])) { return false; } } return true; }; /** * Match a specific key of a given query */ function matchQueryKey (obj, query, queryKey) { var objValue = getDotValue(obj, queryKey) , queryValue = query[queryKey] , i ; if (queryKey[0] === '$') { // We apply an operator like $or, $and if (!matcherFunctions[queryKey]) { throw "Unknown query operator " + queryKey; } return matcherFunctions[queryKey](obj, queryValue); } else { // Normal field matching if (util.isArray(objValue)) { for (i = 0; i < objValue.length; i += 1) { if (matcher(objValue[i], queryValue)) { return true; } } return false; } else { if (!matcherFunctions.$eq(objValue, queryValue)) { return false; } } return true; } } // Interface module.exports.serialize = serialize; module.exports.deserialize = deserialize; module.exports.deepCopy = deepCopy; module.exports.checkObject = checkObject; module.exports.modify = modify; module.exports.getDotValue = getDotValue; module.exports.match = match; module.exports.areThingsEqual = areThingsEqual;