UNPKG

alchemymvc

Version:
789 lines (616 loc) 15.8 kB
const mongo = alchemy.use('mongodb'), MongoClient = mongo.MongoClient; const CONNECTION = Symbol('connection'), CONNECTION_ERROR = Symbol('connection_error'), MONGO_CLIENT = Symbol('mongo_client'); /** * MongoDb Datasource * * @author Jelle De Loecker <jelle@elevenways.be> * @since 0.2.0 * @version 1.3.16 */ const Mongo = Function.inherits('Alchemy.Datasource.Nosql', function Mongo(name, _options) { var options, uri; // Possible connection error this[CONNECTION_ERROR] = null; // The actual DB connection this[CONNECTION] = null; // The MongoDB Client instance this[MONGO_CLIENT] = null; // Define default options this.options = { host: '127.0.0.1', database: null, login: null, password: null, port: 27017 }; Mongo.super.call(this, name, _options); options = this.options; // Allow fully-formed uris if (options.uri) { uri = options.uri; } else { uri = 'mongodb://'; // Add login & password to the uri if they're supplied if (options.login && options.password) { uri += encodeURIComponent(options.login) + ':' + encodeURIComponent(options.password) + '@'; } // Add the hostname/ip address & port uri += options.host + ':' + options.port; // Add the database uri += '/' + options.database + '?'; } // Store the uri this.uri = uri; // Set the connection options this.mongoOptions = { }; // Cache collections in here this.collections = {}; }); // Indicate this datasource supports objectids Mongo.setSupport('objectid', true); // Indicate this datasource supports querying associations Mongo.setSupport('querying_associations', true); /** * Get find options for the MongoDB server * * @author Jelle De Loecker <jelle@elevenways.be> * @since 1.0.0 * @version 1.0.0 * * @param {Object} options * * @return {Object} */ Mongo.setProperty('allowed_find_options', [ 'limit', 'sort', 'projection', 'fields', 'skip', 'hint', 'explain', 'snapshot', 'timeout', 'tailable', 'batchSize', 'returnKey', 'maxScan', 'min', 'max', 'showDiskLoc', 'comment', 'raw', 'promoteLongs', 'promoteValues', 'promoteBuffers', 'readPreference', 'partial', 'maxTimeMS', 'collation', 'session' ]); /** * Convert the given value to a BigInt * * @author Jelle De Loecker <jelle@elevenways.be> * @since 1.3.20 * @version 1.3.20 * * @param {*} value * * @return {BigInt} */ Mongo.setMethod(function castToBigInt(value) { if (value == null) { return value; } if (typeof value == 'object') { if (value.toBigInt) { return value.toBigInt(); } return null; } return BigInt(value); }); /** * Convert the given value from a BigInt (for use in DB) * * @author Jelle De Loecker <jelle@elevenways.be> * @since 1.3.20 * @version 1.3.20 * * @param {BigInt} value * * @return {Mongo.Long} */ Mongo.setMethod(function convertBigIntForDatasource(value) { if (value == null) { return value; } return mongo.Long.fromBigInt(value); }); /** * Convert the given value to a Decimal (for use in JS) * * @author Jelle De Loecker <jelle@elevenways.be> * @since 1.3.20 * @version 1.3.20 * * @param {*} value * * @return {BigInt} */ Mongo.setMethod(function castToDecimal(value) { if (value == null) { return value; } if (typeof value == 'object') { value = value.toString(); } return new Blast.Classes.Develry.Decimal(value); }); /** * Convert the given decimal for use in DB * * @author Jelle De Loecker <jelle@elevenways.be> * @since 1.3.20 * @version 1.3.20 * * @param {Decimal|string} value * * @return {Mongo.Decimal128} */ Mongo.setMethod(function convertDecimalForDatasource(value) { if (value == null) { return value; } if (!(value instanceof Blast.Classes.Develry.Decimal)) { value = new Blast.Classes.Develry.Decimal(value); } return mongo.Decimal128.fromString(value.toString()); }); /** * Get find options for the MongoDB server * * @author Jelle De Loecker <jelle@elevenways.be> * @since 1.0.0 * @version 1.0.0 * * @param {Object} options * * @return {Object} */ Mongo.setMethod(function normalizeFindOptions(options) { var result = {}, key; if (!options) { return result; } for (key in options) { if (this.allowed_find_options.indexOf(key) == -1) { continue; } result[key] = options[key]; } return result; }); /** * Get a connection to the database * * @author Jelle De Loecker <jelle@elevenways.be> * @since 0.2.0 * @version 1.4.0 * * @param {Pledge} */ Mongo.setMethod(function connect() { if (this[CONNECTION]) { return this[CONNECTION]; } if (this[CONNECTION_ERROR]) { throw this[CONNECTION_ERROR]; } let pledge = this[CONNECTION] = new Swift(); // Create the connection to the database Pledge.done(MongoClient.connect(this.uri, this.mongoOptions), (err, client) => { this[CONNECTION] = null; if (err) { this[CONNECTION_ERROR] = err; alchemy.printLog(alchemy.SEVERE, 'Could not create connection to Mongo server', {err: err}); return pledge.reject(err); } else { log.info('Created connection to Mongo datasource', this.name); } if (client) { this[MONGO_CLIENT] = client; this[CONNECTION] = client.db(); } pledge.resolve(this[CONNECTION]); }); return pledge; }); /** * Get a mongodb collection * * @author Jelle De Loecker <jelle@elevenways.be> * @since 0.2.0 * @version 1.4.0 * * @param {string} name * * @return {Pledge<Collection>|Collection} */ Mongo.setMethod(function collection(name) { if (this.collections[name]) { return this.collections[name]; } let result = Swift.waterfall( this.connect(), db => db.collection(name), collection => { this.collections[name] = collection; return collection; } ); if (!this.collections[name]) { this.collections[name] = result; } return result; }); /** * Query the database * * @author Jelle De Loecker <jelle@elevenways.be> * @since 0.2.0 * @version 1.4.0 * * @param {Alchemy.OperationalContext.ReadDocumentFromDatasource} context * * @return {Pledge} */ Mongo.setMethod(function _read(context) { const that = this; const model = context.getModel(), criteria = context.getCriteria(); return Swift.waterfall( this.collection(model.table), async collection => { let compiled, options, pledge = new Swift(); await criteria.normalize(); compiled = await that.compileCriteria(criteria); options = that.compileCriteriaOptions(criteria); if (compiled.pipeline) { // Sorting should happen in the pipeline if (options.sort && options.sort.length) { let sort_object = {}; for (let entry of options.sort) { sort_object[entry[0]] = entry[1]; } compiled.pipeline.unshift({$sort: sort_object}); } // Skipping also happens in the pipeline if (options.skip) { compiled.pipeline.push({$skip: options.skip}); } let aggregate_options = {}; Function.parallel({ available: function getAvailable(next) { if (criteria.options.available === false) { return next(null, null); } let pipeline = JSON.clone(compiled.pipeline), cloned_options = JSON.clone(aggregate_options); pipeline.push({$count: 'available'}); // Expensive aggregate just to get the available count... Pledge.done(collection.aggregate(pipeline, cloned_options), function gotAggregate(err, cursor) { if (err) { return next(err); } Pledge.done(cursor.toArray(), function gotAvailableArray(err, items) { if (err) { return next(err); } if (!items || !items.length) { return next(null, null); } let available = items[0].available; if (options.skip) { available += options.skip; } return next(null, available); }); }); }, rows: function getRows(next) { let pipeline = JSON.clone(compiled.pipeline); // Limits also have to be set in the pipeline now // (We have to do it here, so the `available` count is correct) if (options.limit) { pipeline.push({$limit: options.limit}); } Pledge.done(collection.aggregate(pipeline, aggregate_options), function gotAggregate(err, cursor) { if (err) { return next(err); } Pledge.done(cursor.toArray(), next); }); } }, function done(err, data) { if (err) { return pledge.reject(err); } data.rows = that.organizeResultItems(model, data.rows); pledge.resolve(data); }); return pledge; } // Create the cursor let cursor = collection.find(compiled, options); Function.parallel({ available: function getAvailable(next) { if (criteria.options.available === false) { return next(null, null); } Pledge.done(collection.countDocuments(compiled), next); }, rows: function getRows(next) { Pledge.done(cursor.toArray(), next); } }, function done(err, data) { if (err) { return callback(err); } data.rows = that.organizeResultItems(model, data.rows); pledge.resolve(data); }); return pledge; } ); }); /** * Create a record in the database * * @author Jelle De Loecker <jelle@elevenways.be> * @since 0.2.0 * @version 1.4.0 * * @param {Alchemy.OperationalContext.SaveToDatasource} context * * @return {Pledge} */ Mongo.setMethod(function _create(context) { const model = context.getModel(); return Swift.waterfall( this.collection(model.table), collection => { const converted_data = context.getConvertedData(); const pledge = new Swift(); let data = {}; for (let key in converted_data) { let val = converted_data[key]; if (val == null) { continue; } data[key] = val; } Pledge.done(collection.insertOne(data, {w: 1, fullResult: true}), function afterInsert(err, result) { // Clear the cache model.nukeCache(); if (err != null) { return pledge.reject(err); } // @TODO: fix because of mongodb 6 let write_errors = result.message?.documents?.[0]?.writeErrors; if (write_errors) { let violations = new Classes.Alchemy.Error.Validation.Violations(); if (write_errors.length) { let entry; for (entry of write_errors) { let violation = new Classes.Alchemy.Error.Validation.Violation(); violation.message = entry.errmsg || entry.message || entry.code; violations.add(violation); } } else { violations.add(new Error('Unknown database error')); } return pledge.reject(violations); } pledge.resolve(Object.assign({}, data)); }); return pledge; } ); }); /** * Update a record in the database * * @author Jelle De Loecker <jelle@elevenways.be> * @since 0.2.0 * @version 1.4.0 * * @param {Alchemy.OperationalContext.SaveToDatasource} context * * @return {Pledge} */ Mongo.setMethod(function _update(context) { const model = context.getModel(); return Swift.waterfall( this.collection(model.table), collection => { return performUpdate(collection, model, context); } ); }); /** * Perform the update * * @author Jelle De Loecker <jelle@elevenways.be> * @since 1.4.0 * @version 1.4.0 * * @return {Pledge} */ const performUpdate = (collection, model, context) => { const options = context.getSaveOptions(); let key; // Get the converted data const data = context.getConvertedData(); // Get the id to update, it should always be inside the given data let id = data._id; // Clone the data object let doc = {...data}; // Values that will get flattened let to_flatten = {}; // Field names that won't get flattened let no_flatten = {}; // Remove the id delete doc._id; if (!options.override_created) { delete doc.created; } // Iterate over the fields for (key in doc) { let field = model.getField(key); if (field && (field.is_self_contained || field.is_translatable || typeof doc[key] == 'object')) { no_flatten[key] = doc[key]; } else { to_flatten[key] = doc[key]; } } // Flatten the object, using periods & NOT flattening arrays let flat = Object.flatten(to_flatten, '.', false); // Assign the no-flatten values, too Object.assign(flat, no_flatten); let unset = {}; for (key in flat) { // Undefined or null means we want to delete the value // We can't set null, because that could interfere with dot notation updates if (flat[key] == null) { // Add the key to the unset object unset[key] = ''; // Remove it from the flat object delete flat[key]; } } let update_object = { $set: flat }; if (!Object.isEmpty(unset)) { update_object.$unset = unset; } if (options.debug) { console.log('Updating with obj', id, update_object); } let promise; if (collection.findOneAndUpdate) { promise = collection.findOneAndUpdate({_id: id}, update_object, {upsert: true}); } else if (collection.findAndModify) { promise = collection.findAndModify({_id: id}, [['_id', 1]], update_object, {upsert: true}); } else { // If it's not available (like nedb) promise = collection.update({_id: ''+id}, update_object, {upsert: true}); } let pledge = new Swift(); Pledge.done(promise, function afterUpdate(err, result) { // Clear the cache model.nukeCache(); if (err != null) { return pledge.reject(err); } pledge.resolve(Object.assign({}, data)); }); return pledge; }; /** * Remove a record from the database * * @author Kjell Keisse <kjell@codedor.be> * @author Jelle De Loecker <jelle@elevenways.be> * @since 0.2.0 * @version 1.4.0 * * @param {Alchemy.OperationalContext.RemoveFromDatasource} context * * @return {Pledge} */ Mongo.setMethod(function _remove(context) { const model = context.getModel(), query = context.getQuery(); return Swift.waterfall( this.collection(model.table), collection => { let pledge = new Swift(); Swift.done(collection.findOneAndDelete(query), function _deleted(err, result){ //clear cache model.nukeCache(); if (err != null) { return pledge.reject(err); } pledge.resolve(!!result); }); return pledge; } ); }); /** * Ensure an index in the database * * @author Jelle De Loecker <jelle@elevenways.be> * @since 0.2.0 * @version 1.4.0 */ Mongo.setMethod(function _ensureIndex(model, index, callback) { this.collection(model.table, async function gotCollection(err, collection) { if (err != null) { return callback(err); } let options = { name : index.options.name, unique : index.options.unique ? true : false, sparse : index.options.sparse ? true : false, }; let index_specs; // Hack in the text indexes if (options.name == 'text') { let key; index_specs = {}; for (key in index.fields) { index_specs[key] = 'text'; } } else { index_specs = index.fields; } try { await collection.createIndex(index_specs, options); } catch (err) { // Check for IndexOptionsConflict if (err.code === 85) { let index_to_drop; if (err.message.includes('already exists with a different name:')) { index_to_drop = err.message.after('different name:').trim(); } if (!index_to_drop) { index_to_drop = options.name; } try { // Index already exists, drop it await collection.dropIndex(index_to_drop); // Try again await collection.createIndex(index_specs, options); } catch (second_err) { return callback(second_err); } } else { return callback(err); } } callback(); }); });