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 * @tutorial events
141 *
142 * @description
143 * Fired after a `fetch` operation. A promise may be returned from the
144 * event handler for async behaviour.
145 *
146 * @param {Collection} collection The collection performing the {@link Collection#fetch}.
147 * @param {Object} response Knex query response.
148 * @param {Object} options Options object passed to {@link Collection#fetch fetch}.
149 * @returns {Promise}
150 */
151 return this.triggerThen('fetched', this, response, options);
152 })
153 .catch(this.constructor.EmptyError, function(err) {
154 if (options.require) {
155 throw err;
156 }
157 this.reset([], {silent: true});
158 })
159 .return(this)
160 );
161 }),
162
163 /**
164 * @method Collection#count
165 * @since 0.8.2
166 * @description
167 *
168 * Get the number of records in the collection's table.
169 *
170 * @example
171 *
172 * // select count(*) from shareholders where company_id = 1 and share &gt; 0.1;
173 * Company.forge({id:1})
174 * .shareholders()
175 * .query('where', 'share', '>', '0.1')
176 * .count()
177 * .then(function(count) {
178 * assert(count === 3);
179 * });
180 *
181 * @param {string} [column='*']
182 * Specify a column to count - rows with null values in this column will be excluded.
183 * @param {Object=} options
184 * Hash of options.
185 * @returns {Promise<Number>}
186 * A promise resolving to the number of matching rows.
187 */
188 count: Promise.method(function(column, options) {
189 if (!_.isString(column)) {
190 options = column;
191 column = undefined;
192 }
193 if (options) options = _.clone(options);
194 return this.sync(options).count(column);
195 }),
196
197 /**
198 * @method Collection#fetchOne
199 * @description
200 *
201 * Fetch and return a single {@link Model model} from the collection,
202 * maintaining any {@link Relation relation} data from the collection, and
203 * any {@link Collection#query query} parameters that have already been passed
204 * to the collection. Especially helpful on relations, where you would only
205 * like to return a single model from the associated collection.
206 *
207 * @example
208 *
209 * // select * from authors where site_id = 1 and id = 2 limit 1;
210 * new Site({id:1})
211 * .authors()
212 * .query({where: {id: 2}})
213 * .fetchOne()
214 * .then(function(model) {
215 * // ...
216 * });
217 *
218 * @param {Object=} options
219 * @param {Boolean} [options.require=false]
220 * If `true`, will reject the returned response with a {@link
221 * Model.NotFoundError NotFoundError} if no result is found.
222 * @param {(string|string[])} [options.columns='*']
223 * Limit the number of columns fetched.
224 * @param {Transaction} [options.transacting]
225 * Optionally run the query in a transaction.
226 * @param {string} [options.lock]
227 * Type of row-level lock to use. Valid options are `forShare` and
228 * `forUpdate`. This only works in conjunction with the `transacting`
229 * option, and requires a database that supports it.
230 *
231 * @throws {Model.NotFoundError}
232 * @returns {Promise<Model|null>}
233 * A promise resolving to the fetched {@link Model model} or `null` if none exists.
234 */
235 fetchOne: Promise.method(function(options) {
236 const model = new this.model();
237 model._knex = this.query().clone();
238 this.resetQuery();
239 if (this.relatedData) model.relatedData = this.relatedData;
240 return model.fetch(options);
241 }),
242
243 /**
244 * @method Collection#load
245 * @description
246 * `load` is used to eager load relations onto a Collection, in a similar way
247 * that the `withRelated` property works on {@link Collection#fetch fetch}.
248 * Nested eager loads can be specified by separating the nested relations with
249 * `'.'`.
250 *
251 * @param {string|string[]} relations The relation, or relations, to be loaded.
252 * @param {Object=} options Hash of options.
253 * @param {Transaction=} options.transacting
254 * @param {string=} options.lock
255 * Type of row-level lock to use. Valid options are `forShare` and
256 * `forUpdate`. This only works in conjunction with the `transacting`
257 * option, and requires a database that supports it.
258 *
259 * @returns {Promise<Collection>} A promise resolving to this {@link
260 * Collection collection}
261 */
262 load: Promise.method(function(relations, options) {
263 if (!Array.isArray(relations)) relations = [relations];
264 options = _.assignIn({}, options, {
265 shallow: true,
266 withRelated: relations
267 });
268 return new EagerRelation(this.models, this.toJSON(options), new this.model()).fetch(options).return(this);
269 }),
270
271 /**
272 * @method Collection#create
273 * @description
274 *
275 * Convenience method to create a new {@link Model model} instance within a
276 * collection. Equivalent to instantiating a model with a hash of {@link
277 * Model#attributes attributes}, {@link Model#save saving} the model to the
278 * database then adding the model to the collection.
279 *
280 * When used on a relation, `create` will automatically set foreign key
281 * attributes before persisting the `Model`.
282 *
283 * ```
284 * const { courses, ...attributes } = req.body;
285 *
286 * Student.forge(attributes).save().tap(student =>
287 * Promise.map(courses, course => student.related('courses').create(course))
288 * ).then(student =>
289 * res.status(200).send(student)
290 * ).catch(error =>
291 * res.status(500).send(error.message)
292 * );
293 * ```
294 *
295 * @param {Object} model A set of attributes to be set on the new model.
296 * @param {Object=} options
297 * @param {Transaction=} options.transacting
298 *
299 * @returns {Promise<Model>} A promise resolving with the new {@link Modle
300 * model}.
301 */
302 create: Promise.method(function(model, options) {
303 options = options != null ? _.clone(options) : {};
304 const relatedData = this.relatedData;
305 model = this._prepareModel(model, options);
306
307 // If we've already added things on the query chain,
308 // these are likely intended for the model.
309 if (this._knex) {
310 model._knex = this._knex;
311 this.resetQuery();
312 }
313 return Helpers.saveConstraints(model, relatedData)
314 .save(null, options)
315 .bind(this)
316 .then(function() {
317 if (relatedData && relatedData.type === 'belongsToMany') {
318 return this.attach(model, _.omit(options, 'query'));
319 }
320 })
321 .then(function() {
322 this.add(model, options);
323 })
324 .return(model);
325 }),
326
327 /**
328 * @method Collection#resetQuery
329 * @description
330 * Used to reset the internal state of the current query builder instance.
331 * This method is called internally each time a database action is completed
332 * by {@link Sync}.
333 *
334 * @returns {Collection} Self, this method is chainable.
335 */
336 resetQuery: function() {
337 this._knex = null;
338 return this;
339 },
340
341 /**
342 * @method Collection#query
343 * @description
344 *
345 * `query` is used to tap into the underlying Knex query builder instance for
346 * the current collection. If called with no arguments, it will return the
347 * query builder directly. Otherwise, it will call the specified `method` on
348 * the query builder, applying any additional arguments from the
349 * `collection.query` call. If the `method` argument is a function, it will be
350 * called with the Knex query builder as the context and the first argument.
351 *
352 * @example
353 *
354 * let qb = collection.query();
355 * qb.where({id: 1}).select().then(function(resp) {
356 * // ...
357 * });
358 *
359 * collection.query(function(qb) {
360 * qb.where('id', '>', 5).andWhere('first_name', '=', 'Test');
361 * }).fetch()
362 * .then(function(collection) {
363 * // ...
364 * });
365 *
366 * collection
367 * .query('where', 'other_id', '=', '5')
368 * .fetch()
369 * .then(function(collection) {
370 * // ...
371 * });
372 *
373 * @param {function|Object|...string=} arguments The query method.
374 * @returns {Collection|QueryBuilder}
375 * Will return this model or, if called with no arguments, the underlying query builder.
376 *
377 * @see {@link http://knexjs.org/#Builder Knex `QueryBuilder`}
378 */
379 query: function() {
380 return Helpers.query(this, Array.from(arguments));
381 },
382
383 /**
384 * @method Collection#orderBy
385 * @since 0.9.3
386 * @description
387 *
388 * Specifies the column to sort on and sort order.
389 *
390 * The order parameter is optional, and defaults to 'ASC'. You may
391 * also specify 'DESC' order by prepending a hyphen to the sort column
392 * name. `orderBy("date", 'DESC')` is the same as `orderBy("-date")`.
393 *
394 * Unless specified using dot notation (i.e., "table.column"), the default
395 * table will be the table name of the model `orderBy` was called on.
396 *
397 * @example
398 *
399 * Cars.forge().orderBy('color', 'ASC').fetch()
400 * .then(function (rows) { // ...
401 *
402 * @param sort {string}
403 * Column to sort on
404 * @param order {string}
405 * Ascending ('ASC') or descending ('DESC') order
406 */
407 orderBy() {
408 return Helpers.orderBy.apply(null, [this].concat(Array.from(arguments)));
409 },
410
411 /**
412 * @method Collection#query
413 * @private
414 * @description Creates and returns a new `Bookshelf.Sync` instance.
415 */
416 sync: function(options) {
417 return new Sync(this, options);
418 },
419
420 /* Ensure that QueryBuilder is copied on clone. */
421 clone() {
422 const cloned = BookshelfCollection.__super__.clone.apply(this, arguments);
423 if (this._knex != null) {
424 cloned._knex = cloned._builder(this._knex.clone());
425 }
426 return cloned;
427 },
428
429 /**
430 * @method Collection#_handleResponse
431 * @private
432 * @description
433 * Handles the response data for the collection, returning from the
434 * collection's `fetch` call.
435 */
436 _handleResponse: function(response, options) {
437 const relatedData = this.relatedData;
438
439 this.set(response, {
440 merge: options.merge,
441 remove: options.remove,
442 silent: true,
443 parse: true
444 }).invokeMap(function() {
445 this.formatTimestamps();
446 this._reset();
447 this._previousAttributes = _.cloneDeep(this.attributes);
448 });
449
450 if (relatedData && relatedData.isJoined()) {
451 relatedData.parsePivot(this.models);
452 }
453 },
454
455 /**
456 * @method Collection#_handleEager
457 * @private
458 * @description
459 * Handle the related data loading on the collection.
460 */
461 _handleEager: function(response, options) {
462 return new EagerRelation(this.models, response, new this.model()).fetch(options);
463 }
464 },
465 {
466 extended: function(child) {
467 /**
468 * @class Collection.EmptyError
469 * @description
470 * Thrown when no records are found by {@link Collection#fetch fetch},
471 * {@link Model#fetchAll}, or {@link Model.fetchAll} when called with
472 * the `{require: true}` option.
473 */
474 child.EmptyError = createError(this.EmptyError);
475 }
476 }
477));
478
479BookshelfCollection.EmptyError = Errors.EmptyError;