helene
Version:
Real-time Web Apps for Node.js
505 lines • 18.4 kB
JavaScript
"use strict";
/**
* Handle models (i.e. docs)
* Serialization/deserialization
* Copying
* Querying, update
*/
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.checkKey = checkKey;
exports.checkObject = checkObject;
exports.deepCopy = deepCopy;
exports.isPrimitiveType = isPrimitiveType;
exports.compareThings = compareThings;
exports.modify = modify;
exports.getDotValue = getDotValue;
exports.areThingsEqual = areThingsEqual;
exports.areComparable = areComparable;
exports.match = match;
const isArray_1 = __importDefault(require("lodash/isArray"));
const isDate_1 = __importDefault(require("lodash/isDate"));
const isNull_1 = __importDefault(require("lodash/isNull"));
const isObject_1 = __importDefault(require("lodash/isObject"));
const isRegExp_1 = __importDefault(require("lodash/isRegExp"));
const map_1 = __importDefault(require("lodash/map"));
const filter_1 = __importDefault(require("lodash/filter"));
const uniq_1 = __importDefault(require("lodash/uniq"));
const comparison_functions_1 = require("./comparison-functions");
const last_step_modifier_functions_1 = require("./last-step-modifier-functions");
const logical_operators_1 = require("./logical-operators");
const modifierFunctions = {}, arrayComparisonFunctions = {
$size: true,
$elemMatch: true,
};
/**
* Check a key, throw an error if the key is non valid
* @param {String} key key
* @param {Model} value value, needed to treat the Date edge case
* Non-treatable edge cases here: if part of the object if of the form { $$date: number } or { $$deleted: true }
* 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(key, value) {
if (typeof key === 'number') {
key = key.toString();
}
if (key[0] === '$' &&
!(key === '$$date' && typeof value === 'number') &&
!(key === '$$deleted' && value === true) &&
!(key === '$$indexCreated') &&
!(key === '$$indexRemoved')) {
throw new Error('Field names cannot begin with the $ character');
}
if (key.indexOf('.') !== -1) {
throw new Error('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 ((0, isArray_1.default)(obj)) {
obj.forEach(function (o) {
checkObject(o);
});
}
if (typeof obj === 'object' && obj !== null) {
Object.keys(obj).forEach(function (k) {
checkKey(k, obj[k]);
checkObject(obj[k]);
});
}
}
/**
* Deep copy a DB object
* The optional strictKeys flag (defaulting to false) indicates whether to copy everything or only fields
* where the keys are valid, i.e. don't begin with $ and don't contain a .
*/
function deepCopy(obj, strictKeys = false) {
if (typeof obj === 'boolean' ||
typeof obj === 'number' ||
typeof obj === 'string' ||
obj === null ||
obj === undefined ||
(0, isDate_1.default)(obj)) {
return obj;
}
if ((0, isArray_1.default)(obj)) {
const res = [];
for (const o of obj) {
res.push(deepCopy(o, strictKeys));
}
return res;
}
if (typeof obj === 'object') {
const res = {};
for (const k in obj) {
if (!strictKeys || (k[0] !== '$' && k.indexOf('.') === -1)) {
res[k] = deepCopy(obj[k], strictKeys);
}
}
return res;
}
return undefined;
}
/**
* Tells if an object is a primitive type or a "real" object
* Arrays are considered primitive
*/
function isPrimitiveType(obj) {
return (typeof obj === 'boolean' ||
typeof obj === 'number' ||
typeof obj === 'string' ||
obj === null ||
(0, isDate_1.default)(obj) ||
(0, isArray_1.default)(obj));
}
/**
* Utility functions for comparing things
* Assumes type checking was already done (a and b already have the same type)
* compareNSB works for numbers, strings and booleans
*/
function compareNSB(a, b) {
if (a < b) {
return -1;
}
if (a > b) {
return 1;
}
return 0;
}
function compareArrays(a, b) {
let i, comp;
for (i = 0; i < Math.min(a.length, b.length); i += 1) {
comp = compareThings(a[i], b[i]);
if (comp !== 0) {
return comp;
}
}
// Common section was identical, longest one wins
return compareNSB(a.length, b.length);
}
/**
* Compare { things U undefined }
* Things are defined as any native types (string, number, boolean, null, date) and objects
* We need to compare with undefined as it will be used in indexes
* In the case of objects and arrays, we deep-compare
* If two objects dont have the same type, the (arbitrary) type hierarchy is: undefined, null, number, strings, boolean, dates, arrays, objects
* Return -1 if a < b, 1 if a > b and 0 if a = b (note that equality here is NOT the same as defined in areThingsEqual!)
*
* @param a
* @param b
* @param {Function} _compareStrings String comparing function, returning -1, 0 or 1, overriding default string comparison (useful for languages with accented letters)
*/
function compareThings(a, b, _compareStrings) {
let comp, i;
const compareStrings = _compareStrings || compareNSB;
// undefined
if (a === undefined) {
return b === undefined ? 0 : -1;
}
if (b === undefined) {
return 1;
}
// null
if (a === null) {
return b === null ? 0 : -1;
}
if (b === null) {
return 1;
}
// Numbers
if (typeof a === 'number') {
return typeof b === 'number' ? compareNSB(a, b) : -1;
}
if (typeof b === 'number') {
return typeof a === 'number' ? compareNSB(a, b) : 1;
}
// Strings
if (typeof a === 'string') {
return typeof b === 'string' ? compareStrings(a, b) : -1;
}
if (typeof b === 'string') {
return typeof a === 'string' ? compareStrings(a, b) : 1;
}
// Booleans
if (typeof a === 'boolean') {
return typeof b === 'boolean' ? compareNSB(a, b) : -1;
}
if (typeof b === 'boolean') {
return typeof a === 'boolean' ? compareNSB(a, b) : 1;
}
// Dates
if ((0, isDate_1.default)(a)) {
return (0, isDate_1.default)(b) ? compareNSB(a.getTime(), b.getTime()) : -1;
}
if ((0, isDate_1.default)(b)) {
return (0, isDate_1.default)(a) ? compareNSB(a.getTime(), b.getTime()) : 1;
}
// Arrays (the first element is most significant and so on)
if ((0, isArray_1.default)(a)) {
return (0, isArray_1.default)(b) ? compareArrays(a, b) : -1;
}
if ((0, isArray_1.default)(b)) {
return (0, isArray_1.default)(a) ? compareArrays(a, b) : 1;
}
// Objects
const aKeys = Object.keys(a).sort();
const bKeys = Object.keys(b).sort();
for (i = 0; i < Math.min(aKeys.length, bKeys.length); i += 1) {
comp = compareThings(a[aKeys[i]], b[bKeys[i]]);
if (comp !== 0) {
return comp;
}
}
return compareNSB(aKeys.length, bKeys.length);
}
// ==============================================================
// Updating documents
// ==============================================================
// Given its name, create the complete modifier function
function createModifierFunction(modifier) {
return function (obj, field, value) {
const fieldParts = typeof field === 'string' ? field.split('.') : field;
if (fieldParts.length === 1) {
last_step_modifier_functions_1.LastStepModifierFunctions[modifier](obj, field, value);
}
else {
if (obj[fieldParts[0]] === undefined) {
if (modifier === '$unset') {
return;
} // Bad looking specific fix, needs to be generalized modifiers that behave like $unset are implemented
obj[fieldParts[0]] = {};
}
const nextObj = obj[fieldParts[0]];
if ((0, isNull_1.default)(nextObj) || !(0, isObject_1.default)(nextObj)) {
return;
}
modifierFunctions[modifier](nextObj, fieldParts.slice(1), value);
}
};
}
// Actually create all modifier functions
Object.keys(last_step_modifier_functions_1.LastStepModifierFunctions).forEach(function (modifier) {
modifierFunctions[modifier] = createModifierFunction(modifier);
});
/**
* Modify a DB object according to an update query
*/
function modify(obj, updateQuery) {
const keys = Object.keys(updateQuery), firstChars = (0, map_1.default)(keys, function (item) {
return item[0];
}), dollarFirstChars = (0, filter_1.default)(firstChars, function (c) {
return c === '$';
});
let newDoc, modifiers;
if (keys.indexOf('_id') !== -1 && updateQuery._id !== obj._id) {
throw new Error("You cannot change a document's _id");
}
if (dollarFirstChars.length !== 0 &&
dollarFirstChars.length !== firstChars.length) {
throw new Error('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 = (0, uniq_1.default)(keys);
newDoc = deepCopy(obj);
modifiers.forEach(function (m) {
if (!modifierFunctions[m]) {
throw new Error('Unknown modifier ' + m);
}
// Can't rely on Object.keys throwing on non objects since ES6
// Not 100% satisfying as non objects can be interpreted as objects but no false negatives so we can live with it
if (typeof updateQuery[m] !== 'object') {
throw new Error('Modifier ' + m + "'s argument must be an object");
}
const keys = Object.keys(updateQuery[m]);
keys.forEach(function (k) {
modifierFunctions[m](newDoc, k, updateQuery[m][k]);
});
});
}
// Check result is valid and return it
checkObject(newDoc);
if (obj._id !== newDoc._id) {
throw new Error("You can't change a document's _id");
}
return newDoc;
}
// ==============================================================
// Finding documents
// ==============================================================
/**
* Get a value from object with dot notation
* @param {Object} obj
* @param {String} field
*/
function getDotValue(obj, field) {
const fieldParts = typeof field === 'string' ? field.split('.') : field;
let i, objs;
if (!obj) {
return undefined;
} // field cannot be empty so that means we should return undefined so that nothing can match
if (fieldParts.length === 0) {
return obj;
}
if (fieldParts.length === 1) {
return obj[fieldParts[0]];
}
if ((0, isArray_1.default)(obj[fieldParts[0]])) {
// If the next field is an integer, return only this item of the array
i = parseInt(fieldParts[1], 10);
if (typeof i === 'number' && !isNaN(i)) {
return getDotValue(obj[fieldParts[0]][i], fieldParts.slice(2));
}
// Return the array of values
objs = [];
for (i = 0; i < obj[fieldParts[0]].length; i += 1) {
objs.push(getDotValue(obj[fieldParts[0]][i], fieldParts.slice(1)));
}
return objs;
}
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) {
let 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 ((0, isDate_1.default)(a) || (0, isDate_1.default)(b)) {
return (0, isDate_1.default)(a) && (0, isDate_1.default)(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 ((!((0, isArray_1.default)(a) && (0, isArray_1.default)(b)) && ((0, isArray_1.default)(a) || (0, isArray_1.default)(b))) ||
a === undefined ||
b === undefined) {
return false;
}
// General 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' &&
!(0, isDate_1.default)(a) &&
typeof b !== 'string' &&
typeof b !== 'number' &&
!(0, isDate_1.default)(b)) {
return false;
}
return typeof a === typeof b;
}
/**
* Tell if a given document matches a query
* @param {Object} obj Document to check
* @param {Object} query
*/
function match(obj, query) {
let queryKey, queryValue, i;
// Primitive query against a primitive type
// This is a bit of a hack since we construct an object with an arbitrary key only to dereference it later
// But I don't have time for a cleaner implementation now
if (isPrimitiveType(obj) || isPrimitiveType(query)) {
return matchQueryPart({ needAKey: obj }, 'needAKey', query);
}
// Normal query
const queryKeys = Object.keys(query);
for (i = 0; i < queryKeys.length; i += 1) {
queryKey = queryKeys[i];
queryValue = query[queryKey];
if (queryKey[0] === '$') {
if (!logical_operators_1.LogicalOperators[queryKey]) {
throw new Error('Unknown logical operator ' + queryKey);
}
if (!logical_operators_1.LogicalOperators[queryKey](obj, queryValue)) {
return false;
}
}
else {
if (!matchQueryPart(obj, queryKey, queryValue)) {
return false;
}
}
}
return true;
}
/**
* Match an object against a specific { key: value } part of a query
* if the treatObjAsValue flag is set, don't try to match every part separately, but the array as a whole
*/
function matchQueryPart(obj, queryKey, queryValue, treatObjAsValue = false) {
const objValue = getDotValue(obj, queryKey);
let i, keys, firstChars, dollarFirstChars;
// Check if the value is an array if we don't force a treatment as value
if ((0, isArray_1.default)(objValue) && !treatObjAsValue) {
// If the queryValue is an array, try to perform an exact match
if ((0, isArray_1.default)(queryValue)) {
return matchQueryPart(obj, queryKey, queryValue, true);
}
// Check if we are using an array-specific comparison function
if (queryValue !== null &&
typeof queryValue === 'object' &&
!(0, isRegExp_1.default)(queryValue)) {
keys = Object.keys(queryValue);
for (i = 0; i < keys.length; i += 1) {
if (arrayComparisonFunctions[keys[i]]) {
return matchQueryPart(obj, queryKey, queryValue, true);
}
}
}
// If not, treat it as an array of { obj, query } where there needs to be at least one match
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' &&
!(0, isRegExp_1.default)(queryValue) &&
!(0, isArray_1.default)(queryValue)) {
keys = Object.keys(queryValue);
firstChars = (0, map_1.default)(keys, function (item) {
return item[0];
});
dollarFirstChars = (0, filter_1.default)(firstChars, function (c) {
return c === '$';
});
if (dollarFirstChars.length !== 0 &&
dollarFirstChars.length !== firstChars.length) {
throw new Error('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 (!comparison_functions_1.ComparisonFunctions[keys[i]]) {
throw new Error('Unknown comparison function ' + keys[i]);
}
if (!comparison_functions_1.ComparisonFunctions[keys[i]](objValue, queryValue[keys[i]])) {
return false;
}
}
return true;
}
}
// Using regular expressions with basic querying
if ((0, isRegExp_1.default)(queryValue)) {
return comparison_functions_1.ComparisonFunctions.$regex(objValue, queryValue);
}
// queryValue is either a native value or a normal object
// Basic matching is possible
return areThingsEqual(objValue, queryValue);
}
//# sourceMappingURL=model.js.map