const mongodb = require('mongodb') const MongoClient = mongodb.MongoClient const bson = require('bson') const ObjectID = bson.ObjectID const JSData = require('js-data') const underscore = require('mout/string/underscore') const { DSUtils } = JSData const reserved = [ 'orderBy', 'sort', 'limit', 'offset', 'skip', 'where' ] function Defaults () {} Defaults.prototype.translateId = true const addHiddenPropsToTarget = function (target, props) { DSUtils.forOwn(props, function (value, key) { props[key] = { writable: true, value } }) Object.defineProperties(target, props) } const fillIn = function (dest, src) { DSUtils.forOwn(src, function (value, key) { if (!dest.hasOwnProperty(key) || dest[key] === undefined) { dest[key] = value } }) } function unique (array) { const seen = {} const final = [] array.forEach(function (item) { if (item in seen) { return } final.push(item) seen[item] = 0 }) return final } /** * MongoDBAdapter class. * * @example * import {DS} from 'js-data' * import MongoDBAdapter from 'js-data-mongodb' * const store = new DS() * const adapter = new MongoDBAdapter({ * uri: 'mongodb://localhost:27017' * }) * store.registerAdapter('mongodb', adapter, { 'default': true }) * * @class MongoDBAdapter * @param {Object} [opts] Configuration opts. * @param {string} [opts.uri=''] MongoDB URI. */ export default function MongoDBAdapter (opts) { const self = this if (typeof opts === 'string') { opts = { uri: opts } } opts.uri || (opts.uri = 'mongodb://localhost:27017') self.defaults = new Defaults() DSUtils.deepMixIn(self.defaults, opts) fillIn(self, opts) /** * A Promise that resolves to a reference to the MongoDB client being used by * this adapter. * * @name MongoDBAdapter#client * @type {Object} */ self.client = new DSUtils.Promise(function (resolve, reject) { MongoClient.connect(opts.uri, function (err, db) { return err ? reject(err) : resolve(db) }) }) } addHiddenPropsToTarget(MongoDBAdapter.prototype, { /** * Return a Promise that resolves to a reference to the MongoDB client being * used by this adapter. * * Useful when you need to do anything custom with the MongoDB client library. * * @name MongoDBAdapter#getClient * @method * @return {Object} MongoDB client. */ getClient () { return this.client }, /** * Map filtering params in a selection query to MongoDB a filtering object. * * Handles the following: * * - where * - and bunch of filtering operators * * @name MongoDBAdapter#getQuery * @method * @return {Object} */ getQuery (Resource, query) { const self = this query = query || {} query.where = query.where || {} DSUtils.forOwn(query, function (v, k) { if (reserved.indexOf(k) === -1) { if (DSUtils.isObject(v)) { query.where[k] = v } else { query.where[k] = { '==': v } } delete query[k] } }) let mongoQuery = {} if (Object.keys(query.where).length) { DSUtils.forOwn(query.where, function (criteria, field) { if (!DSUtils.isObject(criteria)) { query.where[field] = { '==': criteria } } DSUtils.forOwn(criteria, function (v, op) { if (op === '==' || op === '===') { mongoQuery[field] = v } else if (op === '!=' || op === '!==') { mongoQuery[field] = mongoQuery[field] || {} mongoQuery[field].$ne = v } else if (op === '>') { mongoQuery[field] = mongoQuery[field] || {} mongoQuery[field].$gt = v } else if (op === '>=') { mongoQuery[field] = mongoQuery[field] || {} mongoQuery[field].$gte = v } else if (op === '<') { mongoQuery[field] = mongoQuery[field] || {} mongoQuery[field].$lt = v } else if (op === '<=') { mongoQuery[field] = mongoQuery[field] || {} mongoQuery[field].$lte = v } else if (op === 'in') { mongoQuery[field] = mongoQuery[field] || {} mongoQuery[field].$in = v } else if (op === 'notIn') { mongoQuery[field] = mongoQuery[field] || {} mongoQuery[field].$nin = v } else if (op === '|==' || op === '|===') { mongoQuery.$or = mongoQuery.$or || [] let orEqQuery = {} orEqQuery[field] = v mongoQuery.$or.push(orEqQuery) } else if (op === '|!=' || op === '|!==') { mongoQuery.$or = mongoQuery.$or || [] let orNeQuery = {} orNeQuery[field] = { '$ne': v } mongoQuery.$or.push(orNeQuery) } else if (op === '|>') { mongoQuery.$or = mongoQuery.$or || [] let orGtQuery = {} orGtQuery[field] = { '$gt': v } mongoQuery.$or.push(orGtQuery) } else if (op === '|>=') { mongoQuery.$or = mongoQuery.$or || [] let orGteQuery = {} orGteQuery[field] = { '$gte': v } mongoQuery.$or.push(orGteQuery) } else if (op === '|<') { mongoQuery.$or = mongoQuery.$or || [] let orLtQuery = {} orLtQuery[field] = { '$lt': v } mongoQuery.$or.push(orLtQuery) } else if (op === '|<=') { mongoQuery.$or = mongoQuery.$or || [] let orLteQuery = {} orLteQuery[field] = { '$lte': v } mongoQuery.$or.push(orLteQuery) } else if (op === '|in') { mongoQuery.$or = mongoQuery.$or || [] let orInQuery = {} orInQuery[field] = { '$in': v } mongoQuery.$or.push(orInQuery) } else if (op === '|notIn') { mongoQuery.$or = mongoQuery.$or || [] let orNinQuery = {} orNinQuery[field] = { '$nin': v } mongoQuery.$or.push(orNinQuery) } }) }) } return mongoQuery }, /** * Map non-filtering params in a selection query to MongoDB query options. * * Handles the following: * * - limit * - skip/offset * - orderBy/sort * * @name MongoDBAdapter#getQueryOptions * @method * @return {Object} */ getQueryOptions (Resource, query) { query = query || {} query.orderBy = query.orderBy || query.sort query.skip = query.skip || query.offset let queryOptions = {} if (query.orderBy) { if (DSUtils.isString(query.orderBy)) { query.orderBy = [ [query.orderBy, 'asc'] ] } for (var i = 0; i < query.orderBy.length; i++) { if (DSUtils.isString(query.orderBy[i])) { query.orderBy[i] = [query.orderBy[i], 'asc'] } } queryOptions.sort = query.orderBy } if (query.skip) { queryOptions.skip = +query.skip } if (query.limit) { queryOptions.limit = +query.limit } return queryOptions }, /** * TODO * * @name MongoDBAdapter#translateId * @method * @return {*} */ translateId (r, opts) { opts || (opts = {}) if (typeof opts.translateId === 'boolean' ? opts.translateId : this.defaults.translateId) { if (DSUtils.isArray(r)) { r.forEach(function (_r) { const __id = _r._id ? _r._id.toString() : _r._id _r._id = typeof __id === 'string' ? __id : _r._id }) } else if (DSUtils.isObject(r)) { const __id = r._id ? r._id.toString() : r._id r._id = typeof __id === 'string' ? __id : r._id } } return r }, /** * TODO * * @name MongoDBAdapter#origify * @method * @return {Object} */ origify (opts) { opts = opts || {} if (typeof opts.orig === 'function') { return opts.orig() } return opts }, /** * TODO * * @name MongoDBAdapter#makeHasManyForeignKey * @method * @return {*} */ toObjectID (Resource, id) { if (id !== undefined && Resource.idAttribute === '_id' && typeof id === 'string' && ObjectID.isValid(id) && !(id instanceof ObjectID)) { return new ObjectID(id) } return id }, /** * TODO * * If the foreignKeys in your database are saved as ObjectIDs, then override * this method and change it to something like: * * ``` * return this.toObjectID(Resource, this.constructor.prototype.makeHasManyForeignKey.call(this, Resource, def, record)) * ``` * * There may be other reasons why you may want to override this method, like * when the id of the parent doesn't exactly match up to the key on the child. * * @name MongoDBAdapter#makeHasManyForeignKey * @method * @return {*} */ makeHasManyForeignKey (Resource, def, record) { return DSUtils.get(record, Resource.idAttribute) }, /** * TODO * * @name MongoDBAdapter#loadHasMany * @method * @return {Promise} */ loadHasMany (Resource, def, records, __options) { const self = this let singular = false if (DSUtils.isObject(records) && !DSUtils.isArray(records)) { singular = true records = [records] } const IDs = records.map(function (record) { return self.makeHasManyForeignKey(Resource, def, record) }) const query = {} const criteria = query[def.foreignKey] = {} if (singular) { // more efficient query when we only have one record criteria['=='] = IDs[0] } else { criteria['in'] = IDs.filter(function (id) { return id }) } return self.findAll(Resource.getResource(def.relation), query, __options).then(function (relatedItems) { records.forEach(function (record) { let attached = [] // avoid unneccesary iteration when we only have one record if (singular) { attached = relatedItems } else { relatedItems.forEach(function (relatedItem) { if (DSUtils.get(relatedItem, def.foreignKey) === record[Resource.idAttribute]) { attached.push(relatedItem) } }) } DSUtils.set(record, def.localField, attached) }) }) }, /** * TODO * * @name MongoDBAdapter#loadHasOne * @method * @return {Promise} */ loadHasOne (Resource, def, records, __options) { if (DSUtils.isObject(records) && !DSUtils.isArray(records)) { records = [records] } return this.loadHasMany(Resource, def, records, __options).then(function () { records.forEach(function (record) { const relatedData = DSUtils.get(record, def.localField) if (DSUtils.isArray(relatedData) && relatedData.length) { DSUtils.set(record, def.localField, relatedData[0]) } }) }) }, /** * TODO * * @name MongoDBAdapter#makeBelongsToForeignKey * @method * @return {*} */ makeBelongsToForeignKey (Resource, def, record) { return this.toObjectID(Resource.getResource(def.relation), DSUtils.get(record, def.localKey)) }, /** * TODO * * @name MongoDBAdapter#loadBelongsTo * @method * @return {Promise} */ loadBelongsTo (Resource, def, records, __options) { const self = this const relationDef = Resource.getResource(def.relation) if (DSUtils.isObject(records) && !DSUtils.isArray(records)) { const record = records return self.find(relationDef, self.makeBelongsToForeignKey(Resource, def, record), __options).then(function (relatedItem) { DSUtils.set(record, def.localField, relatedItem) }) } else { const keys = records.map(function (record) { return self.makeBelongsToForeignKey(Resource, def, record) }).filter(function (key) { return key }) return self.findAll(relationDef, { where: { [relationDef.idAttribute]: { 'in': keys } } }, __options).then(function (relatedItems) { records.forEach(function (record) { relatedItems.forEach(function (relatedItem) { if (relatedItem[relationDef.idAttribute] === record[def.localKey]) { DSUtils.set(record, def.localField, relatedItem) } }) }) }) } }, /** * Retrieve the record with the given primary key. * * @name MongoDBAdapter#find * @method * @param {Object} Resource The Resource. * @param {(string|number)} id Primary key of the record to retrieve. * @param {Object} [opts] Configuration options. * @param {string[]} [opts.with=[]] TODO * @return {Promise} */ find (Resource, id, options) { const self = this let instance options = self.origify(options) options.with || (options.with = []) return self.getClient().then(function (client) { return new DSUtils.Promise(function (resolve, reject) { let mongoQuery = {} mongoQuery[Resource.idAttribute] = self.toObjectID(Resource, id) options.fields = options.fields || {} client.collection(Resource.table || underscore(Resource.name)).findOne(mongoQuery, options, function (err, r) { if (err) { reject(err) } else if (!r) { reject(new Error('Not Found!')) } else { resolve(self.translateId(r, options)) } }) }) }).then(function (_instance) { instance = _instance let tasks = [] const relationList = Resource.relationList || [] relationList.forEach(function (def) { let relationName = def.relation let relationDef = Resource.getResource(relationName) let containedName = null if (options.with.indexOf(relationName) !== -1) { containedName = relationName } else if (options.with.indexOf(def.localField) !== -1) { containedName = def.localField } if (containedName) { let __options = DSUtils.deepMixIn({}, options.orig ? options.orig() : options) __options.with = options.with.slice() __options = DSUtils._(relationDef, __options) DSUtils.remove(__options.with, containedName) __options.with.forEach(function (relation, i) { if (relation && relation.indexOf(containedName) === 0 && relation.length >= containedName.length && relation[containedName.length] === '.') { __options.with[i] = relation.substr(containedName.length + 1) } else { __options.with[i] = '' } }) let task if (def.foreignKey && (def.type === 'hasOne' || def.type === 'hasMany')) { if (def.type === 'hasOne') { task = self.loadHasOne(Resource, def, instance, __options) } else { task = self.loadHasMany(Resource, def, instance, __options) } } else if (def.type === 'hasMany' && def.localKeys) { let localKeys = [] let itemKeys = instance[def.localKeys] || [] itemKeys = DSUtils.isArray(itemKeys) ? itemKeys : DSUtils.keys(itemKeys) localKeys = localKeys.concat(itemKeys || []) task = self.findAll(Resource.getResource(relationName), { where: { [relationDef.idAttribute]: { 'in': unique(localKeys).filter((x) => x).map((x) => self.toObjectID(relationDef, x)) } } }, __options).then(function (relatedItems) { DSUtils.set(instance, def.localField, relatedItems) return relatedItems }) } else if (def.type === 'belongsTo' || (def.type === 'hasOne' && def.localKey)) { task = self.loadBelongsTo(Resource, def, instance, __options) } if (task) { tasks.push(task) } } }) return DSUtils.Promise.all(tasks) }).then(function () { return instance }) }, /** * Retrieve the records that match the selection query. * * @name MongoDBAdapter#findAll * @method * @param {Object} Resource The Resource. * @param {Object} query Selection query. * @param {Object} [opts] Configuration options. * @param {string[]} [opts.with=[]] TODO * @return {Promise} */ findAll (Resource, query, options) { const self = this let items = null options = self.origify(options ? DSUtils.copy(options) : {}) options.with = options.with || [] DSUtils.deepMixIn(options, self.getQueryOptions(Resource, query)) const mongoQuery = self.getQuery(Resource, query) return self.getClient().then(function (client) { return new DSUtils.Promise(function (resolve, reject) { options.fields = options.fields || {} client.collection(Resource.table || underscore(Resource.name)).find(mongoQuery, options).toArray((err, r) => { if (err) { reject(err) } else { resolve(self.translateId(r, options)) } }) }) }).then(function (_items) { items = _items let tasks = [] const relationList = Resource.relationList || [] relationList.forEach(function (def) { let relationName = def.relation let relationDef = Resource.getResource(relationName) let containedName = null if (options.with.indexOf(relationName) !== -1) { containedName = relationName } else if (options.with.indexOf(def.localField) !== -1) { containedName = def.localField } if (containedName) { let __options = DSUtils.deepMixIn({}, options.orig ? options.orig() : options) __options.with = options.with.slice() __options = DSUtils._(relationDef, __options) DSUtils.remove(__options.with, containedName) __options.with.forEach(function (relation, i) { if (relation && relation.indexOf(containedName) === 0 && relation.length >= containedName.length && relation[containedName.length] === '.') { __options.with[i] = relation.substr(containedName.length + 1) } else { __options.with[i] = '' } }) let task if (def.foreignKey && (def.type === 'hasOne' || def.type === 'hasMany')) { if (def.type === 'hasMany') { task = self.loadHasMany(Resource, def, items, __options) } else { task = self.loadHasOne(Resource, def, items, __options) } } else if (def.type === 'hasMany' && def.localKeys) { let localKeys = [] items.forEach(function (item) { let itemKeys = item[def.localKeys] || [] itemKeys = DSUtils.isArray(itemKeys) ? itemKeys : Object.keys(itemKeys) localKeys = localKeys.concat(itemKeys || []) }) task = self.findAll(Resource.getResource(relationName), { where: { [relationDef.idAttribute]: { 'in': unique(localKeys).filter((x) => x).map((x) => self.toObjectID(relationDef, x)) } } }, __options).then(function (relatedItems) { items.forEach(function (item) { let attached = [] let itemKeys = item[def.localKeys] || [] itemKeys = DSUtils.isArray(itemKeys) ? itemKeys : DSUtils.keys(itemKeys) relatedItems.forEach(function (relatedItem) { if (itemKeys && itemKeys.indexOf(relatedItem[relationDef.idAttribute]) !== -1) { attached.push(relatedItem) } }) DSUtils.set(item, def.localField, attached) }) return relatedItems }) } else if (def.type === 'belongsTo' || (def.type === 'hasOne' && def.localKey)) { task = self.loadBelongsTo(Resource, def, items, __options) } if (task) { tasks.push(task) } } }) return DSUtils.Promise.all(tasks) }).then(function () { return items }) }, /** * Create a new record. * * @name MongoDBAdapter#create * @method * @param {Object} Resource The Resource. * @param {Object} props The record to be created. * @param {Object} [opts] Configuration options. * @return {Promise} */ create (Resource, props, opts) { const self = this props = DSUtils.removeCircular(DSUtils.omit(props, Resource.relationFields || [])) opts = self.origify(opts) return self.getClient().then(function (client) { return new DSUtils.Promise(function (resolve, reject) { const collection = client.collection(Resource.table || underscore(Resource.name)) const method = collection.insertOne ? DSUtils.isArray(props) ? 'insertMany' : 'insertOne' : 'insert' collection[method](props, opts, function (err, r) { if (err) { reject(err) } else { r = r.ops ? r.ops : r self.translateId(r, opts) resolve(DSUtils.isArray(props) ? r : r[0]) } }) }) }) }, /** * Destroy the record with the given primary key. * * @name MongoDBAdapter#destroy * @method * @param {Object} Resource The Resource. * @param {(string|number)} id Primary key of the record to destroy. * @param {Object} [opts] Configuration options. * @return {Promise} */ destroy (Resource, id, opts) { const self = this opts = self.origify(opts) return self.getClient().then(function (client) { return new DSUtils.Promise(function (resolve, reject) { const mongoQuery = {} mongoQuery[Resource.idAttribute] = self.toObjectID(Resource, id) const collection = client.collection(Resource.table || underscore(Resource.name)) collection[collection.deleteOne ? 'deleteOne' : 'remove'](mongoQuery, opts, function (err) { if (err) { reject(err) } else { resolve() } }) }) }) }, /** * Destroy the records that match the selection query. * * @name MongoDBAdapter#destroyAll * @method * @param {Object} Resource the Resource. * @param {Object} [query] Selection query. * @param {Object} [opts] Configuration options. * @return {Promise} */ destroyAll (Resource, query, opts) { const self = this opts = self.origify(opts ? DSUtils.copy(opts) : {}) return self.getClient().then(function (client) { DSUtils.deepMixIn(opts, self.getQueryOptions(Resource, query)) const mongoQuery = self.getQuery(Resource, query) return new DSUtils.Promise(function (resolve, reject) { const collection = client.collection(Resource.table || underscore(Resource.name)) collection[collection.deleteMany ? 'deleteMany' : 'remove'](mongoQuery, opts, function (err) { if (err) { reject(err) } else { resolve() } }) }) }) }, /** * Apply the given update to the record with the specified primary key. * * @name MongoDBAdapter#update * @method * @param {Object} Resource The Resource. * @param {(string|number)} id The primary key of the record to be updated. * @param {Object} props The update to apply to the record. * @param {Object} [opts] Configuration options. * @return {Promise} */ update (Resource, id, props, opts) { const self = this props = DSUtils.removeCircular(DSUtils.omit(props, Resource.relationFields || [])) opts = self.origify(opts) return self.find(Resource, id, opts).then(function () { return self.getClient() }).then(function (client) { return new DSUtils.Promise(function (resolve, reject) { const mongoQuery = {} mongoQuery[Resource.idAttribute] = self.toObjectID(Resource, id) const collection = client.collection(Resource.table || underscore(Resource.name)) collection[collection.updateOne ? 'updateOne' : 'update'](mongoQuery, { $set: props }, opts, function (err) { if (err) { reject(err) } else { resolve() } }) }) }).then(function () { return self.find(Resource, id, opts) }) }, /** * Apply the given update to all records that match the selection query. * * @name MongoDBAdapter#updateAll * @method * @param {Object} Resource The Resource. * @param {Object} props The update to apply to the selected records. * @param {Object} [query] Selection query. * @param {Object} [opts] Configuration options. * @return {Promise} */ updateAll (Resource, props, query, opts) { const self = this let ids = [] props = DSUtils.removeCircular(DSUtils.omit(props, Resource.relationFields || [])) opts = self.origify(opts ? DSUtils.copy(opts) : {}) const mongoOptions = DSUtils.copy(opts) mongoOptions.multi = true return self.getClient().then(function (client) { const queryOptions = self.getQueryOptions(Resource, query) queryOptions.$set = props const mongoQuery = self.getQuery(Resource, query) return self.findAll(Resource, query, opts).then(function (items) { ids = items.map(function (item) { return self.toObjectID(Resource, item[Resource.idAttribute]) }) return new DSUtils.Promise(function (resolve, reject) { const collection = client.collection(Resource.table || underscore(Resource.name)) collection[collection.updateMany ? 'updateMany' : 'update'](mongoQuery, queryOptions, mongoOptions, function (err) { if (err) { reject(err) } else { resolve() } }) }) }).then(function () { const query = {} query[Resource.idAttribute] = { 'in': ids } return self.findAll(Resource, query, opts) }) }) } })