UNPKG

17.4 kBJavaScriptView Raw
1const _ = require('lodash');
2const Sync = require('./sync');
3const Helpers = require('./helpers');
4const EagerRelation = require('./eager');
5const Errors = require('./errors');
6const CollectionBase = require('./base/collection');
7const Promise = require('./base/promise');
8const createError = require('create-error');
9
10/**
11 * @class Collection
12 * @extends CollectionBase
13 * @inheritdoc
14 * @classdesc
15 *
16 * Collections are ordered sets of models returned from the database, from a
17 * {@link Model#fetchAll fetchAll} call. They may be used with a suite of
18 * {@link http://lodash.com/ Lodash} methods.
19 *
20 * @constructor
21 * @description
22 *
23 * When creating a {@link Collection}, you may choose to pass in the initial
24 * array of {@link Model models}. The collection's {@link Collection#comparator
25 * comparator} may be included as an option. Passing `false` as the comparator
26 * option will prevent sorting. If you define an {@link Collection#initialize
27 * initialize} function, it will be invoked when the collection is created.
28 *
29 * @example
30 * let tabs = new TabSet([tab1, tab2, tab3]);
31 *
32 * @param {(Model[])=} models Initial array of models.
33 * @param {Object=} options
34 * @param {Boolean} [options.comparator=false]
35 * {@link Collection#comparator Comparator} for collection, or `false` to disable sorting.
36 */
37const BookshelfCollection = (module.exports = CollectionBase.extend(
38 {
39 /**
40 * @method Collection#through
41 * @description
42 * Used to define passthrough relationships - `hasOne`, `hasMany`, `belongsTo`
43 * or `belongsToMany`, "through" an `Interim` model or collection.
44 *
45 * @param {Model} Interim Pivot model.
46 *
47 * @param {string=} throughForeignKey
48 *
49 * Foreign key in this collection model. By default, the `foreignKey` is assumed to
50 * be the singular form of the `Target` model's tableName, followed by `_id` /
51 * `_{{{@link Model#idAttribute idAttribute}}}`.
52 *
53 * @param {string=} otherKey
54 *
55 * Foreign key in the `Interim` model. By default, the `otherKey` is assumed to
56 * be the singular form of this model's tableName, followed by `_id` /
57 * `_{{{@link Model#idAttribute idAttribute}}}`.
58 *
59 * @param {string=} throughForeignKeyTarget
60 *
61 * Column in the `Target` model which `throughForeignKey` references, if other
62 * than `Target` model's `id` / `{@link Model#idAttribute idAttribute}`.
63 *
64 * @param {string=} otherKeyTarget
65 *
66 * Column in this collection model which `otherKey` references, if other
67 * than `id` / `{@link Model#idAttribute idAttribute}`.
68 *
69 * @returns {Collection}
70 */
71 through: function(Interim, throughForeignKey, otherKey, throughForeignKeyTarget, otherKeyTarget) {
72 return this.relatedData.through(this, Interim, {
73 throughForeignKey,
74 otherKey,
75 throughForeignKeyTarget,
76 otherKeyTarget
77 });
78 },
79
80 /**
81 * @method Collection#fetch
82 * @description
83 * Fetch the default set of models for this collection from the database,
84 * resetting the collection when they arrive. If you wish to trigger an error
85 * if the fetched collection is empty, pass `{require: true}` as one of the
86 * options to the {@link Collection#fetch fetch} call. A {@link
87 * Collection#fetched "fetched"} event will be fired when records are
88 * successfully retrieved. If you need to constrain the query performed by
89 * `fetch`, you can call the {@link Collection#query query} method before
90 * calling `fetch`.
91 *
92 * *If you'd like to only fetch specific columns, you may specify a `columns`
93 * property in the options for the `fetch` call.*
94 *
95 * The `withRelated` option may be specified to fetch the models of the
96 * collection, eager loading any specified {@link Relation relations} named on
97 * the model. A single property, or an array of properties can be specified as
98 * a value for the `withRelated` property. The results of these relation
99 * queries will be loaded into a relations property on the respective models,
100 * may be retrieved with the {@link Model#related related} method.
101 *
102 * @fires Collection#fetched
103 * @throws {Collection.EmptyError}
104 * Upon a sucessful query resulting in no records returns. Only fired if `require: true` is passed as an option.
105 *
106 * @param {Object=} options
107 * @param {Boolean} [options.require=false] Trigger a {@link Collection.EmptyError} if no records are found.
108 * @param {string|string[]} [options.withRelated=[]] A relation, or list of relations, to be eager loaded as part of the `fetch` operation.
109 * @returns {Promise<Collection>}
110 */
111 fetch: Promise.method(function(options) {
112 options = options ? _.clone(options) : {};
113 return (
114 this.sync(options)
115 .select()
116 .bind(this)
117 .tap(function(response) {
118 if (!response || response.length === 0) {
119 throw new this.constructor.EmptyError('EmptyResponse');
120 }
121 })
122
123 // Now, load all of the data onto the collection as necessary.
124 .tap(function(response) {
125 return this._handleResponse(response, options);
126 })
127
128 // If the "withRelated" is specified, we also need to eager load all of the
129 // data on the collection, as a side-effect, before we ultimately jump into the
130 // next step of the collection. Since the `columns` are only relevant to the current
131 // level, ensure those are omitted from the options.
132 .tap(function(response) {
133 if (options.withRelated) {
134 return this._handleEager(response, _.omit(options, 'columns'));
135 }
136 })
137 .tap(function(response) {
138 /**
139 * @event Collection#fetched
140 *
141 * @description
142 * Fired after a `fetch` operation. A promise may be returned from the
143 * event handler for async behaviour.
144 *
145 * @param {Collection} collection The collection performing the {@link Collection#fetch}.
146 * @param {Object} response Knex query response.
147 * @param {Object} options Options object passed to {@link Collection#fetch fetch}.
148 * @returns {Promise}
149 */
150 return this.triggerThen('fetched', this, response, options);
151 })
152 .catch(this.constructor.EmptyError, function(err) {
153 if (options.require) {
154 throw err;
155 }
156 this.reset([], {silent: true});
157 })
158 .return(this)
159 );
160 }),
161
162 /**
163 * @method Collection#count
164 * @since 0.8.2
165 * @description
166 *
167 * Get the number of records in the collection's table.
168 *
169 * @example
170 *
171 * // select count(*) from shareholders where company_id = 1 and share &gt; 0.1;
172 * Company.forge({id:1})
173 * .shareholders()
174 * .query('where', 'share', '>', '0.1')
175 * .count()
176 * .then(function(count) {
177 * assert(count === 3);
178 * });
179 *
180 * @param {string} [column='*']
181 * Specify a column to count - rows with null values in this column will be excluded.
182 * @param {Object=} options
183 * Hash of options.
184 * @returns {Promise<Number>}
185 * A promise resolving to the number of matching rows.
186 */
187 count: Promise.method(function(column, options) {
188 if (!_.isString(column)) {
189 options = column;
190 column = undefined;
191 }
192 if (options) options = _.clone(options);
193 return this.sync(options).count(column);
194 }),
195
196 /**
197 * @method Collection#fetchOne
198 * @description
199 *
200 * Fetch and return a single {@link Model model} from the collection,
201 * maintaining any {@link Relation relation} data from the collection, and
202 * any {@link Collection#query query} parameters that have already been passed
203 * to the collection. Especially helpful on relations, where you would only
204 * like to return a single model from the associated collection.
205 *
206 * @example
207 *
208 * // select * from authors where site_id = 1 and id = 2 limit 1;
209 * new Site({id:1})
210 * .authors()
211 * .query({where: {id: 2}})
212 * .fetchOne()
213 * .then(function(model) {
214 * // ...
215 * });
216 *
217 * @param {Object=} options
218 * @param {Boolean} [options.require=false]
219 * If `true`, will reject the returned response with a {@link
220 * Model.NotFoundError NotFoundError} if no result is found.
221 * @param {(string|string[])} [options.columns='*']
222 * Limit the number of columns fetched.
223 * @param {Transaction} [options.transacting]
224 * Optionally run the query in a transaction.
225 * @param {string} [options.lock]
226 * Type of row-level lock to use. Valid options are `forShare` and
227 * `forUpdate`. This only works in conjunction with the `transacting`
228 * option, and requires a database that supports it.
229 *
230 * @throws {Model.NotFoundError}
231 * @returns {Promise<Model|null>}
232 * A promise resolving to the fetched {@link Model model} or `null` if none exists.
233 */
234 fetchOne: Promise.method(function(options) {
235 const model = new this.model();
236 model._knex = this.query().clone();
237 this.resetQuery();
238 if (this.relatedData) model.relatedData = this.relatedData;
239 return model.fetch(options);
240 }),
241
242 /**
243 * @method Collection#load
244 * @description
245 * `load` is used to eager load relations onto a Collection, in a similar way
246 * that the `withRelated` property works on {@link Collection#fetch fetch}.
247 * Nested eager loads can be specified by separating the nested relations with
248 * `'.'`.
249 *
250 * @param {string|string[]} relations The relation, or relations, to be loaded.
251 * @param {Object=} options Hash of options.
252 * @param {Transaction=} options.transacting
253 * @param {string=} options.lock
254 * Type of row-level lock to use. Valid options are `forShare` and
255 * `forUpdate`. This only works in conjunction with the `transacting`
256 * option, and requires a database that supports it.
257 *
258 * @returns {Promise<Collection>} A promise resolving to this {@link
259 * Collection collection}
260 */
261 load: Promise.method(function(relations, options) {
262 if (!Array.isArray(relations)) relations = [relations];
263 options = _.assignIn({}, options, {
264 shallow: true,
265 withRelated: relations
266 });
267 return new EagerRelation(this.models, this.toJSON(options), new this.model()).fetch(options).return(this);
268 }),
269
270 /**
271 * @method Collection#create
272 * @description
273 *
274 * Convenience method to create a new {@link Model model} instance within a
275 * collection. Equivalent to instantiating a model with a hash of {@link
276 * Model#attributes attributes}, {@link Model#save saving} the model to the
277 * database then adding the model to the collection.
278 *
279 * When used on a relation, `create` will automatically set foreign key
280 * attributes before persisting the `Model`.
281 *
282 * ```
283 * const { courses, ...attributes } = req.body;
284 *
285 * Student.forge(attributes).save().tap(student =>
286 * Promise.map(courses, course => student.related('courses').create(course))
287 * ).then(student =>
288 * res.status(200).send(student)
289 * ).catch(error =>
290 * res.status(500).send(error.message)
291 * );
292 * ```
293 *
294 * @param {Object} model A set of attributes to be set on the new model.
295 * @param {Object=} options
296 * @param {Transaction=} options.transacting
297 *
298 * @returns {Promise<Model>} A promise resolving with the new {@link Modle
299 * model}.
300 */
301 create: Promise.method(function(model, options) {
302 options = options != null ? _.clone(options) : {};
303 const relatedData = this.relatedData;
304 model = this._prepareModel(model, options);
305
306 // If we've already added things on the query chain,
307 // these are likely intended for the model.
308 if (this._knex) {
309 model._knex = this._knex;
310 this.resetQuery();
311 }
312 return Helpers.saveConstraints(model, relatedData)
313 .save(null, options)
314 .bind(this)
315 .then(function() {
316 if (relatedData && relatedData.type === 'belongsToMany') {
317 return this.attach(model, _.omit(options, 'query'));
318 }
319 })
320 .then(function() {
321 this.add(model, options);
322 })
323 .return(model);
324 }),
325
326 /**
327 * @method Collection#resetQuery
328 * @description
329 * Used to reset the internal state of the current query builder instance.
330 * This method is called internally each time a database action is completed
331 * by {@link Sync}.
332 *
333 * @returns {Collection} Self, this method is chainable.
334 */
335 resetQuery: function() {
336 this._knex = null;
337 return this;
338 },
339
340 /**
341 * @method Collection#query
342 * @description
343 *
344 * `query` is used to tap into the underlying Knex query builder instance for
345 * the current collection. If called with no arguments, it will return the
346 * query builder directly. Otherwise, it will call the specified `method` on
347 * the query builder, applying any additional arguments from the
348 * `collection.query` call. If the `method` argument is a function, it will be
349 * called with the Knex query builder as the context and the first argument.
350 *
351 * @example
352 *
353 * let qb = collection.query();
354 * qb.where({id: 1}).select().then(function(resp) {
355 * // ...
356 * });
357 *
358 * collection.query(function(qb) {
359 * qb.where('id', '>', 5).andWhere('first_name', '=', 'Test');
360 * }).fetch()
361 * .then(function(collection) {
362 * // ...
363 * });
364 *
365 * collection
366 * .query('where', 'other_id', '=', '5')
367 * .fetch()
368 * .then(function(collection) {
369 * // ...
370 * });
371 *
372 * @param {function|Object|...string=} arguments The query method.
373 * @returns {Collection|QueryBuilder}
374 * Will return this model or, if called with no arguments, the underlying query builder.
375 *
376 * @see {@link http://knexjs.org/#Builder Knex `QueryBuilder`}
377 */
378 query: function() {
379 return Helpers.query(this, Array.from(arguments));
380 },
381
382 /**
383 * @method Collection#orderBy
384 * @since 0.9.3
385 * @description
386 *
387 * Specifies the column to sort on and sort order.
388 *
389 * The order parameter is optional, and defaults to 'ASC'. You may
390 * also specify 'DESC' order by prepending a hyphen to the sort column
391 * name. `orderBy("date", 'DESC')` is the same as `orderBy("-date")`.
392 *
393 * Unless specified using dot notation (i.e., "table.column"), the default
394 * table will be the table name of the model `orderBy` was called on.
395 *
396 * @example
397 *
398 * Cars.forge().orderBy('color', 'ASC').fetch()
399 * .then(function (rows) { // ...
400 *
401 * @param sort {string}
402 * Column to sort on
403 * @param order {string}
404 * Ascending ('ASC') or descending ('DESC') order
405 */
406 orderBy() {
407 return Helpers.orderBy.apply(null, [this].concat(Array.from(arguments)));
408 },
409
410 /**
411 * @method Collection#query
412 * @private
413 * @description Creates and returns a new `Bookshelf.Sync` instance.
414 */
415 sync: function(options) {
416 return new Sync(this, options);
417 },
418
419 /* Ensure that QueryBuilder is copied on clone. */
420 clone() {
421 const cloned = BookshelfCollection.__super__.clone.apply(this, arguments);
422 if (this._knex != null) {
423 cloned._knex = cloned._builder(this._knex.clone());
424 }
425 return cloned;
426 },
427
428 /**
429 * @method Collection#_handleResponse
430 * @private
431 * @description
432 * Handles the response data for the collection, returning from the
433 * collection's `fetch` call.
434 */
435 _handleResponse: function(response, options) {
436 const relatedData = this.relatedData;
437
438 this.set(response, {
439 merge: options.merge,
440 remove: options.remove,
441 silent: true,
442 parse: true
443 }).invokeMap(function() {
444 this.formatTimestamps();
445 this._reset();
446 this._previousAttributes = _.cloneDeep(this.attributes);
447 });
448
449 if (relatedData && relatedData.isJoined()) {
450 relatedData.parsePivot(this.models);
451 }
452 },
453
454 /**
455 * @method Collection#_handleEager
456 * @private
457 * @description
458 * Handle the related data loading on the collection.
459 */
460 _handleEager: function(response, options) {
461 return new EagerRelation(this.models, response, new this.model()).fetch(options);
462 }
463 },
464 {
465 extended: function(child) {
466 /**
467 * @class Collection.EmptyError
468 * @description
469 * Thrown when no records are found by {@link Collection#fetch fetch},
470 * {@link Model#fetchAll}, or {@link Model.fetchAll} when called with
471 * the `{require: true}` option.
472 */
473 child.EmptyError = createError(this.EmptyError);
474 }
475 }
476));
477
478BookshelfCollection.EmptyError = Errors.EmptyError;