You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

685 lines
18 KiB

  1. /*!
  2. * Module dependencies
  3. */
  4. var util = require('util');
  5. var utils = require('./utils');
  6. var PromiseProvider = require('./promise_provider');
  7. var Query = require('./query');
  8. var read = Query.prototype.read;
  9. /**
  10. * Aggregate constructor used for building aggregation pipelines.
  11. *
  12. * ####Example:
  13. *
  14. * new Aggregate();
  15. * new Aggregate({ $project: { a: 1, b: 1 } });
  16. * new Aggregate({ $project: { a: 1, b: 1 } }, { $skip: 5 });
  17. * new Aggregate([{ $project: { a: 1, b: 1 } }, { $skip: 5 }]);
  18. *
  19. * Returned when calling Model.aggregate().
  20. *
  21. * ####Example:
  22. *
  23. * Model
  24. * .aggregate({ $match: { age: { $gte: 21 }}})
  25. * .unwind('tags')
  26. * .exec(callback)
  27. *
  28. * ####Note:
  29. *
  30. * - The documents returned are plain javascript objects, not mongoose documents (since any shape of document can be returned).
  31. * - Requires MongoDB >= 2.1
  32. * - Mongoose does **not** cast pipeline stages. `new Aggregate({ $match: { _id: '00000000000000000000000a' } });` will not work unless `_id` is a string in the database. Use `new Aggregate({ $match: { _id: mongoose.Types.ObjectId('00000000000000000000000a') } });` instead.
  33. *
  34. * @see MongoDB http://docs.mongodb.org/manual/applications/aggregation/
  35. * @see driver http://mongodb.github.com/node-mongodb-native/api-generated/collection.html#aggregate
  36. * @param {Object|Array} [ops] aggregation operator(s) or operator array
  37. * @api public
  38. */
  39. function Aggregate() {
  40. this._pipeline = [];
  41. this._model = undefined;
  42. this.options = undefined;
  43. if (arguments.length === 1 && util.isArray(arguments[0])) {
  44. this.append.apply(this, arguments[0]);
  45. } else {
  46. this.append.apply(this, arguments);
  47. }
  48. }
  49. /**
  50. * Binds this aggregate to a model.
  51. *
  52. * @param {Model} model the model to which the aggregate is to be bound
  53. * @return {Aggregate}
  54. * @api public
  55. */
  56. Aggregate.prototype.model = function(model) {
  57. this._model = model;
  58. return this;
  59. };
  60. /**
  61. * Appends new operators to this aggregate pipeline
  62. *
  63. * ####Examples:
  64. *
  65. * aggregate.append({ $project: { field: 1 }}, { $limit: 2 });
  66. *
  67. * // or pass an array
  68. * var pipeline = [{ $match: { daw: 'Logic Audio X' }} ];
  69. * aggregate.append(pipeline);
  70. *
  71. * @param {Object} ops operator(s) to append
  72. * @return {Aggregate}
  73. * @api public
  74. */
  75. Aggregate.prototype.append = function() {
  76. var args = (arguments.length === 1 && util.isArray(arguments[0]))
  77. ? arguments[0]
  78. : utils.args(arguments);
  79. if (!args.every(isOperator)) {
  80. throw new Error('Arguments must be aggregate pipeline operators');
  81. }
  82. this._pipeline = this._pipeline.concat(args);
  83. return this;
  84. };
  85. /**
  86. * Appends a new $project operator to this aggregate pipeline.
  87. *
  88. * Mongoose query [selection syntax](#query_Query-select) is also supported.
  89. *
  90. * ####Examples:
  91. *
  92. * // include a, include b, exclude _id
  93. * aggregate.project("a b -_id");
  94. *
  95. * // or you may use object notation, useful when
  96. * // you have keys already prefixed with a "-"
  97. * aggregate.project({a: 1, b: 1, _id: 0});
  98. *
  99. * // reshaping documents
  100. * aggregate.project({
  101. * newField: '$b.nested'
  102. * , plusTen: { $add: ['$val', 10]}
  103. * , sub: {
  104. * name: '$a'
  105. * }
  106. * })
  107. *
  108. * // etc
  109. * aggregate.project({ salary_k: { $divide: [ "$salary", 1000 ] } });
  110. *
  111. * @param {Object|String} arg field specification
  112. * @see projection http://docs.mongodb.org/manual/reference/aggregation/project/
  113. * @return {Aggregate}
  114. * @api public
  115. */
  116. Aggregate.prototype.project = function(arg) {
  117. var fields = {};
  118. if (typeof arg === 'object' && !util.isArray(arg)) {
  119. Object.keys(arg).forEach(function(field) {
  120. fields[field] = arg[field];
  121. });
  122. } else if (arguments.length === 1 && typeof arg === 'string') {
  123. arg.split(/\s+/).forEach(function(field) {
  124. if (!field) {
  125. return;
  126. }
  127. var include = field[0] === '-' ? 0 : 1;
  128. if (include === 0) {
  129. field = field.substring(1);
  130. }
  131. fields[field] = include;
  132. });
  133. } else {
  134. throw new Error('Invalid project() argument. Must be string or object');
  135. }
  136. return this.append({$project: fields});
  137. };
  138. /**
  139. * Appends a new custom $group operator to this aggregate pipeline.
  140. *
  141. * ####Examples:
  142. *
  143. * aggregate.group({ _id: "$department" });
  144. *
  145. * @see $group http://docs.mongodb.org/manual/reference/aggregation/group/
  146. * @method group
  147. * @memberOf Aggregate
  148. * @param {Object} arg $group operator contents
  149. * @return {Aggregate}
  150. * @api public
  151. */
  152. /**
  153. * Appends a new custom $match operator to this aggregate pipeline.
  154. *
  155. * ####Examples:
  156. *
  157. * aggregate.match({ department: { $in: [ "sales", "engineering" } } });
  158. *
  159. * @see $match http://docs.mongodb.org/manual/reference/aggregation/match/
  160. * @method match
  161. * @memberOf Aggregate
  162. * @param {Object} arg $match operator contents
  163. * @return {Aggregate}
  164. * @api public
  165. */
  166. /**
  167. * Appends a new $skip operator to this aggregate pipeline.
  168. *
  169. * ####Examples:
  170. *
  171. * aggregate.skip(10);
  172. *
  173. * @see $skip http://docs.mongodb.org/manual/reference/aggregation/skip/
  174. * @method skip
  175. * @memberOf Aggregate
  176. * @param {Number} num number of records to skip before next stage
  177. * @return {Aggregate}
  178. * @api public
  179. */
  180. /**
  181. * Appends a new $limit operator to this aggregate pipeline.
  182. *
  183. * ####Examples:
  184. *
  185. * aggregate.limit(10);
  186. *
  187. * @see $limit http://docs.mongodb.org/manual/reference/aggregation/limit/
  188. * @method limit
  189. * @memberOf Aggregate
  190. * @param {Number} num maximum number of records to pass to the next stage
  191. * @return {Aggregate}
  192. * @api public
  193. */
  194. /**
  195. * Appends a new $geoNear operator to this aggregate pipeline.
  196. *
  197. * ####NOTE:
  198. *
  199. * **MUST** be used as the first operator in the pipeline.
  200. *
  201. * ####Examples:
  202. *
  203. * aggregate.near({
  204. * near: [40.724, -73.997],
  205. * distanceField: "dist.calculated", // required
  206. * maxDistance: 0.008,
  207. * query: { type: "public" },
  208. * includeLocs: "dist.location",
  209. * uniqueDocs: true,
  210. * num: 5
  211. * });
  212. *
  213. * @see $geoNear http://docs.mongodb.org/manual/reference/aggregation/geoNear/
  214. * @method near
  215. * @memberOf Aggregate
  216. * @param {Object} parameters
  217. * @return {Aggregate}
  218. * @api public
  219. */
  220. Aggregate.prototype.near = function(arg) {
  221. var op = {};
  222. op.$geoNear = arg;
  223. return this.append(op);
  224. };
  225. /*!
  226. * define methods
  227. */
  228. 'group match skip limit out'.split(' ').forEach(function($operator) {
  229. Aggregate.prototype[$operator] = function(arg) {
  230. var op = {};
  231. op['$' + $operator] = arg;
  232. return this.append(op);
  233. };
  234. });
  235. /**
  236. * Appends new custom $unwind operator(s) to this aggregate pipeline.
  237. *
  238. * Note that the `$unwind` operator requires the path name to start with '$'.
  239. * Mongoose will prepend '$' if the specified field doesn't start '$'.
  240. *
  241. * ####Examples:
  242. *
  243. * aggregate.unwind("tags");
  244. * aggregate.unwind("a", "b", "c");
  245. *
  246. * @see $unwind http://docs.mongodb.org/manual/reference/aggregation/unwind/
  247. * @param {String} fields the field(s) to unwind
  248. * @return {Aggregate}
  249. * @api public
  250. */
  251. Aggregate.prototype.unwind = function() {
  252. var args = utils.args(arguments);
  253. var res = [];
  254. for (var i = 0; i < args.length; ++i) {
  255. var arg = args[i];
  256. if (arg && typeof arg === 'object') {
  257. res.push({ $unwind: arg });
  258. } else if (typeof arg === 'string') {
  259. res.push({
  260. $unwind: (arg && arg.charAt(0) === '$') ? arg : '$' + arg
  261. });
  262. } else {
  263. throw new Error('Invalid arg "' + arg + '" to unwind(), ' +
  264. 'must be string or object');
  265. }
  266. }
  267. return this.append.apply(this, res);
  268. };
  269. /**
  270. * Appends new custom $lookup operator(s) to this aggregate pipeline.
  271. *
  272. * ####Examples:
  273. *
  274. * aggregate.lookup({ from: 'users', localField: 'userId', foreignField: '_id', as: 'users' });
  275. *
  276. * @see $lookup https://docs.mongodb.org/manual/reference/operator/aggregation/lookup/#pipe._S_lookup
  277. * @param {Object} options to $lookup as described in the above link
  278. * @return {Aggregate}
  279. * @api public
  280. */
  281. Aggregate.prototype.lookup = function(options) {
  282. return this.append({$lookup: options});
  283. };
  284. /**
  285. * Appends new custom $sample operator(s) to this aggregate pipeline.
  286. *
  287. * ####Examples:
  288. *
  289. * aggregate.sample(3); // Add a pipeline that picks 3 random documents
  290. *
  291. * @see $sample https://docs.mongodb.org/manual/reference/operator/aggregation/sample/#pipe._S_sample
  292. * @param {Number} size number of random documents to pick
  293. * @return {Aggregate}
  294. * @api public
  295. */
  296. Aggregate.prototype.sample = function(size) {
  297. return this.append({$sample: {size: size}});
  298. };
  299. /**
  300. * Appends a new $sort operator to this aggregate pipeline.
  301. *
  302. * If an object is passed, values allowed are `asc`, `desc`, `ascending`, `descending`, `1`, and `-1`.
  303. *
  304. * If a string is passed, it must be a space delimited list of path names. The sort order of each path is ascending unless the path name is prefixed with `-` which will be treated as descending.
  305. *
  306. * ####Examples:
  307. *
  308. * // these are equivalent
  309. * aggregate.sort({ field: 'asc', test: -1 });
  310. * aggregate.sort('field -test');
  311. *
  312. * @see $sort http://docs.mongodb.org/manual/reference/aggregation/sort/
  313. * @param {Object|String} arg
  314. * @return {Aggregate} this
  315. * @api public
  316. */
  317. Aggregate.prototype.sort = function(arg) {
  318. // TODO refactor to reuse the query builder logic
  319. var sort = {};
  320. if (arg.constructor.name === 'Object') {
  321. var desc = ['desc', 'descending', -1];
  322. Object.keys(arg).forEach(function(field) {
  323. sort[field] = desc.indexOf(arg[field]) === -1 ? 1 : -1;
  324. });
  325. } else if (arguments.length === 1 && typeof arg === 'string') {
  326. arg.split(/\s+/).forEach(function(field) {
  327. if (!field) {
  328. return;
  329. }
  330. var ascend = field[0] === '-' ? -1 : 1;
  331. if (ascend === -1) {
  332. field = field.substring(1);
  333. }
  334. sort[field] = ascend;
  335. });
  336. } else {
  337. throw new TypeError('Invalid sort() argument. Must be a string or object.');
  338. }
  339. return this.append({$sort: sort});
  340. };
  341. /**
  342. * Sets the readPreference option for the aggregation query.
  343. *
  344. * ####Example:
  345. *
  346. * Model.aggregate(..).read('primaryPreferred').exec(callback)
  347. *
  348. * @param {String} pref one of the listed preference options or their aliases
  349. * @param {Array} [tags] optional tags for this query
  350. * @see mongodb http://docs.mongodb.org/manual/applications/replication/#read-preference
  351. * @see driver http://mongodb.github.com/node-mongodb-native/driver-articles/anintroductionto1_1and2_2.html#read-preferences
  352. */
  353. Aggregate.prototype.read = function(pref, tags) {
  354. if (!this.options) {
  355. this.options = {};
  356. }
  357. read.call(this, pref, tags);
  358. return this;
  359. };
  360. /**
  361. * Execute the aggregation with explain
  362. *
  363. * ####Example:
  364. *
  365. * Model.aggregate(..).explain(callback)
  366. *
  367. * @param {Function} callback
  368. * @return {Promise}
  369. */
  370. Aggregate.prototype.explain = function(callback) {
  371. var _this = this;
  372. var Promise = PromiseProvider.get();
  373. return new Promise.ES6(function(resolve, reject) {
  374. if (!_this._pipeline.length) {
  375. var err = new Error('Aggregate has empty pipeline');
  376. if (callback) {
  377. callback(err);
  378. }
  379. reject(err);
  380. return;
  381. }
  382. prepareDiscriminatorPipeline(_this);
  383. _this._model
  384. .collection
  385. .aggregate(_this._pipeline, _this.options || {})
  386. .explain(function(error, result) {
  387. if (error) {
  388. if (callback) {
  389. callback(error);
  390. }
  391. reject(error);
  392. return;
  393. }
  394. if (callback) {
  395. callback(null, result);
  396. }
  397. resolve(result);
  398. });
  399. });
  400. };
  401. /**
  402. * Sets the allowDiskUse option for the aggregation query (ignored for < 2.6.0)
  403. *
  404. * ####Example:
  405. *
  406. * Model.aggregate(..).allowDiskUse(true).exec(callback)
  407. *
  408. * @param {Boolean} value Should tell server it can use hard drive to store data during aggregation.
  409. * @param {Array} [tags] optional tags for this query
  410. * @see mongodb http://docs.mongodb.org/manual/reference/command/aggregate/
  411. */
  412. Aggregate.prototype.allowDiskUse = function(value) {
  413. if (!this.options) {
  414. this.options = {};
  415. }
  416. this.options.allowDiskUse = value;
  417. return this;
  418. };
  419. /**
  420. * Sets the cursor option option for the aggregation query (ignored for < 2.6.0).
  421. * Note the different syntax below: .exec() returns a cursor object, and no callback
  422. * is necessary.
  423. *
  424. * ####Example:
  425. *
  426. * var cursor = Model.aggregate(..).cursor({ batchSize: 1000 }).exec();
  427. * cursor.each(function(error, doc) {
  428. * // use doc
  429. * });
  430. *
  431. * @param {Object} options set the cursor batch size
  432. * @see mongodb http://mongodb.github.io/node-mongodb-native/2.0/api/AggregationCursor.html
  433. */
  434. Aggregate.prototype.cursor = function(options) {
  435. if (!this.options) {
  436. this.options = {};
  437. }
  438. this.options.cursor = options || {};
  439. return this;
  440. };
  441. /**
  442. * Adds a [cursor flag](http://mongodb.github.io/node-mongodb-native/2.1/api/Cursor.html#addCursorFlag)
  443. *
  444. * ####Example:
  445. *
  446. * var cursor = Model.aggregate(..).cursor({ batchSize: 1000 }).exec();
  447. * cursor.each(function(error, doc) {
  448. * // use doc
  449. * });
  450. *
  451. * @param {String} flag
  452. * @param {Boolean} value
  453. * @see mongodb http://mongodb.github.io/node-mongodb-native/2.1/api/Cursor.html#addCursorFlag
  454. */
  455. Aggregate.prototype.addCursorFlag = function(flag, value) {
  456. if (!this.options) {
  457. this.options = {};
  458. }
  459. this.options[flag] = value;
  460. return this;
  461. };
  462. /**
  463. * Executes the aggregate pipeline on the currently bound Model.
  464. *
  465. * ####Example:
  466. *
  467. * aggregate.exec(callback);
  468. *
  469. * // Because a promise is returned, the `callback` is optional.
  470. * var promise = aggregate.exec();
  471. * promise.then(..);
  472. *
  473. * @see Promise #promise_Promise
  474. * @param {Function} [callback]
  475. * @return {Promise}
  476. * @api public
  477. */
  478. Aggregate.prototype.exec = function(callback) {
  479. if (!this._model) {
  480. throw new Error('Aggregate not bound to any Model');
  481. }
  482. var _this = this;
  483. var Promise = PromiseProvider.get();
  484. var options = utils.clone(this.options);
  485. if (options && options.cursor) {
  486. if (options.cursor.async) {
  487. delete options.cursor.async;
  488. return new Promise.ES6(function(resolve) {
  489. if (!_this._model.collection.buffer) {
  490. process.nextTick(function() {
  491. var cursor = _this._model.collection.
  492. aggregate(_this._pipeline, options || {});
  493. resolve(cursor);
  494. callback && callback(null, cursor);
  495. });
  496. return;
  497. }
  498. _this._model.collection.emitter.once('queue', function() {
  499. var cursor = _this._model.collection.
  500. aggregate(_this._pipeline, options || {});
  501. resolve(cursor);
  502. callback && callback(null, cursor);
  503. });
  504. });
  505. }
  506. return this._model.collection.
  507. aggregate(this._pipeline, this.options || {});
  508. }
  509. return new Promise.ES6(function(resolve, reject) {
  510. if (!_this._pipeline.length) {
  511. var err = new Error('Aggregate has empty pipeline');
  512. if (callback) {
  513. callback(err);
  514. }
  515. reject(err);
  516. return;
  517. }
  518. prepareDiscriminatorPipeline(_this);
  519. _this._model
  520. .collection
  521. .aggregate(_this._pipeline, _this.options || {}, function(error, result) {
  522. if (error) {
  523. if (callback) {
  524. callback(error);
  525. }
  526. reject(error);
  527. return;
  528. }
  529. if (callback) {
  530. callback(null, result);
  531. }
  532. resolve(result);
  533. });
  534. });
  535. };
  536. /**
  537. * Provides promise for aggregate.
  538. *
  539. * ####Example:
  540. *
  541. * Model.aggregate(..).then(successCallback, errorCallback);
  542. *
  543. * @see Promise #promise_Promise
  544. * @param {Function} [resolve] successCallback
  545. * @param {Function} [reject] errorCallback
  546. * @return {Promise}
  547. */
  548. Aggregate.prototype.then = function(resolve, reject) {
  549. var _this = this;
  550. var Promise = PromiseProvider.get();
  551. var promise = new Promise.ES6(function(success, error) {
  552. _this.exec(function(err, val) {
  553. if (err) error(err);
  554. else success(val);
  555. });
  556. });
  557. return promise.then(resolve, reject);
  558. };
  559. /*!
  560. * Helpers
  561. */
  562. /**
  563. * Checks whether an object is likely a pipeline operator
  564. *
  565. * @param {Object} obj object to check
  566. * @return {Boolean}
  567. * @api private
  568. */
  569. function isOperator(obj) {
  570. var k;
  571. if (typeof obj !== 'object') {
  572. return false;
  573. }
  574. k = Object.keys(obj);
  575. return k.length === 1 && k
  576. .some(function(key) {
  577. return key[0] === '$';
  578. });
  579. }
  580. /*!
  581. * Adds the appropriate `$match` pipeline step to the top of an aggregate's
  582. * pipeline, should it's model is a non-root discriminator type. This is
  583. * analogous to the `prepareDiscriminatorCriteria` function in `lib/query.js`.
  584. *
  585. * @param {Aggregate} aggregate Aggregate to prepare
  586. */
  587. function prepareDiscriminatorPipeline(aggregate) {
  588. var schema = aggregate._model.schema,
  589. discriminatorMapping = schema && schema.discriminatorMapping;
  590. if (discriminatorMapping && !discriminatorMapping.isRoot) {
  591. var originalPipeline = aggregate._pipeline,
  592. discriminatorKey = discriminatorMapping.key,
  593. discriminatorValue = discriminatorMapping.value;
  594. // If the first pipeline stage is a match and it doesn't specify a `__t`
  595. // key, add the discriminator key to it. This allows for potential
  596. // aggregation query optimizations not to be disturbed by this feature.
  597. if (originalPipeline[0] && originalPipeline[0].$match && !originalPipeline[0].$match[discriminatorKey]) {
  598. originalPipeline[0].$match[discriminatorKey] = discriminatorValue;
  599. // `originalPipeline` is a ref, so there's no need for
  600. // aggregate._pipeline = originalPipeline
  601. } else if (originalPipeline[0] && originalPipeline[0].$geoNear) {
  602. originalPipeline[0].$geoNear.query =
  603. originalPipeline[0].$geoNear.query || {};
  604. originalPipeline[0].$geoNear.query[discriminatorKey] = discriminatorValue;
  605. } else {
  606. var match = {};
  607. match[discriminatorKey] = discriminatorValue;
  608. aggregate._pipeline = [{$match: match}].concat(originalPipeline);
  609. }
  610. }
  611. }
  612. /*!
  613. * Exports
  614. */
  615. module.exports = Aggregate;