diff --git a/lib/waterline/adapter/aggregateQueries.js b/lib/waterline/adapter/aggregateQueries.js index d286f19f3..0848feb9b 100644 --- a/lib/waterline/adapter/aggregateQueries.js +++ b/lib/waterline/adapter/aggregateQueries.js @@ -127,6 +127,97 @@ module.exports = { if(err) return cb(err); cb(null, models); }); + }, + + // If an optimized findAndModify exists, use it, otherwise use an asynchronous loop with create() +findAndModifyEach: function(attributesToCheck, valuesList, options, cb) { + var self = this; + var connName; + var adapter; + + if(typeof options === 'function') { + cb = options; + // set default values + options = { + upsert: false, + new: true, + mergeArrays: false + }; + } + + var isObjectArray = false; + if (_.isObject(attributesToCheck[0])) { + if (attributesToCheck.length > 1 && + attributesToCheck.length !== valuesList.length) { + return cb(new Error('findAndModifyEach: The two passed arrays have to be of the same length.')); + } + isObjectArray = true; } + // Normalize Arguments + cb = normalize.callback(cb); + + // Clone sensitive data + attributesToCheck = _.clone(attributesToCheck); + valuesList = _.clone(valuesList); + + // Custom user adapter behavior + if(hasOwnProperty(this.dictionary, 'findAndModifyEach')) { + connName = this.dictionary.findAndModifyEach; + adapter = this.connections[connName]._adapter; + + if(hasOwnProperty(adapter, 'findAndModifyEach')) { + return adapter.findAndModifyEach(connName, this.collection, valuesList, options, cb); + } + } + + // Build a list of models + var models = []; + var i = 0; + + async.eachSeries(valuesList, function (values, cb) { + if (!_.isObject(values)) return cb(new Error('findAndModifyEach: Unexpected value in valuesList.')); + + // Check that each of the criteria keys match: + // build a criteria query + var criteria = {}; + + if (isObjectArray) { + if (_.isObject(attributesToCheck[i])) { + Object.keys(attributesToCheck[i]).forEach(function (attrName) { + criteria[attrName] = values[attrName]; + }); + if (attributesToCheck.length > 1) { + i++; + } + } else { + return cb(new Error('findAndModifyEach: Element ' + i + ' in attributesToCheck is not an object.' )); + } + } else { + attributesToCheck.forEach(function (attrName) { + criteria[attrName] = values[attrName]; + }); + } + + return self.findAndModify.call(self, criteria, values, options, function (err, model) { + if(err) return cb(err); + + // if returned models are an array push each result + if (Array.isArray(model)) { + for (var i = 0; i < model.length; i++) { + models.push(model[i]); + }; + } else if (model) { + // Add model to list + models.push(model); + } + + cb(null, model); + }); + }, function (err) { + if(err) return cb(err); + cb(null, models); + }); +} + }; diff --git a/lib/waterline/adapter/compoundQueries.js b/lib/waterline/adapter/compoundQueries.js index cf6b36d99..7b494fef3 100644 --- a/lib/waterline/adapter/compoundQueries.js +++ b/lib/waterline/adapter/compoundQueries.js @@ -41,6 +41,106 @@ module.exports = { self.create(values, cb); }); + }, + + findAndModify: function(criteria, values, options, cb) { + var self = this; + var connName; + var adapter; + + if(typeof options === 'function') { + cb = options; + // set default values + options = { + upsert: false, + new: false, + mergeArrays: false + }; + } + + if (typeof values === 'function') { + cb = values; + values = null + } + + options = options || { }; + + //new: true is the default value + if (!('new' in options)) { + options.new = true; + } + + // // If no values were specified, use criteria + // if (!values) values = criteria.where ? criteria.where : criteria; + + // Normalize Arguments + criteria = normalize.criteria(criteria); + cb = normalize.callback(cb); + + // Build Default Error Message + var err = "No find() or create() method defined in adapter!"; + + // Custom user adapter behavior + if(hasOwnProperty(this.dictionary, 'findAndModify')) { + connName = this.dictionary.findOrCreate; + adapter = this.connections[connName]._adapter; + + if(hasOwnProperty(adapter, 'findAndModify')) { + return adapter.findAndModify(connName, this.collection, values, options, cb); + } + } + + // Default behavior + // WARNING: Not transactional! (unless your data adapter is) + this.findOne(criteria, function(err, result) { + if(err) return cb(err); + if(result) { + + //merging any arrays in the result with any matching arrays in the values + // Note: At this point values should just be an object + if (options.mergeArrays) { + var valueKeys = Object.keys(values); + // Loop over all the results to see if it contains an array + for (var i = 0; i < valueKeys.length; i++) { + // Check if both properties are an array, if one isn't just ignore + // Note: We do not have to explicitly check if the property exists in the result + // because isArray then just returns false + if (Array.isArray(values[valueKeys[i]]) && Array.isArray(result[valueKeys[i]])) { + //now take the union of the arrays + values[valueKeys[i]] = _.union(result[valueKeys[i]], values[[valueKeys[i]]]); + } + } + } + + self.update(criteria, values, function(err, updatedResults) { + if (err) { + return cb(err); + } + // if new is given return the model after it has been updated + if (options.new) { + return cb(null, updatedResults); + } else { + // Unserialize values + return cb(null, result); + } + + }); + } else if (options.upsert) { + // Create a new record if nothing is found and upsert is true. + //Note(globegitter): This might now ignore the 'options.new' flag + //so need to find a proper way to test/verify that. + self.create(values, function(err, result) { + if(err) return cb(err); + if (options.new) { + return cb(null, result); + } else { + return cb(null, []); + } + }); + } else { + return cb(null, []); + } + }); } }; diff --git a/lib/waterline/core/typecast.js b/lib/waterline/core/typecast.js index 35f420ccc..e32ce30df 100644 --- a/lib/waterline/core/typecast.js +++ b/lib/waterline/core/typecast.js @@ -209,6 +209,9 @@ Cast.prototype.date = function date(value) { if (value.__proto__ == Date.prototype) { _value = new Date(value.getTime()); } + else if (typeof value.toDate === 'function') { + _value = value.toDate(); + } else { _value = new Date(Date.parse(value)); } diff --git a/lib/waterline/query/aggregate.js b/lib/waterline/query/aggregate.js index f6f44ab49..7fbad09c9 100644 --- a/lib/waterline/query/aggregate.js +++ b/lib/waterline/query/aggregate.js @@ -89,98 +89,167 @@ module.exports = { valuesList = null; } - // Normalize criteria - criteria = normalize.criteria(criteria); + return _asyncRun('findOrCreateEach', this, criteria, valuesList, null, cb); - // Return Deferred or pass to adapter - if(typeof cb !== 'function') { - return new Deferred(this, this.findOrCreateEach, criteria, valuesList); + }, + /** + * Iterate through a list of objects, trying to find each one + * For any that exist update them. If upsert is true, create all that + * don't exits + * + * @param {Object} criteria search criteria + * @param {Object} values values to update if record found + * @param {Object} [options] + * @param {Boolean} [options.upsert] If true, creates the object if not found + * @param {Boolean} [options.new] If true returns the newly created object, otherwise + * returns either the model before it was updated/created + * @param {Boolean} [options.mergeArrays] If true, merges any arrays passed in values + * @param {Function} [cb] callback + * @return Deferred object if no callback is given + */ + + findAndModifyEach: function(criteria, valuesList, options, cb) { + var self = this; + + if(typeof options === 'function') { + // cb = options; + // set default values + options = { + upsert: false, + new: false, + mergeArrays: false + }; } - // Validate Params - var usage = utils.capitalize(this.identity) + '.findOrCreateEach(criteria, valuesList, callback)'; + return _asyncRun('findAndModifyEach', this, criteria, valuesList, options, cb); + } +}; - if(typeof cb !== 'function') return usageError('Invalid callback specified!', usage, cb); - if(!criteria) return usageError('No criteria specified!', usage, cb); - if(!Array.isArray(criteria)) return usageError('No criteria specified!', usage, cb); - if(!valuesList) return usageError('No valuesList specified!', usage, cb); - if(!Array.isArray(valuesList)) return usageError('Invalid valuesList specified (should be an array!)', usage, cb); +/** + * Runs given queryType. Right now that is either findAndModifyEach or findOrCreateEach + * Essentially just a helper function for shared code. + * + * @param {String} queryType + * @param {Array} criteria + * @param {Array} valuesList + * @param {Object} [options] depends on which queryType calls, if given or not + * @param {Function} [cb] + * @return {String} + * @api private + */ - var errStr = _validateValues(valuesList); - if(errStr) return usageError(errStr, usage, cb); +function _asyncRun(queryType, self, criteria, valuesList, options, cb) { + // Normalize criteria + criteria = normalize.criteria(criteria); - // Validate each record in the array and if all are valid - // pass the array to the adapter's findOrCreateEach method - var validateItem = function(item, next) { - _validate.call(self, item, next); - } + if (typeof options === 'function') { + cb = options; + options = null; + } + var optionsString = ''; + if (options) { + optionsString = ', options' + } - async.each(valuesList, validateItem, function(err) { - if(err) return cb(err); + // Return Deferred or pass to adapter + if(typeof cb !== 'function') { + return new Deferred(self, self[queryType], criteria, valuesList, options); + } - // Transform Values - var transformedValues = []; + // Validate Params + var usage = utils.capitalize(self.identity) + '.' + queryType + '(criteria, valuesList ' + optionsString + ', callback)'; - valuesList.forEach(function(value) { + if(typeof cb !== 'function') return usageError('Invalid callback specified!', usage, cb); + if(!criteria) return usageError('No criteria specified!', usage, cb); + if(!Array.isArray(criteria)) return usageError('No criteria specified!', usage, cb); + if(!valuesList) return usageError('No valuesList specified!', usage, cb); + if(!Array.isArray(valuesList)) return usageError('Invalid valuesList specified (should be an array!)', usage, cb); - // Transform values - value = self._transformer.serialize(value); + var errStr = _validateValues(valuesList); + if(errStr) return usageError(errStr, usage, cb); - // Clean attributes - value = self._schema.cleanValues(value); - transformedValues.push(value); - }); + // Validate each record in the array and if all are valid + // pass the array to the adapter's findOrCreateEach method + var validateItem = function(item, next) { + _validate.call(self, item, next); + } - // Set values array to the transformed array - valuesList = transformedValues; - // Transform Search Criteria - var transformedCriteria = []; + async.each(valuesList, validateItem, function(err) { + if(err) return cb(err); - criteria.forEach(function(value) { - value = self._transformer.serialize(value); - transformedCriteria.push(value); - }); + // Transform Values + var transformedValues = []; - // Set criteria array to the transformed array - criteria = transformedCriteria; + valuesList.forEach(function(value) { - // Pass criteria and attributes to adapter definition - self.adapter.findOrCreateEach(criteria, valuesList, function(err, values) { - if(err) return cb(err); + // Transform values + value = self._transformer.serialize(value); - // Unserialize Values - var unserializedValues = []; + // Clean attributes + value = self._schema.cleanValues(value); + transformedValues.push(value); + }); - values.forEach(function(value) { - value = self._transformer.unserialize(value); - unserializedValues.push(value); - }); + // Set values array to the transformed array + valuesList = transformedValues; + + // Transform Search Criteria + var transformedCriteria = []; + + criteria.forEach(function(value) { + value = self._transformer.serialize(value); + transformedCriteria.push(value); + }); + + // Set criteria array to the transformed array + criteria = transformedCriteria; + + var cbFunction = function(err, values) { + if(err) return cb(err); - // Set values array to the transformed array - values = unserializedValues; + // Unserialize Values + var unserializedValues = []; - // Run AfterCreate Callbacks - async.each(values, function(item, next) { - callbacks.afterCreate(self, item, next); - }, function(err) { - if(err) return cb(err); + values.forEach(function(value) { + value = self._transformer.unserialize(value); + unserializedValues.push(value); + }); + + // Set values array to the transformed array + values = unserializedValues; - var models = []; + // Run AfterCreate Callbacks + async.each(values, function(item, next) { + callbacks.afterCreate(self, item, next); + }, function(err) { + if(err) return cb(err); - // Make each result an instance of model - values.forEach(function(value) { - models.push(new self._model(value)); - }); + var models = []; - cb(null, models); + // Make each result an instance of model + values.forEach(function(value) { + models.push(new self._model(value)); }); + + cb(null, models); }); - }); - } -}; + }; + var params = [criteria, valuesList]; + + //Currently only the findAndModifyEach function has an options parameter + if (queryType === 'findAndModifyEach') { + params.push(options); + } + + params.push(cbFunction); + // Pass criteria and attributes to adapter definition + //the first argument makes sure the function has access to the right 'this' + self.adapter[queryType].apply(self.adapter, params); + }); +} /** * Validate valuesList diff --git a/lib/waterline/query/composite.js b/lib/waterline/query/composite.js index 0c53f53cd..fc51a8f9e 100644 --- a/lib/waterline/query/composite.js +++ b/lib/waterline/query/composite.js @@ -1,25 +1,25 @@ /** - * Composite Queries - */ +* Composite Queries +*/ var async = require('async'), - _ = require('lodash'), - usageError = require('../utils/usageError'), - utils = require('../utils/helpers'), - normalize = require('../utils/normalize'), - Deferred = require('./deferred'), - hasOwnProperty = utils.object.hasOwnProperty; +_ = require('lodash'), +usageError = require('../utils/usageError'), +utils = require('../utils/helpers'), +normalize = require('../utils/normalize'), +Deferred = require('./deferred'), +hasOwnProperty = utils.object.hasOwnProperty; module.exports = { /** - * Find or Create a New Record - * - * @param {Object} search criteria - * @param {Object} values to create if no record found - * @param {Function} callback - * @return Deferred object if no callback - */ + * Find or Create a New Record + * + * @param {Object} search criteria + * @param {Object} values to create if no record found + * @param {Function} callback + * @return Deferred object if no callback + */ findOrCreate: function(criteria, values, cb) { var self = this; @@ -72,6 +72,142 @@ module.exports = { return cb(null, result); }); }); + }, + + /** + * Finds and Updates a Record. If upsert is passed also creates it when it does + * not find it. + * + * @param {Object} criteria search criteria + * @param {Object} values values to update if record found + * @param {Object} [options] + * @param {Boolean} [options.upsert] If true, creates the object if not found + * @param {Boolean} [options.new] If true returns the newly created object, otherwise + * returns either the model before it was updated/created. Defaults to true. + * @param {Boolean} [options.mergeArrays] If true, merges any arrays passed in values + * @param {Function} [cb] callback + * @return Deferred object if no callback is given + */ + + findAndModify: function(criteria, values, options, cb) { + var self = this; + + if(typeof options === 'function') { + cb = options; + // set default values + options = { + upsert: false, + new: false, + mergeArrays: false + }; + } + + if (typeof values === 'function') { + cb = values; + values = null + } + + options = options || { }; + + if (!('new' in options)) { + options.new = true; + } + + // If no criteria is specified, bail out with a vengeance. + var usage = utils.capitalize(this.identity) + '.findAndModify([criteria], values, upsert, new, callback)'; + if(typeof cb == 'function' && (!criteria || criteria.length === 0)) { + return usageError('No criteria option specified!', usage, cb); + } + + // If no values are specified, bail out with a vengeance. + usage = utils.capitalize(this.identity) + '.findAndModify(criteria, [values], upsert, new, callback)'; + if(typeof cb == 'function' && (!values || values.length === 0)) { + return usageError('No values option specified!', usage, cb); + } + + // Normalize criteria + criteria = normalize.criteria(criteria); + + // Return Deferred or pass to adapter + if(typeof cb !== 'function') { + return new Deferred(this, this.findAndModify, criteria, values, options); + } + + // If an array of length 1 is passed convert, otherwise call findAndModifyEach + if(Array.isArray(criteria) && Array.isArray(values)) { + if (criteria.length > 1 || values.length > 1) { + // return usageError('Passing an array of models is not supported yet!', usage, cb); + return this.findAndModifyEach(criteria, values, options, cb); + } else if (criteria.length === 1 && values.length === 1){ + criteria = criteria[0]; + values = values[0]; + } + } + + // if(typeof cb !== 'function') return usageError('Invalid callback specified!', usage, cb); + + // Try a find first. + this.find(criteria).exec(function(err, results) { + if (err) return cb(err); + + if (results && results.length !== 0) { + + // merging together any passed 'type: array' values with all the found ones + // you should usually use mergeArrays only if you are searching for a unique + // indexed element. + if (options.mergeArrays) { + for (var i = 0; i < results.length; i++) { + var result = results[i]; + var valueKeys = Object.keys(values); + //Loop over all the value properties to see if it contains an array + for (var j = 0; j < valueKeys.length; j++) { + // Check if both properties are an array, if one isn't just ignore + // Note: We do not have to explicitly check if the property exists in the result + // because isArray then just returns false + if (Array.isArray(values[valueKeys[i]]) && Array.isArray(result[valueKeys[i]])) { + //now take the union of the arrays + values[valueKeys[i]] = _.union(result[valueKeys[i]], values[[valueKeys[i]]]); + } + } + } + } + + //Then update + self.update(criteria, values).exec(function(err, updatedResults) { + if (err) { + return cb(err); + } + // if new is given return the model after it has been updated + if (options.new) { + // Unserialize values + results = self._transformer.unserialize(updatedResults[0]); + } else { + // Unserialize values + results = self._transformer.unserialize(results[0]); + } + + + // Return an instance of Model + var model = new self._model(results); + return cb(null, results); + }); + } else if (options.upsert) { + // Create a new record if nothing is found and upsert is true. + self.create(values).exec(function(err, result) { + if(err) return cb(err); + + // if new is given return the model after it has been created + //an empty array otherwise + if (options.new) { + return cb(null, result); + } else { + return cb(null, []); + } + }); + } else { + return cb(null, []); + } + }); } }; diff --git a/lib/waterline/query/deferred.js b/lib/waterline/query/deferred.js index 3042627bc..238ffd530 100644 --- a/lib/waterline/query/deferred.js +++ b/lib/waterline/query/deferred.js @@ -16,7 +16,7 @@ var Promise = require('bluebird'), // that were created using Q Promise.prototype.fail = Promise.prototype.catch; -var Deferred = module.exports = function(context, method, criteria, values) { +var Deferred = module.exports = function(context, method, criteria, values, options) { if(!context) return new Error('Must supply a context to a new Deferred object. Usage: new Deferred(context, method, criteria)'); if(!method) return new Error('Must supply a method to a new Deferred object. Usage: new Deferred(context, method, criteria)'); @@ -25,6 +25,7 @@ var Deferred = module.exports = function(context, method, criteria, values) { this._method = method; this._criteria = criteria; this._values = values || null; + this._options = options || { }; this._deferred = null; // deferred object for promises @@ -494,8 +495,16 @@ Deferred.prototype.exec = function(cb) { cb = normalize.callback(cb); // Set up arguments + callback - var args = [this._criteria, cb]; - if(this._values) args.splice(1, 0, this._values); + var args = [this._criteria]; + + if(this._values) { + args.push(this._values); + } + + if(this._options && Object.keys(this._options).length > 0) { + args.push(this._options); + } + args.push(cb); // Pass control to the adapter with the appropriate arguments. this._method.apply(this._context, args); diff --git a/test/unit/core/core.cast/cast.date.js b/test/unit/core/core.cast/cast.date.js index 6db3063e8..906116483 100644 --- a/test/unit/core/core.cast/cast.date.js +++ b/test/unit/core/core.cast/cast.date.js @@ -38,5 +38,15 @@ describe('Core Type Casting', function() { assert(values.name.toUTCString() === 'Wed, 18 Sep 2013 00:00:00 GMT'); }); + it('should objects that implement toDate()', function() { + function Foo() {} + Foo.prototype.toDate = function () { return new Date(1379462400000); }; + var values = person._cast.run({ + name: new Foo() + }); + assert(values.name.constructor.name === 'Date'); + assert(values.name.toUTCString() === 'Wed, 18 Sep 2013 00:00:00 GMT'); + }); + }); }); diff --git a/test/unit/query/query.findAndModify.js b/test/unit/query/query.findAndModify.js new file mode 100644 index 000000000..02d21c282 --- /dev/null +++ b/test/unit/query/query.findAndModify.js @@ -0,0 +1,164 @@ +var Waterline = require('../../../lib/waterline'), + assert = require('assert'); + +describe('Collection Query', function() { + + describe('.findAndModify()', function() { + + describe('with proper values', function() { + var query; + + before(function(done) { + + var waterline = new Waterline(); + var Model = Waterline.Collection.extend({ + identity: 'user', + connection: 'foo', + attributes: { + name: { + type: 'string', + defaultsTo: 'Foo Bar' + }, + doSomething: function() {} + } + }); + + waterline.loadCollection(Model); + + // Fixture Adapter Def + var adapterDef = { + find: function(con, col, criteria, cb) { return cb(null, []); }, + create: function(con, col, values, cb) { return cb(null, values); } + }; + + var connections = { + 'foo': { + adapter: 'foobar' + } + }; + + waterline.initialize({ adapters: { foobar: adapterDef }, connections: connections }, function(err, colls) { + if(err) return done(err); + query = colls.collections.user; + done(); + }); + }); + + it('should get empty array without upsert flag', function(done) { + query.findAndModify({ }, { name: 'Foo Bar' }, function(err, status) { + assert(status.length === 0); + done(); + }); + }); + + it('should get empty array with exec', function(done) { + query.findAndModify({ }, { name: 'Foo Bar' }).exec(function(err, status) { + assert(status.length === 0); + done(); + }); + }); + + it('should return empty model, before it got created, with new: false', function(done) { + query.findAndModify({ }, { name: 'Bar Foo'}, { upsert: true, new: false }).exec(function(err, status) { + assert(status.length === 0); + done(); + }); + }); + + it('should return created model with upsert: true option', function(done) { + query.findAndModify({ }, { name: 'Bar Foo'}, { upsert: true }).exec(function(err, status) { + assert(status.name === 'Bar Foo'); + done(); + }); + }); + + it('should work with multiple objects', function(done) { + query.findAndModify({ }, [{ name: 'Bar Foo'}, { name: 'Makis' }], { upsert: true, new: true }).exec(function(err, status) { + assert(status[0].name === 'Bar Foo'); + assert(status[1].name === 'Makis'); + done(); + }); + }); + + it('should add timestamps', function(done) { + query.findAndModify({ }, { }, { upsert: true, new: true }, function(err, status) { + assert(status.createdAt); + assert(status.updatedAt); + done(); + }); + }); + + it('should strip values that don\'t belong to the schema', function(done) { + query.findAndModify({ }, { foo: 'bar' }, { upsert: true, new: true }, function(err, values) { + assert(!values.foo); + done(); + }); + }); + + it('should return an instance of Model', function(done) { + query.findAndModify({ }, { name: 'Rice' }, { upsert: true, new: true }, function(err, status) { + assert(typeof status.doSomething === 'function'); + done(); + }); + }); + + it('should allow a query to be built using deferreds', function(done) { + query.findAndModify(null, null, { upsert: true, new: true }) + .where({ }) + .set({ name: 'bob' }) + .exec(function(err, result) { + assert(!err); + assert(result); + assert(result.name === 'bob'); + done(); + }); + }); + }); + + describe('casting values', function() { + var query; + + before(function(done) { + + var waterline = new Waterline(); + var Model = Waterline.Collection.extend({ + identity: 'user', + connection: 'foo', + attributes: { + name: 'string', + age: 'integer' + } + }); + + waterline.loadCollection(Model); + + // Fixture Adapter Def + var adapterDef = { + find: function(con, col, criteria, cb) { return cb(null, []); }, + create: function(con, col, values, cb) { return cb(null, values); } + }; + + var connections = { + 'foo': { + adapter: 'foobar' + } + }; + + waterline.initialize({ adapters: { foobar: adapterDef }, connections: connections }, function(err, colls) { + if(err) return done(err); + query = colls.collections.user; + done(); + }); + }); + + it('should cast values before sending to adapter', function(done) { + query.findAndModify({ }, { name: 'foo', age: '27' }, { upsert: true, new: true }, function(err, values) { + assert(values.name === 'foo'); + assert(values.age === 27); + done(); + }); + }); + }); + + }); +});