|
|
|
@ -5,7 +5,13 @@ |
|
|
|
|
* Querying, update |
|
|
|
|
*/ |
|
|
|
|
const { uniq, isDate, isRegExp } = require('./utils.js') |
|
|
|
|
/** |
|
|
|
|
* @type {Object.<string, Model~modifierFunction>} |
|
|
|
|
*/ |
|
|
|
|
const modifierFunctions = {} |
|
|
|
|
/** |
|
|
|
|
* @type {Object.<string, Model~modifierFunction>} |
|
|
|
|
*/ |
|
|
|
|
const lastStepModifierFunctions = {} |
|
|
|
|
const comparisonFunctions = {} |
|
|
|
|
const logicalOperators = {} |
|
|
|
@ -13,8 +19,8 @@ const arrayComparisonFunctions = {} |
|
|
|
|
|
|
|
|
|
/** |
|
|
|
|
* 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 |
|
|
|
|
* @param {string} k key |
|
|
|
|
* @param {document} v 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... |
|
|
|
@ -36,6 +42,7 @@ const checkKey = (k, v) => { |
|
|
|
|
/** |
|
|
|
|
* Check a DB object and throw an error if it's not valid |
|
|
|
|
* Works by applying the above checkKey function to all fields recursively |
|
|
|
|
* @param {document|document[]} obj |
|
|
|
|
*/ |
|
|
|
|
const checkObject = obj => { |
|
|
|
|
if (Array.isArray(obj)) { |
|
|
|
@ -61,6 +68,8 @@ const checkObject = obj => { |
|
|
|
|
* so eval and the like are not safe |
|
|
|
|
* Accepted primitive types: Number, String, Boolean, Date, null |
|
|
|
|
* Accepted secondary types: Objects, Arrays |
|
|
|
|
* @param {document} obj |
|
|
|
|
* @return {string} |
|
|
|
|
*/ |
|
|
|
|
const serialize = obj => { |
|
|
|
|
return JSON.stringify(obj, function (k, v) { |
|
|
|
@ -80,6 +89,8 @@ const serialize = obj => { |
|
|
|
|
/** |
|
|
|
|
* From a one-line representation of an object generate by the serialize function |
|
|
|
|
* Return the object itself |
|
|
|
|
* @param {string} rawData |
|
|
|
|
* @return {document} |
|
|
|
|
*/ |
|
|
|
|
const deserialize = rawData => JSON.parse(rawData, function (k, v) { |
|
|
|
|
if (k === '$$date') return new Date(v) |
|
|
|
@ -98,6 +109,9 @@ const deserialize = rawData => JSON.parse(rawData, function (k, v) { |
|
|
|
|
* 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 . |
|
|
|
|
* @param {?document} obj |
|
|
|
|
* @param {boolean} [strictKeys=false] |
|
|
|
|
* @return {?document} |
|
|
|
|
*/ |
|
|
|
|
function deepCopy (obj, strictKeys) { |
|
|
|
|
if ( |
|
|
|
@ -129,6 +143,8 @@ function deepCopy (obj, strictKeys) { |
|
|
|
|
/** |
|
|
|
|
* Tells if an object is a primitive type or a "real" object |
|
|
|
|
* Arrays are considered primitive |
|
|
|
|
* @param {*} obj |
|
|
|
|
* @return {boolean} |
|
|
|
|
*/ |
|
|
|
|
const isPrimitiveType = obj => ( |
|
|
|
|
typeof obj === 'boolean' || |
|
|
|
@ -143,6 +159,9 @@ const isPrimitiveType = 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 |
|
|
|
|
* @param {number|string|boolean} a |
|
|
|
|
* @param {number|string|boolean} b |
|
|
|
|
* @return {number} 0 if a == b, 1 i a > b, -1 if a < b |
|
|
|
|
*/ |
|
|
|
|
const compareNSB = (a, b) => { |
|
|
|
|
if (a < b) return -1 |
|
|
|
@ -150,6 +169,14 @@ const compareNSB = (a, b) => { |
|
|
|
|
return 0 |
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
/** |
|
|
|
|
* Utility function for comparing array |
|
|
|
|
* Assumes type checking was already done (a and b already have the same type) |
|
|
|
|
* compareNSB works for numbers, strings and booleans |
|
|
|
|
* @param {Array} a |
|
|
|
|
* @param {Array} b |
|
|
|
|
* @return {number} 0 if arrays have the same length and all elements equal one another. Else either 1 or -1. |
|
|
|
|
*/ |
|
|
|
|
const compareArrays = (a, b) => { |
|
|
|
|
const minLength = Math.min(a.length, b.length) |
|
|
|
|
for (let i = 0; i < minLength; i += 1) { |
|
|
|
@ -169,8 +196,10 @@ const compareArrays = (a, b) => { |
|
|
|
|
* 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 {Function} _compareStrings String comparing function, returning -1, 0 or 1, overriding default string comparison (useful for languages with accented letters) |
|
|
|
|
* @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) |
|
|
|
|
* @return {number} |
|
|
|
|
*/ |
|
|
|
|
const compareThings = (a, b, _compareStrings) => { |
|
|
|
|
const compareStrings = _compareStrings || compareNSB |
|
|
|
@ -221,16 +250,18 @@ const compareThings = (a, b, _compareStrings) => { |
|
|
|
|
// ==============================================================
|
|
|
|
|
|
|
|
|
|
/** |
|
|
|
|
* @callback Model~modifierFunction |
|
|
|
|
* The signature of modifier functions is as follows |
|
|
|
|
* Their structure is always the same: recursively follow the dot notation while creating |
|
|
|
|
* the nested documents if needed, then apply the "last step modifier" |
|
|
|
|
* @param {Object} obj The model to modify |
|
|
|
|
* @param {String} field Can contain dots, in that case that means we will set a subfield recursively |
|
|
|
|
* @param {Model} value |
|
|
|
|
* @param {document} value |
|
|
|
|
*/ |
|
|
|
|
|
|
|
|
|
/** |
|
|
|
|
* Set a field to a new value |
|
|
|
|
* @type Model~modifierFunction |
|
|
|
|
*/ |
|
|
|
|
lastStepModifierFunctions.$set = (obj, field, value) => { |
|
|
|
|
obj[field] = value |
|
|
|
@ -238,6 +269,7 @@ lastStepModifierFunctions.$set = (obj, field, value) => { |
|
|
|
|
|
|
|
|
|
/** |
|
|
|
|
* Unset a field |
|
|
|
|
* @type Model~modifierFunction |
|
|
|
|
*/ |
|
|
|
|
lastStepModifierFunctions.$unset = (obj, field, value) => { |
|
|
|
|
delete obj[field] |
|
|
|
@ -248,6 +280,7 @@ lastStepModifierFunctions.$unset = (obj, field, value) => { |
|
|
|
|
* Optional modifier $each instead of value to push several values |
|
|
|
|
* Optional modifier $slice to slice the resulting array, see https://docs.mongodb.org/manual/reference/operator/update/slice/
|
|
|
|
|
* Différeence with MongoDB: if $slice is specified and not $each, we act as if value is an empty array |
|
|
|
|
* @type Model~modifierFunction |
|
|
|
|
*/ |
|
|
|
|
lastStepModifierFunctions.$push = (obj, field, value) => { |
|
|
|
|
// Create the array if it doesn't exist
|
|
|
|
@ -298,6 +331,7 @@ lastStepModifierFunctions.$push = (obj, field, value) => { |
|
|
|
|
* Add an element to an array field only if it is not already in it |
|
|
|
|
* No modification if the element is already in the array |
|
|
|
|
* Note that it doesn't check whether the original array contains duplicates |
|
|
|
|
* @type Model~modifierFunction |
|
|
|
|
*/ |
|
|
|
|
lastStepModifierFunctions.$addToSet = (obj, field, value) => { |
|
|
|
|
// Create the array if it doesn't exist
|
|
|
|
@ -323,6 +357,7 @@ lastStepModifierFunctions.$addToSet = (obj, field, value) => { |
|
|
|
|
|
|
|
|
|
/** |
|
|
|
|
* Remove the first or last element of an array |
|
|
|
|
* @type Model~modifierFunction |
|
|
|
|
*/ |
|
|
|
|
lastStepModifierFunctions.$pop = (obj, field, value) => { |
|
|
|
|
if (!Array.isArray(obj[field])) throw new Error('Can\'t $pop an element from non-array values') |
|
|
|
@ -335,6 +370,7 @@ lastStepModifierFunctions.$pop = (obj, field, value) => { |
|
|
|
|
|
|
|
|
|
/** |
|
|
|
|
* Removes all instances of a value from an existing array |
|
|
|
|
* @type Model~modifierFunction |
|
|
|
|
*/ |
|
|
|
|
lastStepModifierFunctions.$pull = (obj, field, value) => { |
|
|
|
|
if (!Array.isArray(obj[field])) throw new Error('Can\'t $pull an element from non-array values') |
|
|
|
@ -347,6 +383,7 @@ lastStepModifierFunctions.$pull = (obj, field, value) => { |
|
|
|
|
|
|
|
|
|
/** |
|
|
|
|
* Increment a numeric field's value |
|
|
|
|
* @type Model~modifierFunction |
|
|
|
|
*/ |
|
|
|
|
lastStepModifierFunctions.$inc = (obj, field, value) => { |
|
|
|
|
if (typeof value !== 'number') throw new Error(`${value} must be a number`) |
|
|
|
@ -359,6 +396,7 @@ lastStepModifierFunctions.$inc = (obj, field, value) => { |
|
|
|
|
|
|
|
|
|
/** |
|
|
|
|
* Updates the value of the field, only if specified field is greater than the current value of the field |
|
|
|
|
* @type Model~modifierFunction |
|
|
|
|
*/ |
|
|
|
|
lastStepModifierFunctions.$max = (obj, field, value) => { |
|
|
|
|
if (typeof obj[field] === 'undefined') obj[field] = value |
|
|
|
@ -367,13 +405,18 @@ lastStepModifierFunctions.$max = (obj, field, value) => { |
|
|
|
|
|
|
|
|
|
/** |
|
|
|
|
* Updates the value of the field, only if specified field is smaller than the current value of the field |
|
|
|
|
* @type Model~modifierFunction |
|
|
|
|
*/ |
|
|
|
|
lastStepModifierFunctions.$min = (obj, field, value) => { |
|
|
|
|
if (typeof obj[field] === 'undefined') obj[field] = value |
|
|
|
|
else if (value < obj[field]) obj[field] = value |
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
// Given its name, create the complete modifier function
|
|
|
|
|
/** |
|
|
|
|
* Create the complete modifier function |
|
|
|
|
* @param {string} modifier one of lastStepModifierFunctions keys |
|
|
|
|
* @return {Model~modifierFunction} |
|
|
|
|
*/ |
|
|
|
|
const createModifierFunction = modifier => (obj, field, value) => { |
|
|
|
|
const fieldParts = typeof field === 'string' ? field.split('.') : field |
|
|
|
|
|
|
|
|
@ -394,6 +437,9 @@ Object.keys(lastStepModifierFunctions).forEach(modifier => { |
|
|
|
|
|
|
|
|
|
/** |
|
|
|
|
* Modify a DB object according to an update query |
|
|
|
|
* @param {document} obj |
|
|
|
|
* @param {query} updateQuery |
|
|
|
|
* @return {document} |
|
|
|
|
*/ |
|
|
|
|
const modify = (obj, updateQuery) => { |
|
|
|
|
const keys = Object.keys(updateQuery) |
|
|
|
@ -441,8 +487,9 @@ const modify = (obj, updateQuery) => { |
|
|
|
|
|
|
|
|
|
/** |
|
|
|
|
* Get a value from object with dot notation |
|
|
|
|
* @param {Object} obj |
|
|
|
|
* @param {String} field |
|
|
|
|
* @param {object} obj |
|
|
|
|
* @param {string} field |
|
|
|
|
* @return {*} |
|
|
|
|
*/ |
|
|
|
|
const getDotValue = (obj, field) => { |
|
|
|
|
const fieldParts = typeof field === 'string' ? field.split('.') : field |
|
|
|
@ -468,6 +515,9 @@ const getDotValue = (obj, field) => { |
|
|
|
|
* 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 |
|
|
|
|
* @param {*} a |
|
|
|
|
* @param {*} a |
|
|
|
|
* @return {boolean} |
|
|
|
|
*/ |
|
|
|
|
const areThingsEqual = (a, b) => { |
|
|
|
|
// Strings, booleans, numbers, null
|
|
|
|
@ -513,6 +563,9 @@ const areThingsEqual = (a, b) => { |
|
|
|
|
|
|
|
|
|
/** |
|
|
|
|
* Check that two values are comparable |
|
|
|
|
* @param {*} a |
|
|
|
|
* @param {*} a |
|
|
|
|
* @return {boolean} |
|
|
|
|
*/ |
|
|
|
|
const areComparable = (a, b) => { |
|
|
|
|
if ( |
|
|
|
@ -530,20 +583,47 @@ const areComparable = (a, b) => { |
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
/** |
|
|
|
|
* @callback Model~comparisonOperator |
|
|
|
|
* Arithmetic and comparison operators |
|
|
|
|
* @param {Native value} a Value in the object |
|
|
|
|
* @param {Native value} b Value in the query |
|
|
|
|
* @param {*} a Value in the object |
|
|
|
|
* @param {*} b Value in the query |
|
|
|
|
* @return {boolean} |
|
|
|
|
*/ |
|
|
|
|
|
|
|
|
|
/** |
|
|
|
|
* Lower than |
|
|
|
|
* @type Model~comparisonOperator |
|
|
|
|
*/ |
|
|
|
|
comparisonFunctions.$lt = (a, b) => areComparable(a, b) && a < b |
|
|
|
|
|
|
|
|
|
/** |
|
|
|
|
* Lower than or equals |
|
|
|
|
* @type Model~comparisonOperator |
|
|
|
|
*/ |
|
|
|
|
comparisonFunctions.$lte = (a, b) => areComparable(a, b) && a <= b |
|
|
|
|
|
|
|
|
|
/** |
|
|
|
|
* Greater than |
|
|
|
|
* @type Model~comparisonOperator |
|
|
|
|
*/ |
|
|
|
|
comparisonFunctions.$gt = (a, b) => areComparable(a, b) && a > b |
|
|
|
|
|
|
|
|
|
/** |
|
|
|
|
* Greater than or equals |
|
|
|
|
* @type Model~comparisonOperator |
|
|
|
|
*/ |
|
|
|
|
comparisonFunctions.$gte = (a, b) => areComparable(a, b) && a >= b |
|
|
|
|
|
|
|
|
|
/** |
|
|
|
|
* Does not equal |
|
|
|
|
* @type Model~comparisonOperator |
|
|
|
|
*/ |
|
|
|
|
comparisonFunctions.$ne = (a, b) => a === undefined || !areThingsEqual(a, b) |
|
|
|
|
|
|
|
|
|
/** |
|
|
|
|
* Is in Array |
|
|
|
|
* @type Model~comparisonOperator |
|
|
|
|
*/ |
|
|
|
|
comparisonFunctions.$in = (a, b) => { |
|
|
|
|
if (!Array.isArray(b)) throw new Error('$in operator called with a non-array') |
|
|
|
|
|
|
|
|
@ -553,13 +633,20 @@ comparisonFunctions.$in = (a, b) => { |
|
|
|
|
|
|
|
|
|
return false |
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
/** |
|
|
|
|
* Is not in Array |
|
|
|
|
* @type Model~comparisonOperator |
|
|
|
|
*/ |
|
|
|
|
comparisonFunctions.$nin = (a, b) => { |
|
|
|
|
if (!Array.isArray(b)) throw new Error('$nin operator called with a non-array') |
|
|
|
|
|
|
|
|
|
return !comparisonFunctions.$in(a, b) |
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
/** |
|
|
|
|
* Matches Regexp |
|
|
|
|
* @type Model~comparisonOperator |
|
|
|
|
*/ |
|
|
|
|
comparisonFunctions.$regex = (a, b) => { |
|
|
|
|
if (!isRegExp(b)) throw new Error('$regex operator called with non regular expression') |
|
|
|
|
|
|
|
|
@ -567,27 +654,38 @@ comparisonFunctions.$regex = (a, b) => { |
|
|
|
|
else return b.test(a) |
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
comparisonFunctions.$exists = (value, exists) => { |
|
|
|
|
/** |
|
|
|
|
* Returns true if field exists |
|
|
|
|
* @type Model~comparisonOperator |
|
|
|
|
*/ |
|
|
|
|
comparisonFunctions.$exists = (a, b) => { |
|
|
|
|
// This will be true for all values of stat except false, null, undefined and 0
|
|
|
|
|
// That's strange behaviour (we should only use true/false) but that's the way Mongo does it...
|
|
|
|
|
if (exists || exists === '') exists = true |
|
|
|
|
else exists = false |
|
|
|
|
if (b || b === '') b = true |
|
|
|
|
else b = false |
|
|
|
|
|
|
|
|
|
if (value === undefined) return !exists |
|
|
|
|
else return exists |
|
|
|
|
if (a === undefined) return !b |
|
|
|
|
else return b |
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
// Specific to arrays
|
|
|
|
|
comparisonFunctions.$size = (obj, value) => { |
|
|
|
|
if (!Array.isArray(obj)) return false |
|
|
|
|
if (value % 1 !== 0) throw new Error('$size operator called without an integer') |
|
|
|
|
/** |
|
|
|
|
* Specific to Arrays, returns true if a length equals b |
|
|
|
|
* @type Model~comparisonOperator |
|
|
|
|
*/ |
|
|
|
|
comparisonFunctions.$size = (a, b) => { |
|
|
|
|
if (!Array.isArray(a)) return false |
|
|
|
|
if (b % 1 !== 0) throw new Error('$size operator called without an integer') |
|
|
|
|
|
|
|
|
|
return obj.length === value |
|
|
|
|
return a.length === b |
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
comparisonFunctions.$elemMatch = (obj, value) => { |
|
|
|
|
if (!Array.isArray(obj)) return false |
|
|
|
|
return obj.some(el => match(el, value)) |
|
|
|
|
/** |
|
|
|
|
* Specific to Arrays, returns true if some elements of a match the query b |
|
|
|
|
* @type Model~comparisonOperator |
|
|
|
|
*/ |
|
|
|
|
comparisonFunctions.$elemMatch = (a, b) => { |
|
|
|
|
if (!Array.isArray(a)) return false |
|
|
|
|
return a.some(el => match(el, b)) |
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
arrayComparisonFunctions.$size = true |
|
|
|
@ -595,8 +693,9 @@ arrayComparisonFunctions.$elemMatch = true |
|
|
|
|
|
|
|
|
|
/** |
|
|
|
|
* Match any of the subqueries |
|
|
|
|
* @param {Model} obj |
|
|
|
|
* @param {Array of Queries} query |
|
|
|
|
* @param {document} obj |
|
|
|
|
* @param {query[]} query |
|
|
|
|
* @return {boolean} |
|
|
|
|
*/ |
|
|
|
|
logicalOperators.$or = (obj, query) => { |
|
|
|
|
if (!Array.isArray(query)) throw new Error('$or operator used without an array') |
|
|
|
@ -610,8 +709,9 @@ logicalOperators.$or = (obj, query) => { |
|
|
|
|
|
|
|
|
|
/** |
|
|
|
|
* Match all of the subqueries |
|
|
|
|
* @param {Model} obj |
|
|
|
|
* @param {Array of Queries} query |
|
|
|
|
* @param {document} obj |
|
|
|
|
* @param {query[]} query |
|
|
|
|
* @return {boolean} |
|
|
|
|
*/ |
|
|
|
|
logicalOperators.$and = (obj, query) => { |
|
|
|
|
if (!Array.isArray(query)) throw new Error('$and operator used without an array') |
|
|
|
@ -625,15 +725,23 @@ logicalOperators.$and = (obj, query) => { |
|
|
|
|
|
|
|
|
|
/** |
|
|
|
|
* Inverted match of the query |
|
|
|
|
* @param {Model} obj |
|
|
|
|
* @param {Query} query |
|
|
|
|
* @param {document} obj |
|
|
|
|
* @param {query} query |
|
|
|
|
* @return {boolean} |
|
|
|
|
*/ |
|
|
|
|
logicalOperators.$not = (obj, query) => !match(obj, query) |
|
|
|
|
|
|
|
|
|
/** |
|
|
|
|
* @callback Model~whereCallback |
|
|
|
|
* @param {document} obj |
|
|
|
|
* @return {boolean} |
|
|
|
|
*/ |
|
|
|
|
|
|
|
|
|
/** |
|
|
|
|
* Use a function to match |
|
|
|
|
* @param {Model} obj |
|
|
|
|
* @param {Query} query |
|
|
|
|
* @param {document} obj |
|
|
|
|
* @param {Model~whereCallback} fn |
|
|
|
|
* @return {boolean} |
|
|
|
|
*/ |
|
|
|
|
logicalOperators.$where = (obj, fn) => { |
|
|
|
|
if (typeof fn !== 'function') throw new Error('$where operator used without a function') |
|
|
|
@ -646,8 +754,9 @@ logicalOperators.$where = (obj, fn) => { |
|
|
|
|
|
|
|
|
|
/** |
|
|
|
|
* Tell if a given document matches a query |
|
|
|
|
* @param {Object} obj Document to check |
|
|
|
|
* @param {Object} query |
|
|
|
|
* @param {document} obj Document to check |
|
|
|
|
* @param {query} query |
|
|
|
|
* @return {boolean} |
|
|
|
|
*/ |
|
|
|
|
const match = (obj, query) => { |
|
|
|
|
// Primitive query against a primitive type
|
|
|
|
@ -673,6 +782,15 @@ const match = (obj, query) => { |
|
|
|
|
* 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 |
|
|
|
|
*/ |
|
|
|
|
/** |
|
|
|
|
* 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 |
|
|
|
|
* @param {object} obj |
|
|
|
|
* @param {string} queryKey |
|
|
|
|
* @param {*} queryValue |
|
|
|
|
* @param {boolean} [treatObjAsValue=false] |
|
|
|
|
* @return {boolean} |
|
|
|
|
*/ |
|
|
|
|
function matchQueryPart (obj, queryKey, queryValue, treatObjAsValue) { |
|
|
|
|
const objValue = getDotValue(obj, queryKey) |
|
|
|
|
|
|
|
|
|