UNPKG

mongo-portable

Version:

Portable Pure JS MongoDB - Based on Monglodb (https://github.com/euforic/monglodb.git) by Christian Sullivan (http://RogueSynaptics.com)

1,415 lines (1,163 loc) 36.8 kB
import { JSWLogger } from "jsw-logger"; import * as _ from "lodash"; import * as Promise from "promise"; import { Cursor } from "./Cursor"; import { Aggregation } from "../aggregation"; import { ObjectId } from "../document"; import { EventEmitter } from "../emitter"; import { Selector, SelectorMatcher } from "../selector"; /*** * Gets the size of an object. * * @method Object#size * * @param {Object} obj - The object * * @returns {Number} The size of the object */ const getObjectSize = (obj) => { let size = 0; let key; for (key in obj) { if (obj.hasOwnProperty(key)) { size++; } } return size; }; // module.exports = function(Aggregation, Cursor, Selector, SelectorMatcher, ObjectId, EventEmitter, Logger, _) { /*** * Collection * * @module Collection * @constructor * @since 0.0.1 * @author Eduardo Astolfi <eastolfi91@gmail.com> * @copyright 2016 Eduardo Astolfi <eastolfi91@gmail.com> * @license MIT Licensed * * @classdesc Collection class that maps a MongoDB-like collection */ let database = null; export class Collection /*extends EventEmitter*/ { /*** * @ignore */ public static _noCreateModifiers = { $unset: true, $pop: true, $rename: true, $pull: true, $pullAll: true }; public name; public databaseName; public fullName; public docs; // tslint:disable-next-line:variable-name public doc_indexes; public snapshots; // opts; public emit: ((name: string, args: object) => Promise<void>); protected logger: JSWLogger; // var Collection = function(db, collectionName, options) { /*** * @param {MongoPortable} db - Additional options * @param {String} collectionName - The name of the collection * @param {Object} [options] - Database object * * @param {Object} [options.pkFactory=null] - Object overriding the basic "ObjectId" primary key generation. */ constructor(db, collectionName/*, options*/) { // super(options.log || {}); // super(); if (!(this instanceof Collection)) { return new Collection(db, collectionName/*, options*/); } this.logger = JSWLogger.instance; if (_.isNil(db)) { this.logger.throw("db parameter required"); } if (_.isNil(collectionName)) { this.logger.throw("collectionName parameter required"); } // if (_.isNil(options) || !_.isPlainObject(options)) options = {}; Collection.checkCollectionName(collectionName); // this.db = db; database = db; this.name = collectionName; this.databaseName = db._databaseName; this.fullName = this.databaseName + "." + this.name; this.docs = []; this.doc_indexes = {}; this.snapshots = []; // this.opts = {}; // Default options // _.merge(this.opts, options); this.emit = (name, args): Promise<void> => { return db.emit(name, args); }; } // emit(name, args) { // super.emit(name, args, database._stores); // } // TODO enforce rule that field names can't start with '$' or contain '.' // (real mongodb does in fact enforce this) // TODO possibly enforce that 'undefined' does not appear (we assume // this in our handling of null and $exists) /*** * Inserts a document into the collection * * @method Collection#insert * * @param {Object} doc - Document to be inserted * @param {Object} [options] - Additional options * * @param {Function} [callback=null] Callback function to be called at the end with the results * * @returns {Promise<Object>} Returns a promise with the inserted document */ public insert(doc, options, callback?): Promise<any> { const self = this; return new Promise((resolve, reject) => { // REJECT if (_.isNil(doc)) { self.logger.throw("doc parameter required"); } if (!_.isPlainObject(doc)) { self.logger.throw("doc must be an object"); } if (_.isNil(options)) { options = {}; } if (_.isFunction(options)) { callback = options; options = {}; } if (!_.isNil(callback) && !_.isFunction(callback)) { self.logger.throw("callback must be a function"); } // Creating a safe copy of the document const _doc = _.cloneDeep(doc); // If the document comes with a number ID, parse it to String if (_.isNumber(_doc._id)) { _doc._id = _.toString(_doc._id); } if (_.isNil(_doc._id) || (!(_doc._id instanceof ObjectId) && (!_.isString(_doc._id) || !_doc._id.length))) { _doc._id = new ObjectId(); } // Add options to more dates _doc.timestamp = new ObjectId().generationTime; // Reverse self.doc_indexes[_.toString(_doc._id)] = self.docs.length; self.docs.push(_doc); /*** * "insert" event. * * @event MongoPortable~insert * * @param {Object} collection - Information about the collection * @param {Object} doc - Information about the document inserted */ self.emit("insert", { collection: self, doc: _doc }).then(() => { if (callback) { callback(null, _doc); } resolve(_doc); }).catch((error) => { // EXCEPTION UTIL if (callback) { callback(error, null); } reject(error); }); }); } /*** * Inserts several documents into the collection * * @method Collection#bulkInsert * * @param {Array} docs - Documents to be inserted * @param {Object} [options] - Additional options * * @param {Function} [callback=null] Callback function to be called at the end with the results * * @returns {Promise<Array<Object>>} Returns a promise with the inserted documents */ public bulkInsert = function(docs, options, callback?) { const self = this; return new Promise((resolve, reject) => { if (_.isNil(docs)) { self.logger.throw("docs parameter required"); } if (!_.isArray(docs)) { self.logger.throw("docs must be an array"); } if (_.isNil(options)) { options = {}; } if (_.isFunction(options)) { callback = options; options = {}; } if (!_.isNil(callback) && !_.isFunction(callback)) { self.logger.throw("callback must be a function"); } const promises = []; for (const doc of docs) { promises.push(self.insert(doc, options)); } Promise.all(promises) .then((_docs) => { if (callback) { callback(null, _docs); } resolve(docs); }).catch((error) => { if (callback) { callback(error, null); } reject(error); }); }); }; /*** * Finds all matching documents * * @method Collection#find * * @param {Object|Array|String} [selection={}] - The selection for matching documents * @param {Object|Array|String} [fields={}] - The fields of the document to show * @param {Object} [options] - Additional options * * @param {Number} [options.skip] - Number of documents to be skipped * @param {Number} [options.limit] - Max number of documents to display * @param {Object|Array|String} [options.fields] - Same as "fields" parameter (if both passed, "options.fields" will be ignored) * @param {Boolean} [options.doNotFetch=false] - If set to'"true" returns the cursor not fetched * * @param {Function} [callback=null] - Callback function to be called at the end with the results * * @returns {Promise<Array<Object>|Cursor>} Returns a promise with the documents (or cursor if "options.forceFetch" set to true) */ public find(selection, fields, options, callback?): Promise<object[] | Cursor> { const self = this; return new Promise((resolve, reject) => { const params = ensureFindParams({ selection, fields, options, callback }); selection = params.selection; fields = params.fields; options = params.options; callback = params.callback; /*** * "find" event. * * @event MongoPortable~find * * @property {Object} collection - Information about the collection * @property {Object} selector - The selection of the query * @property {Object} fields - The fields showed in the query */ self.emit("find", { collection: self, selector: selection, fields }).then(() => { const cursor = new Cursor(self.docs, selection, fields, options); // Pass the cursor fetched to the callback if (options.doNotFecth) { if (callback) { callback(null, cursor); } resolve(cursor); } else { const docs = cursor.fetch(); if (callback) { callback(null, docs); } resolve(docs); } }).catch((error) => { if (callback) { callback(error, null); } reject(error); }); }); } /*** * Finds the first matching document * * @method Collection#findOne * * @param {Object|Array|String} [selection={}] - The selection for matching documents * @param {Object|Array|String} [fields={}] - The fields of the document to show * @param {Object} [options] - Additional options * * @param {Number} [options.skip] - Number of documents to be skipped * @param {Number} [options.limit] - Max number of documents to display * @param {Object|Array|String} [options.fields] - Same as "fields" parameter (if both passed, "options.fields" will be ignored) * * @param {Function} [callback=null] - Callback function to be called at the end with the results * * @returns {Promise<Object>} Returns a promise with the first matching document of the collection */ public findOne(selection, fields, options, callback?): Promise<any> { const self = this; return new Promise((resolve, reject) => { const params = ensureFindParams({ selection, fields, options, callback }); selection = params.selection; fields = params.fields; options = params.options; callback = params.callback; /*** * "findOne" event. * * @event MongoPortable~findOne * * @property {Object} collection - Information about the collection * @property {Object} selector - The selection of the query * @property {Object} fields - The fields showed in the query */ self.emit("findOne", { collection: self, selector: selection, fields }).then(() => { const cursor = new Cursor(self.docs, selection, fields, options); let res = null; if (cursor.hasNext()) { res = cursor.next(); } if (callback) { callback(null, res); } resolve(res); }).catch((error) => { if (callback) { callback(error, null); } reject(error); }); }); } /*** * Updates one or many documents * * @method Collection#update * * @param {Object|Array|String} [selection={}] - The selection for matching documents * @param {Object} [update={}] - The update operation * @param {Object} [options] - Additional options * * @param {Number} [options.updateAsMongo=true] - By default: * If the [update] object contains update operator modifiers, such as those using the "$set" modifier, then: * <ul> * <li>The [update] object must contain only update operator expressions</li> * <li>The Collection#update method updates only the corresponding fields in the document</li> * <ul> * If the [update] object contains only "field: value" expressions, then: * <ul> * <li>The Collection#update method replaces the matching document with the [update] object. The Collection#update method does not replace the "_id" value</li> * <li>Collection#update cannot update multiple documents</li> * <ul> * * @param {Number} [options.override=false] - Replaces the whole document (only apllies when [updateAsMongo=false]) * @param {Number} [options.upsert=false] - Creates a new document when no document matches the query criteria * @param {Number} [options.multi=false] - Updates multiple documents that meet the criteria * @param {Object} [options.writeConcern=null] - An object expressing the write concern * * @param {Function} [callback=null] - Callback function to be called at the end with the results * * @returns {Promise<Object>} Returns a promise with the update/insert (if upsert=true) information */ public update(selection, update, options, callback?): Promise<any> { const self = this; return new Promise((resolve, reject) => { if (_.isNil(selection)) { selection = {}; } if (_.isNil(update)) { self.logger.throw("You must specify the update operation"); } if (_.isNil(options)) { options = { skip: 0, limit: 15 // for no limit pass [options.limit = -1] }; } if (_.isFunction(selection)) { self.logger.throw("You must specify the update operation"); } if (_.isFunction(update)) { self.logger.throw("You must specify the update operation"); } if (_.isFunction(options)) { callback = options; options = {}; } // Check special case where we are using an objectId if (selection instanceof ObjectId) { selection = { _id: selection }; } if (!_.isNil(callback) && !_.isFunction(callback)) { self.logger.throw("callback must be a function"); } const res = null; // var docs = null; if (options.multi) { // docs = self.find(selection, null, { forceFetch: true }); self.find(selection, null, { forceFetch: true }) .then(onDocsFound) .catch(doReject); } else { // docs = self.findOne(selection); self.findOne(selection, null, null, callback) .then(onDocsFound) .catch(doReject); } function onDocsFound(docs: object | object[]) { if (_.isNil(docs)) { docs = []; } if (!_.isArray(docs)) { docs = [docs]; } if ((docs as object[]).length === 0) { if (options.upsert) { self.insert(update, null, callback) .then((inserted) => { doResolve({ updated: { documents: null, count: 0 }, inserted: { documents: [inserted], count: 1 } }); }).catch(doReject); // res = { // updated: { // documents: null, // count: 0 // }, // inserted: { // documents: [inserted], // count: 1 // } // }; } else { // No documents found /*res = */doResolve({ updated: { documents: null, count: 0 }, inserted: { documents: null, count: 0 } }); } } else { const updatedDocs = []; for (let i = 0; i < (docs as object[]).length; i++) { const doc = docs[i]; let override = null; let hasModifier = false; for (const key of Object.keys(update)) { // IE7 doesn't support indexing into strings (eg, key[0] or key.indexOf('$') ), so use substr. // Testing over the first letter: // Bests result with 1e8 loops => key[0](~3s) > substr(~5s) > regexp(~6s) > indexOf(~16s) const modifier = (key.substr(0, 1) === "$"); if (modifier) { hasModifier = true; } if (options.updateAsMongo) { if (hasModifier && !modifier) { self.logger.throw("All update fields must be an update operator"); } if (!hasModifier && options.multi) { self.logger.throw("You can not update several documents when no update operators are included"); } if (hasModifier) { override = false; } if (!hasModifier) { override = true; } } else { override = !!options.override; } } let _docUpdate = null; if (override) { // Overrides the document except for the "_id" _docUpdate = { _id: doc._id }; // Must ignore fields starting with '$', '.'... for (const key of Object.keys(update)) { if (key.substr(0, 1) === "$" || /\./g.test(key)) { self.logger.warn(`The field ${key} can not begin with '$' or contain '.'`); } else { _docUpdate[key] = update[key]; } } } else { _docUpdate = _.cloneDeep(doc); for (const key of Object.keys(update)) { const val = update[key]; if (key.substr(0, 1) === "$") { _docUpdate = applyModifier(_docUpdate, key, val); } else { if (!_.isNil(_docUpdate[key])) { if (key !== "_id") { _docUpdate[key] = val; } else { self.logger.warn("The field '_id' can not be updated"); } } else { self.logger.warn(`The document does not contains the field ${key}`); } } } } updatedDocs.push(_docUpdate); const idx = self.doc_indexes[_docUpdate._id]; self.docs[idx] = _docUpdate; } /*** * "update" event. * * @event MongoPortable~update * * @property {Object} collection - Information about the collection * @property {Object} selector - The selection of the query * @property {Object} modifier - The modifier used in the query * @property {Object} docs - The updated/inserted documents information */ self.emit("update", { collection: self, selector: selection, modifier: update, docs: updatedDocs }).then(() => { doResolve({ updated: { documents: updatedDocs, count: updatedDocs.length }, inserted: { documents: null, count: 0 } }); }).catch((error) => { doReject(error); }); // res = { // updated: { // documents: updatedDocs, // count: updatedDocs.length // }, // inserted: { // documents: null, // count: 0 // } // }; } // if (callback) callback(null, res); // return res; } function doResolve(result) { if (callback) { callback(null, result); } resolve(result); } function doReject(error) { if (callback) { callback(error, null); } reject(error); } }); } /*** * Removes one or many documents * * @method Collection#remove * * @param {Object|Array|String} [selection={}] - The selection for matching documents * @param {Object} [options] - Additional options * * @param {Number} [options.justOne=false] - Deletes the first occurrence of the selection * @param {Object} [options.writeConcern=null] - An object expressing the write concern * * @param {Function} [callback=null] - Callback function to be called at the end with the results * * @returns {Promise<Array<Obejct>>} Promise with the deleted documents */ public remove(selection, options, callback?): Promise<object[]> { const self = this; if (_.isNil(selection)) { selection = {}; } if (_.isFunction(selection)) { callback = selection; selection = {}; } if (_.isFunction(options)) { callback = options; options = {}; } if (_.isNil(options)) { options = { justOne: false }; } // If we are not passing a selection and we are not removing just one, is the same as a drop if (getObjectSize(selection) === 0 && !options.justOne) { return self.drop(options, callback); } else { return new Promise((resolve, reject) => { // Check special case where we are using an objectId if (selection instanceof ObjectId) { selection = { _id: selection }; } if (!_.isNil(callback) && !_.isFunction(callback)) { self.logger.throw("callback must be a function"); } self.find(selection, null, null, callback) .then((documents: any[]) => { const docs = []; for (const doc of documents) { const idx = self.doc_indexes[doc._id]; delete self.doc_indexes[doc._id]; self.docs.splice(idx, 1); docs.push(doc); } /*** * "remove" event. * * @event MongoPortable~remove * * @property {Object} collection - Information about the collection * @property {Object} selector - The selection of the query * @property {Object} docs - The deleted documents information */ self.emit("remove", { collection: self, selector: selection, docs }).then(() => { if (callback) { callback(null, docs); } resolve(docs); }).catch((error) => { if (callback) { callback(error, null); } reject(error); }); }).catch((error) => { if (callback) { callback(error, null); } reject(error); }); }); } } /*** * Alias for {@link Collection#remove} * * @method Collection#delete */ public delete(selection, options, callback?): Promise<object[]> { return this.remove(selection, options, callback); } /*** * Alias for {@link Collection#remove} * * @method Collection#destroy */ public destroy(selection, options, callback?): Promise<object[]> { return this.remove(selection, options, callback); } /*** * Drops a collection * * @method Collection#drop * * @param {Object} [ options] - Additional options * * @param {Number} [options.dropIndexes=false] - True if we want to drop the indexes too * @param {Object} [options.writeConcern=null] - An object expressing the write concern * * @param {Function} [callback=null] - Callback function to be called at the end with the results * * @returns {Promise<Object[]>} Promise with the deleted documents */ public drop(options, callback?): Promise<object[]> { const self = this; return new Promise((resolve, reject) => { if (_.isNil(options)) { options = {}; } if (_.isFunction(options)) { callback = options; options = {}; } if (!_.isNil(callback) && !_.isFunction(callback)) { self.logger.throw("callback must be a function"); } self.find(null, null, { limit: -1 }).then((docs) => { self.doc_indexes = {}; self.docs = []; if (options.dropIndexes) { // TODO } self.emit("dropCollection", { collection: self, indexes: !!options.dropIndexes }).then(() => { if (callback) { callback(null, docs); } resolve(docs as object[]); }).catch((error) => { if (callback) { callback(error, false); } reject(); }); }).catch((error) => { if (callback) { callback(error, false); } reject(); }); }); } /*** * Insert or update a document. If the document has an "_id" is an update (with upsert), if not is an insert. * * @method Collection#save * * @param {Object} doc - Document to be inserted/updated * * @param {Number} [options.dropIndexes=false] - True if we want to drop the indexes too * @param {Object} [options.writeConcern=null] - An object expressing the write concern * * @param {Function} [callback=null] - Callback function to be called at the end with the results * * @returns {Promise<Object>} Returns a promise with the inserted document or the update information */ public save(doc, options, callback?): Promise<any> { if (_.isNil(doc) || _.isFunction(doc)) { this.logger.throw("You must pass a document"); } if (_.isFunction(options)) { callback = options; options = {}; } if (_.isNil(options)) { options = {}; } if (_.hasIn(doc, "_id")) { options.upsert = true; return this.update( { _id: doc._id }, doc, options, callback ); } else { return this.insert(doc, options, callback); } } /*** * @ignore */ public ensureIndex() { // TODO Implement EnsureIndex this.logger.throw("Collection#ensureIndex unimplemented by driver"); } // TODO document (at some point) // TODO test // TODO obviously this particular implementation will not be very efficient /*** * @ignore */ public backup(backupID, callback?): Promise<any> { const self = this; return new Promise((resolve, reject) => { if (_.isFunction(backupID)) { callback = backupID; backupID = new ObjectId().toString(); } if (!_.isNil(callback) && !_.isFunction(callback)) { self.logger.throw("callback must be a function"); } self.snapshots[backupID] = _.cloneDeep(self.docs); self.emit("snapshot", { collection: self, backupID, documents: self.snapshots[backupID] }).then(() => { const result = { backupID, documents: self.snapshots[backupID] }; if (callback) { callback(null, result); } resolve(result); }).catch((error) => { if (callback) { callback(error, null); } reject(error); }); }); } // Lists available Backups /*** * @ignore */ public backups(/*callback*/) { // if (!_.isNil(callback) && !_.isFunction(callback)) this.logger.throw("callback must be a function"); const backups = []; for (const id of Object.keys(this.snapshots)) { backups.push({ id, documents: this.snapshots[id] }); } // if (callback) callback(null, backups); return backups; } // Lists available Backups /*** * @ignore */ public removeBackup(backupID/*, callback*/): string { // if (_.isFunction(backupID)) { // callback = backupID; // backupID = null; // } if (_.isNil(backupID)) { this.logger.throw("backupID required"); } // if (!_.isNil(callback) && !_.isFunction(callback)) this.logger.throw("callback must be a function"); let result = null; if (backupID) { delete this.snapshots[_.toString(backupID)]; result = backupID; // } else { // this.snapshots = {}; // result = true; } // if (callback) callback(null, result); return result; } public clearBackups() { // TODO } // Restore the snapshot. If no snapshot exists, raise an exception; /*** * @ignore */ public restore(backupID, callback): Promise<string> { const self = this; return new Promise((resolve, reject) => { if (_.isFunction(backupID)) { callback = backupID; backupID = null; } if (!_.isNil(callback) && !_.isFunction(callback)) { self.logger.throw("callback must be a function"); } const snapshotCount = getObjectSize(self.snapshots); let backupData = null; if (snapshotCount === 0) { self.logger.throw("There is no snapshots"); } else { if (!backupID) { if (snapshotCount === 1) { self.logger.info("No backupID passed. Restoring the only snapshot"); // Retrieve the only snapshot for (const key of Object.keys(self.snapshots)) { backupID = key; } } else { self.logger.throw("The are several snapshots. Please specify one backupID"); } } } backupData = self.snapshots[backupID]; if (!backupData) { self.logger.throw(`Unknown Backup ID: ${backupID}`); } self.docs = backupData; self.emit("restore", { collection: self, backupID }).then(() => { if (callback) { callback(null, backupID); } resolve(backupID); }).catch((error) => { if (callback) { callback(error, null); } reject(error); }); }); } /*** * Calculates aggregate values for the data in a collection * * @method Collection#aggregate * * @param {Array} pipeline - A sequence of data aggregation operations or stages * @param {Object} [options] - Additional options * * @param {Boolean} [options.forceFetch=false] - If set to'"true" returns the array of documents already fetched * * @returns {Array|Cursor} If "options.forceFetch" set to true returns the array of documents, otherwise returns a cursor */ public aggregate(pipeline, options = { forceFetch: false }) { if (_.isNil(pipeline) || !_.isArray(pipeline)) { this.logger.throw('The "pipeline" param must be an array'); } const aggregation = new Aggregation(pipeline); for (const stage of pipeline) { for (const key of Object.keys(stage)) { if (key.substr(0, 1) !== "$") { this.logger.throw("The pipeline stages must begin with '$'"); } if (!aggregation.validStage(key)) { this.logger.throw(`Invalid stage "${key}"`); } break; } } const result = aggregation.aggregate(this); return result; // change to cursor } /*** * @ignore */ public rename(newName) { if (_.isString(newName)) { if (this.name !== newName) { Collection.checkCollectionName(newName); const dbName = this.name.split(".").length > 1 ? this.name.split(".")[0] : ""; this.name = newName; this.fullName = dbName + "." + this.name; return this; } } else { // Error return null; } } /*** * @ignore */ public static checkCollectionName(collectionName) { if (!_.isString(collectionName)) { JSWLogger.instance.throw("collection name must be a String"); } if (!collectionName || collectionName.indexOf("..") !== -1) { JSWLogger.instance.throw("collection names cannot be empty"); } if (collectionName.indexOf("$") !== -1 && collectionName.match(/((^\$cmd)|(oplog\.\$main))/) === null) { JSWLogger.instance.throw("collection names must not contain '$'"); } if (collectionName.match(/^system\./) !== null) { JSWLogger.instance.throw("collection names must not start with 'system.' (reserved for internal use)"); } if (collectionName.match(/^\.|\.$/) !== null) { JSWLogger.instance.throw("collection names must not start or end with '.'"); } } } const applyModifier = (_docUpdate, key, val) => { const doc = _.cloneDeep(_docUpdate); // var mod = modifiers[key]; if (!modifiers[key]) { JSWLogger.instance.throw(`Invalid modifier specified: ${key}`); } for (const keypath of Object.keys(val)) { const value = val[keypath]; const keyparts = keypath.split("."); modify(doc, keyparts, value, key); // var no_create = !!Collection._noCreateModifiers[key]; // var forbid_array = (key === "$rename"); // var target = Collection._findModTarget(_docUpdate, keyparts, no_create, forbid_array); // var field = keyparts.pop(); // mod(target, field, value, keypath, _docUpdate); } return doc; }; const modify = (document, keyparts, value, key, level = 0) => { for (let i = level; i < keyparts.length; i++) { let path = keyparts[i]; const isNumeric = /^[0-9]+$/.test(path); let target = document[path]; const create = _.hasIn(Collection._noCreateModifiers, key) ? false : true; if (!create && (!_.isObject(document) || _.isNil(target))) { JSWLogger.instance.throw(`The element "${path}" must exists in "${JSON.stringify(document)}"`); } if (_.isArray(document)) { // Do not allow $rename on arrays if (key === "$rename") { return null; } // Only let the use of "arrayfield.<numeric_index>.subfield" if (isNumeric) { path = _.toNumber(path); } else { JSWLogger.instance.throw(`The field "${path}" can not be appended to an array`); } // Fill the array to the desired length while (document.length < path) { document.push(null); } } if (i < keyparts.length - 1) { if (_.isNil(target)) { // If we are accessing with "arrayField.<numeric_index>" if (_.isFinite(_.toNumber(keyparts[i + 1]))) { // || keyparts[i + 1] === '$' // TODO "arrayField.$" target = []; } else { target = {}; } } document[path] = modify(target, keyparts, value, key, level + 1); return document; } else { modifiers[key](document, path, value); return document; } } }; /*** * @ignore */ const modifiers = { $inc(target, field, arg) { if (!_.isNumber(arg)) { JSWLogger.instance.throw("Modifier $inc allowed for numbers only"); } if (field in target) { if (!_.isNumber(target[field])) { JSWLogger.instance.throw("Cannot apply $inc modifier to non-number"); } target[field] += arg; } else { target[field] = arg; } }, $set(target, field, arg) { target[field] = _.cloneDeep(arg); }, $unset(target, field, arg) { if (!_.isNil(target)) { if (_.isArray(target)) { if (field in target) { target[field] = null; } } else { delete target[field]; } } }, $push(target, field, arg) { const fieldTarget = target[field]; if (_.isNil(fieldTarget)) { target[field] = [arg]; } else if (!_.isArray(fieldTarget)) { JSWLogger.instance.throw("Cannot apply $push modifier to non-array"); } else { fieldTarget.push(_.cloneDeep(arg)); } }, $pushAll(target, field, arg) { const fieldTarget = target[field]; if (_.isNil(fieldTarget)) { target[field] = arg; } else if (!_.isArray(fieldTarget)) { JSWLogger.instance.throw("Modifier $pushAll/pullAll allowed for arrays only"); } else { for (const argValue of arg) { fieldTarget.push(argValue); } } }, $addToSet(target, field, arg) { const fieldTarget = target[field]; if (_.isNil(fieldTarget)) { target[field] = [arg]; } else if (!_.isArray(fieldTarget)) { JSWLogger.instance.throw("Cannot apply $addToSet modifier to non-array"); } else { let isEach = false; if (_.isPlainObject(arg)) { for (const key of Object.keys(arg)) { if (key === "$each") { isEach = true; } break; } } const values = isEach ? arg.$each : [arg]; _.forEach(values, (value) => { for (const fieldTargetValue of fieldTarget) { if (SelectorMatcher.equal(value, fieldTargetValue)) { return; } } fieldTarget.push(value); }); } }, $pop(target, field, arg) { if (_.isNil(target) || _.isNil(target[field])) { return; } const fieldTarget = target[field]; if (!_.isArray(fieldTarget)) { JSWLogger.instance.throw("Cannot apply $pop modifier to non-array"); } else { if (_.isNumber(arg) && arg < 0) { fieldTarget.splice(0, 1); } else { fieldTarget.pop(); } } }, $pull(target, field, arg) { if (_.isNil(target) || _.isNil(target[field])) { return; } const fieldTarget = target[field]; if (!_.isArray(fieldTarget)) { JSWLogger.instance.throw("Cannot apply $pull/pullAll modifier to non-array"); } else { const out = []; if (typeof arg === "object" && !(arg instanceof Array)) { // XXX would be much nicer to compile this once, rather than // for each document we modify.. but usually we're not // modifying that many documents, so we'll let it slide for // now // XXX _compileSelector isn't up for the job, because we need // to permit stuff like {$pull: {a: {$gt: 4}}}.. something // like {$gt: 4} is not normally a complete selector. const match = new Selector({ __matching__: arg }); for (const fieldTargetValue of fieldTarget) { const doc = { __matching__: fieldTargetValue }; if (!match.test(doc)) { out.push(fieldTargetValue); } } } else { for (const fieldTargetValue of fieldTarget) { if (!SelectorMatcher.equal(fieldTargetValue, arg)) { out.push(fieldTargetValue); } } } target[field] = out; } }, $pullAll(target, field, arg) { if (_.isNil(target) || _.isNil(target[field])) { return; } const fieldTarget = target[field]; if (!_.isNil(fieldTarget) && !_.isArray(fieldTarget)) { JSWLogger.instance.throw("Modifier $pushAll/pullAll allowed for arrays only"); } else if (!_.isNil(fieldTarget)) { const out = []; for (const fieldTargetValue of fieldTarget) { let exclude = false; for (const argValue of arg) { if (SelectorMatcher.equal(fieldTargetValue, argValue)) { exclude = true; break; } } if (!exclude) { out.push(fieldTargetValue); } } target[field] = out; } }, $rename(target, field, value) { if (field === value) { // no idea why mongo has this restriction.. JSWLogger.instance.throw("The new field name must be different"); } if (!_.isString(value) || value.trim() === "") { JSWLogger.instance.throw("The new name must be a non-empty string"); } target[value] = target[field]; delete target[field]; }, $bit(target, field, arg) { // XXX mongo only supports $bit on integers, and we only support // native javascript numbers (doubles) so far, so we can't support $bit JSWLogger.instance.throw("$bit is not supported"); } }; const ensureFindParams = (params) => { // selection, fields, options, callback if (_.isNil(params.selection)) { params.selection = {}; } if (_.isNil(params.fields)) { params.fields = []; } if (_.isNil(params.options)) { params.options = { skip: 0, limit: 15 // for no limit pass [options.limit = -1] }; } // callback as first parameter if (_.isFunction(params.selection)) { params.callback = params.selection; params.selection = {}; } // callback as second parameter if (_.isFunction(params.fields)) { params.callback = params.fields; params.fields = []; } // callback as third parameter if (_.isFunction(params.options)) { params.callback = params.options; params.options = {}; } // Check special case where we are using an objectId if (params.selection instanceof ObjectId) { params.selection = { _id: params.selection }; } if (!_.isNil(params.callback) && !_.isFunction(params.callback)) { JSWLogger.instance.throw("callback must be a function"); } if (params.options.fields) { if (_.isNil(params.fields) || params.fields.length === 0) { params.fields = params.options.fields; } else { JSWLogger.instance.warn("Fields already present. Ignoring 'options.fields'."); } } return params; };