The JavaScript Database, for Node.js, nw.js, electron and the browser
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
nedb/lib/model.js

438 lines
12 KiB

/**
* 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 = {}
, comparisonFunctions = {}
, logicalOperators = {}
;
/**
* 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
// ==============================================================
/**
* 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));
}
}
/**
* 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;
}
/**
* Check that two values are comparable
*/
function areComparable (a, b) {
if (typeof a !== 'string' && typeof a !== 'number' && !util.isDate(a) &&
typeof b !== 'string' && typeof b !== 'number' && !util.isDate(b)) {
return false;
}
if (typeof a !== typeof b) { return false; }
return true;
}
/**
* Arithmetic comparators
*/
comparisonFunctions.$lt = function (a, b) {
return areComparable(a, b) && a < b;
};
comparisonFunctions.$lte = function (a, b) {
return areComparable(a, b) && a <= b;
};
comparisonFunctions.$gt = function (a, b) {
return areComparable(a, b) && a > b;
};
comparisonFunctions.$gte = function (a, b) {
return areComparable(a, b) && a >= b;
};
/**
* Match any of the subqueries
*/
logicalOperators.$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;
};
/**
* Match all of the subqueries
*/
logicalOperators.$and = function (obj, query) {
var i;
if (!util.isArray(query)) { throw "$and operator used without an array"; }
for (i = 0; i < query.length; i += 1) {
if (!match(obj, query[i])) { return false; }
}
return true;
};
/**
* 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 (!matchQueryPart(obj, queryKeys[i], query[queryKeys[i]])) { return false; }
}
return true;
};
/**
* Match an object against a specific { key: value } part of a query
*/
function matchQueryPart (obj, queryKey, queryValue) {
var objValue = getDotValue(obj, queryKey)
, i
, keys, firstChars, dollarFirstChars
;
// Query part begins with a logical operator: apply it
if (queryKey[0] === '$') {
if (!logicalOperators[queryKey]) { throw "Unknown logical operator " + queryKey; }
return logicalOperators[queryKey](obj, queryValue);
}
// Check if the object value is an array treat it as an array of { obj, query }
// Where there needs to be at least one match
if (util.isArray(objValue)) {
for (i = 0; i < objValue.length; i += 1) {
if (matchQueryPart({ k: objValue[i] }, 'k', queryValue)) { return true; } // k here could be any string
}
return false;
}
// queryValue is an actual object. Determine whether it contains comparison operators
// or only normal fields. Mixed objects are not allowed
if (queryValue !== null && typeof queryValue === 'object') {
keys = Object.keys(queryValue);
firstChars = _.map(keys, function (item) { return item[0]; })
dollarFirstChars = _.filter(firstChars, function (c) { return c === '$'; })
if (dollarFirstChars.length !== 0 && dollarFirstChars.length !== firstChars.length) {
throw "You cannot mix operators and normal fields";
}
// queryValue is an object of this form: { $comparisonOperator1: value1, ... }
if (dollarFirstChars.length > 0) {
for (i = 0; i < keys.length; i += 1) {
if (!comparisonFunctions[keys[i]]) { throw "Unknown comparison function " + keys[i]; }
if (!comparisonFunctions[keys[i]](objValue, queryValue[keys[i]])) { return false; }
}
return true;
}
}
// queryValue is either a native value or a normal object
// Simple matching is possible
if (!areThingsEqual(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;