UNPKG

33.9 kBJavaScriptView Raw
1const _ = require('lodash');
2const inflection = require('inflection');
3
4const Helpers = require('./helpers');
5const ModelBase = require('./base/model');
6const RelationBase = require('./base/relation');
7const Promise = require('bluebird');
8const constants = require('./constants');
9const push = Array.prototype.push;
10const removePivotPrefix = (key) => key.slice(constants.PIVOT_PREFIX.length);
11const hasPivotPrefix = (key) => _.startsWith(key, constants.PIVOT_PREFIX);
12
13/**
14 * @classdesc
15 * Used internally, the `Relation` class helps in simplifying the relationship building,
16 * centralizing all logic dealing with type and option handling.
17 *
18 * @extends RelationBase
19 * @class
20 */
21const Relation = RelationBase.extend(
22 /** @lends Relation.prototype */ {
23 /**
24 * Assembles the new model or collection we're creating an instance of, gathering any relevant
25 * primitives from the parent object without keeping any hard references.
26 *
27 * @param {Model} parent The parent to which this relation belongs to.
28 * @return {Model|Collection|Object} The new model or collection instance.
29 */
30 init(parent) {
31 this.parentId = parent.id;
32 this.parentTableName = _.result(parent, 'tableName');
33 this.parentIdAttribute = this.attribute('parentIdAttribute', parent);
34
35 // Use formatted attributes so that morphKey and foreignKey will match attribute keys.
36 this.parentAttributes = parent.format(_.clone(parent.attributes));
37
38 if (this.type === 'morphTo' && !parent._isEager) {
39 // If the parent object is eager loading, and it's a polymorphic `morphTo` relation, we
40 // can't know what the target will be until the models are sorted and matched.
41 this.target = Helpers.morphCandidate(this.candidates, this.parentAttributes[this.key('morphKey')]);
42 this.targetTableName = _.result(this.target.prototype, 'tableName');
43 }
44
45 this.targetIdAttribute = this.attribute('targetIdAttribute', parent);
46 this.parentFk = this.attribute('parentFk');
47
48 const target = this.target ? this.relatedInstance() : {};
49 target.relatedData = this;
50
51 if (this.type === 'belongsToMany') {
52 _.extend(target, PivotHelpers);
53 }
54
55 return target;
56 },
57
58 /**
59 * Initializes a `through` relation, setting the `Target` model and `options`, which includes
60 * any additional keys for the relation.
61 *
62 * @param {Model|Collection} source
63 * @param {Model} Target The pivot model the related models or collections run through.
64 * @param {object} options Additional properties to set on the relation object.
65 */
66 through(source, Target, options) {
67 const type = this.type;
68 if (type !== 'hasOne' && type !== 'hasMany' && type !== 'belongsToMany' && type !== 'belongsTo') {
69 throw new Error('`through` is only chainable from `hasOne`, `belongsTo`, `hasMany`, or `belongsToMany`');
70 }
71
72 this.throughTarget = Target;
73 this.throughTableName = _.result(Target.prototype, 'tableName');
74
75 _.extend(this, options);
76 _.extend(source, PivotHelpers);
77
78 this.parentIdAttribute = this.attribute('parentIdAttribute');
79 this.targetIdAttribute = this.attribute('targetIdAttribute');
80 this.throughIdAttribute = this.attribute('throughIdAttribute', Target);
81 this.parentFk = this.attribute('parentFk');
82
83 // Set the appropriate foreign key if we're doing a belongsToMany, for convenience.
84 if (this.type === 'belongsToMany') {
85 this.foreignKey = this.throughForeignKey;
86 } else if (this.otherKey) {
87 this.foreignKey = this.otherKey;
88 }
89
90 return source;
91 },
92
93 /**
94 * Generates and returns a specified key.
95 *
96 * @param {string} keyName
97 * Can be one of `foreignKey`, `morphKey`, `morphValue`, `otherKey` or `throughForeignKey`.
98 * @return {string|undefined}
99 */
100 key(keyName) {
101 if (this[keyName]) return this[keyName];
102 switch (keyName) {
103 case 'otherKey':
104 this[keyName] = singularMemo(this.targetTableName) + '_' + this.targetIdAttribute;
105 break;
106 case 'throughForeignKey':
107 this[keyName] = singularMemo(this.joinTable()) + '_' + this.throughIdAttribute;
108 break;
109 case 'foreignKey':
110 switch (this.type) {
111 case 'morphTo': {
112 const idKeyName = this.columnNames && this.columnNames[1] ? this.columnNames[1] : this.morphName + '_id';
113 this[keyName] = idKeyName;
114 break;
115 }
116 case 'belongsTo':
117 this[keyName] = singularMemo(this.targetTableName) + '_' + this.targetIdAttribute;
118 break;
119 default:
120 if (this.isMorph()) {
121 this[keyName] = this.columnNames && this.columnNames[1] ? this.columnNames[1] : this.morphName + '_id';
122 break;
123 }
124 this[keyName] = singularMemo(this.parentTableName) + '_' + this.parentIdAttribute;
125 break;
126 }
127 break;
128 case 'morphKey':
129 this[keyName] = this.columnNames && this.columnNames[0] ? this.columnNames[0] : this.morphName + '_type';
130 break;
131 case 'morphValue':
132 this[keyName] = this.morphValue || this.parentTableName || this.targetTableName;
133 break;
134 }
135 return this[keyName];
136 },
137
138 /**
139 * Get the correct name for the following attributes:
140 * - parentIdAttribute
141 * - targetIdAttribute
142 * - throughIdAttribute
143 * - parentFk
144 *
145 * @param {string} attribute The attribute name being requested.
146 * @param {Model} [parent] The parent model.
147 * @return {string}
148 */
149 attribute(attribute, parent) {
150 switch (attribute) {
151 case 'parentIdAttribute':
152 if (this.isThrough()) {
153 if (this.type === 'belongsTo' && this.throughForeignKey) {
154 return this.throughForeignKey;
155 }
156
157 if (this.type === 'belongsToMany' && this.isThroughForeignKeyTargeted()) {
158 return this.throughForeignKeyTarget;
159 }
160
161 if (this.isOtherKeyTargeted()) {
162 return this.otherKeyTarget;
163 }
164
165 return this.parentIdAttribute; // Return attribute calculated on `init` by default.
166 }
167
168 if (this.type === 'belongsTo' && this.foreignKey) {
169 return this.foreignKey;
170 }
171
172 if (this.type !== 'belongsTo' && this.isForeignKeyTargeted()) {
173 return this.foreignKeyTarget;
174 }
175
176 return _.result(parent, 'idAttribute');
177
178 case 'targetIdAttribute':
179 if (this.isThrough()) {
180 if ((this.type === 'belongsToMany' || this.type === 'belongsTo') && this.isOtherKeyTargeted()) {
181 return this.otherKeyTarget;
182 }
183
184 return this.targetIdAttribute; // Return attribute calculated on `init` by default.
185 }
186
187 if (this.type === 'morphTo' && !parent._isEager) {
188 return _.result(this.target.prototype, 'idAttribute');
189 }
190
191 if (this.type === 'belongsTo' && this.isForeignKeyTargeted()) {
192 return this.foreignKeyTarget;
193 }
194
195 if (this.type === 'belongsToMany' && this.isOtherKeyTargeted()) {
196 return this.otherKeyTarget;
197 }
198
199 return this.targetIdAttribute;
200
201 case 'throughIdAttribute':
202 if (this.type !== 'belongsToMany' && this.isThroughForeignKeyTargeted()) {
203 return this.throughForeignKeyTarget;
204 }
205
206 if (this.type === 'belongsToMany' && this.throughForeignKey) {
207 return this.throughForeignKey;
208 }
209
210 return _.result(parent.prototype, 'idAttribute');
211
212 case 'parentFk':
213 if (!this.hasParentAttributes()) {
214 return;
215 }
216
217 if (this.isThrough()) {
218 if (this.type === 'belongsToMany' && this.isThroughForeignKeyTargeted()) {
219 return this.parentAttributes[this.throughForeignKeyTarget];
220 }
221
222 if (this.type === 'belongsTo') {
223 return this.throughForeignKey ? this.parentAttributes[this.parentIdAttribute] : this.parentId;
224 }
225
226 if (this.isOtherKeyTargeted()) {
227 return this.parentAttributes[this.otherKeyTarget];
228 }
229
230 return this.parentFk; // Return attribute calculated on `init` by default.
231 }
232
233 return this.parentAttributes[this.isInverse() ? this.key('foreignKey') : this.parentIdAttribute];
234 }
235 },
236
237 /**
238 * Injects the necessary `select` constraints into a `knex` query builder.
239 *
240 * @param {Knex} knex Knex instance.
241 * @param {object} options
242 * @return {undefined}
243 */
244 selectConstraints(knex, options) {
245 const resp = options.parentResponse;
246
247 // The `belongsToMany` and `through` relations have joins & pivot columns.
248 if (this.isJoined()) this.joinClauses(knex);
249
250 // Call the function, if one exists, to constrain the eager loaded query.
251 if (options._beforeFn) options._beforeFn.call(knex, knex);
252
253 // The base select column
254 if (Array.isArray(options.columns)) {
255 knex.columns(options.columns);
256 }
257
258 const currentColumns = _.find(knex._statements, {grouping: 'columns'});
259
260 if (!currentColumns || currentColumns.length === 0) {
261 knex.distinct(this.targetTableName + '.*');
262 }
263
264 if (this.isJoined()) this.joinColumns(knex);
265
266 // If this is a single relation and we're not eager loading limit the query to a single item.
267 if (this.isSingle() && !resp) knex.limit(1);
268
269 // Finally, add (and validate) the WHERE conditions necessary for constraining the relation.
270 this.whereClauses(knex, resp);
271 },
272
273 /**
274 * Injects and validates necessary `through` constraints for the current model.
275 *
276 * @param {Knex} knex Knex instance.
277 * @return {undefined}
278 */
279 joinColumns(knex) {
280 const columns = [];
281 const joinTable = this.joinTable();
282 if (this.isThrough()) columns.push(this.throughIdAttribute);
283 columns.push(this.key('foreignKey'));
284 if (this.type === 'belongsToMany') columns.push(this.key('otherKey'));
285 push.apply(columns, this.pivotColumns);
286 knex.columns(
287 _.map(columns, function(col) {
288 return joinTable + '.' + col + ' as _pivot_' + col;
289 })
290 );
291 },
292
293 /**
294 * Generates the join clauses necessary for the current relation.
295 *
296 * @param {Knex} knex Knex instance.
297 * @return {undefined}
298 */
299 joinClauses(knex) {
300 const joinTable = this.joinTable();
301
302 if (this.type === 'belongsTo' || this.type === 'belongsToMany') {
303 const targetKey = this.type === 'belongsTo' ? this.key('foreignKey') : this.key('otherKey');
304
305 knex.join(joinTable, joinTable + '.' + targetKey, '=', this.targetTableName + '.' + this.targetIdAttribute);
306
307 // A `belongsTo` -> `through` is currently the only relation with two joins.
308 if (this.type === 'belongsTo') {
309 knex.join(
310 this.parentTableName,
311 joinTable + '.' + this.throughIdAttribute,
312 '=',
313 this.parentTableName + '.' + this.key('throughForeignKey')
314 );
315 }
316 } else {
317 knex.join(
318 joinTable,
319 joinTable + '.' + this.throughIdAttribute,
320 '=',
321 this.targetTableName + '.' + this.key('throughForeignKey')
322 );
323 }
324 },
325
326 /**
327 * Check that there isn't an incorrect foreign key set, versus the one passed in when the
328 * relation was formed.
329 *
330 * @param {Knex} knex Knex instance.
331 * @param {object} response
332 * @return {undefined}
333 */
334 whereClauses(knex, response) {
335 let key;
336
337 if (this.isJoined()) {
338 const isBelongsTo = this.type === 'belongsTo';
339 const targetTable = isBelongsTo ? this.parentTableName : this.joinTable();
340
341 const column = isBelongsTo ? this.parentIdAttribute : this.key('foreignKey');
342
343 key = `${targetTable}.${column}`;
344 } else {
345 const column = this.isInverse() ? this.targetIdAttribute : this.key('foreignKey');
346
347 key = `${this.targetTableName}.${column}`;
348 }
349
350 const method = response ? 'whereIn' : 'where';
351 const ids = response ? this.eagerKeys(response) : this.parentFk;
352 knex[method](key, ids);
353
354 if (this.isMorph()) {
355 const table = this.targetTableName;
356 const key = this.key('morphKey');
357 const value = this.key('morphValue');
358 knex.where(`${table}.${key}`, value);
359 }
360 },
361
362 /**
363 * Fetches all eagerly loaded foreign keys from the current relation.
364 *
365 * @param {object} response
366 * @return {array} The requested eager keys.
367 */
368 eagerKeys(response) {
369 const key = this.isInverse() && !this.isThrough() ? this.key('foreignKey') : this.parentIdAttribute;
370 return _.reject(
371 _(response)
372 .map(key)
373 .uniq()
374 .value(),
375 _.isNil
376 );
377 },
378
379 /**
380 * Generates the appropriate default join table name for a
381 * {@link Model#belongsToMany belongsToMany} or {@link Model#through through} relation.
382 * The default name is composed of the two table names ordered alphabetically and joined by an
383 * underscore.
384 *
385 * @return {string} The table name.
386 */
387 joinTable() {
388 if (this.isThrough()) return this.throughTableName;
389 return this.joinTableName || [this.parentTableName, this.targetTableName].sort().join('_');
390 },
391
392 /**
393 * Creates a new model or collection instance, depending on the `relatedData` settings and the
394 * models passed in.
395 *
396 * @param {Model[]} [models]
397 * @param {object} [options]
398 * @return {Model|Collection} The new instance.
399 */
400 relatedInstance(models, options) {
401 models = models || [];
402 options = options || {};
403 const Target = this.target;
404
405 // If it's a single model, check whether there's already a model we can pick from, otherwise
406 // create a new instance.
407 if (this.isSingle()) {
408 if (!(Target.prototype instanceof ModelBase)) {
409 throw new Error(`The ${this.type} related object must be a Bookshelf.Model`);
410 }
411 return models[0] || new Target();
412 }
413
414 // Allows us to just use a model, but create a temporary collection for a "*-many" relation.
415 if (Target.prototype instanceof ModelBase) {
416 return Target.collection(models, {
417 parse: true,
418 merge: options.merge,
419 remove: options.remove
420 });
421 }
422 return new Target(models, {parse: true});
423 },
424
425 /**
426 * Groups the eagerly loaded relations according to the type of relationship we're handling for
427 * easy attachment to the parent models.
428 *
429 * @param {string} relationName The relation name being paired to its parent models.
430 * @param {Model[]} related The related models obtained from the eager load fetch call.
431 * @param {Model[]} parentModels The parent models of the eager fetched relation.
432 * @param {options} options Eager fetch query options.
433 * @return {Model[]} The eager fetch models.
434 */
435 eagerPair(relationName, related, parentModels, options) {
436 // If this is a morphTo, we only want to pair on the morphValue for the current relation.
437 if (this.type === 'morphTo') {
438 parentModels = _.filter(parentModels, (m) => {
439 return m.get(this.key('morphKey')) === this.key('morphValue');
440 });
441 }
442
443 // If this is a `through` or `belongsToMany` relation, we need to cleanup and setup the
444 // `interim` model.
445 if (this.isJoined()) related = this.parsePivot(related);
446
447 // Group all of the related models for easier association with their parent models.
448 const idKey = (key) => (_.isBuffer(key) ? key.toString('hex') : key);
449 const grouped = _.groupBy(related, (m) => {
450 let key;
451 if (m.pivot) {
452 if (this.isInverse() && this.isThrough()) {
453 key = this.isThroughForeignKeyTargeted() ? m.pivot.get(this.throughForeignKeyTarget) : m.pivot.id;
454 } else {
455 key = m.pivot.get(this.key('foreignKey'));
456 }
457 } else if (this.isInverse()) {
458 key = this.isForeignKeyTargeted() ? m.get(this.foreignKeyTarget) : m.id;
459 } else {
460 key = m.get(this.key('foreignKey'));
461 }
462 return idKey(key);
463 });
464
465 // Loop over the `parentModels` and attach the grouped sub-models, keeping the `relatedData`
466 // on the new related instance.
467 _.each(parentModels, (model) => {
468 let groupedKey;
469 if (!this.isInverse()) {
470 const parsedKey = Object.keys(model.parse({[this.parentIdAttribute]: null}))[0];
471 groupedKey = idKey(model.get(parsedKey));
472 } else {
473 const keyColumn = this.key(this.isThrough() ? 'throughForeignKey' : 'foreignKey');
474 const formatted = model.format(_.clone(model.attributes));
475 groupedKey = idKey(formatted[keyColumn]);
476 }
477 if (groupedKey != null) {
478 const relation = (model.relations[relationName] = this.relatedInstance(grouped[groupedKey], options));
479 if (this.type === 'belongsToMany') {
480 // If type is `belongsToMany` then the relatedData needs to be recreated through the
481 // parent model
482 relation.relatedData = model[relationName]().relatedData;
483 } else {
484 relation.relatedData = this;
485 }
486 if (this.isJoined()) _.extend(relation, PivotHelpers);
487 }
488 });
489
490 // Now that related models have been successfully paired, update each with its parsed
491 // attributes
492 related.map((model) => {
493 model.attributes = model.parse(model.attributes);
494 model.formatTimestamps()._previousAttributes = _.cloneDeep(model.attributes);
495 model._reset();
496 });
497
498 return related;
499 },
500
501 /**
502 * Creates new pivot models in case any of the models being processed have pivot attributes.
503 * This is only true for models belonging to {@link Model#belongsToMany belongsToMany} and
504 * {@link Model#through through} relations. All other models will discard any existing pivot
505 * attributes if present.
506 *
507 * @param {Model[]} models List of models being processed.
508 * @return {Model[]} Parsed model list possibly containing additional pivot models.
509 */
510 parsePivot(models) {
511 return _.map(models, (model) => {
512 // Separate pivot attributes.
513 const grouped = _.reduce(
514 model.attributes,
515 (acc, value, key) => {
516 if (hasPivotPrefix(key)) {
517 acc.pivot[removePivotPrefix(key)] = value;
518 } else {
519 acc.model[key] = value;
520 }
521 return acc;
522 },
523 {model: {}, pivot: {}}
524 );
525
526 // Assign non-pivot attributes to model.
527 model.attributes = grouped.model;
528
529 // If there are any pivot attributes create a new pivot model with these attributes.
530 if (!_.isEmpty(grouped.pivot)) {
531 const Through = this.throughTarget;
532 const tableName = this.joinTable();
533 model.pivot = Through != null ? new Through(grouped.pivot) : new this.Model(grouped.pivot, {tableName});
534 }
535
536 return model;
537 });
538 },
539
540 /**
541 * Sets the pivot column names to be retrieved along with the current model. This allows for
542 * additional fields to be pulled from the joining table.
543 *
544 * @param {string|string[]} columns Extra column names to fetch.
545 * @return {undefined}
546 */
547 withPivot(columns) {
548 if (!Array.isArray(columns)) columns = [columns];
549 this.pivotColumns = this.pivotColumns || [];
550 push.apply(this.pivotColumns, columns);
551 },
552
553 /**
554 * Checks whether or not a relation is of the {@link Relation#through through} type.
555 *
556 * @return {boolean}
557 */
558 isThrough() {
559 return this.throughTarget != null;
560 },
561
562 /**
563 * Checks whether or not a relation has joins. Only {@link Model#belongsToMany belongsToMany}
564 * and {@link Model#through through} relations make use of joins currently.
565 *
566 * @return {boolean}
567 */
568 isJoined() {
569 return this.type === 'belongsToMany' || this.isThrough();
570 },
571
572 /**
573 * Checks whether or not a relation is of the {@link Model#morphOne morphOne} or
574 * {@link Model#morphMany morphMany} type.
575 *
576 * @return {boolean}
577 */
578 isMorph() {
579 return this.type === 'morphOne' || this.type === 'morphMany';
580 },
581
582 /**
583 * Checks whether or not a relation is of the single type (one to one).
584 *
585 * @return {boolean}
586 */
587 isSingle() {
588 const type = this.type;
589 return type === 'hasOne' || type === 'belongsTo' || type === 'morphOne' || type === 'morphTo';
590 },
591
592 /**
593 * Checks whether or not the relation is the inverse of a {@link Model#morphOne morphOne},
594 * {@link Model#morphMany morphMany}, {@link Model#hasOne hasOne} or
595 * {@link Model#hasMany hasMany} relation.
596 *
597 * @return {boolean}
598 */
599 isInverse() {
600 return this.type === 'belongsTo' || this.type === 'morphTo';
601 },
602
603 /**
604 * Checks whether or not the relation has a foreign key target set.
605 *
606 * @return {boolean}
607 */
608 isForeignKeyTargeted() {
609 return this.foreignKeyTarget != null;
610 },
611
612 /**
613 * Checks whether or not the {@link Model#through through} relation has a foreign key target
614 * set.
615 *
616 * @return {boolean}
617 */
618 isThroughForeignKeyTargeted() {
619 return this.throughForeignKeyTarget != null;
620 },
621
622 /**
623 * Checks whether or not the relation has a the `other` foreign key target set.
624 *
625 * @return {boolean}
626 */
627 isOtherKeyTargeted() {
628 return this.otherKeyTarget != null;
629 },
630
631 /**
632 * Checks whether or not the relation has the parent attributes set.
633 *
634 * @return {boolean}
635 */
636 hasParentAttributes() {
637 return this.parentAttributes != null;
638 }
639 }
640);
641
642// Simple memoization of the singularize call.
643const singularMemo = (function() {
644 const cache = Object.create(null);
645 return function(arg) {
646 if (!(arg in cache)) {
647 cache[arg] = inflection.singularize(arg);
648 }
649 return cache[arg];
650 };
651})();
652
653/**
654 * Specific to many-to-many relationships, these methods are mixed into the
655 * {@link Model#belongsToMany belongsToMany} relationships when they are created, providing helpers
656 * for attaching and detaching related models.
657 *
658 * @mixin
659 */
660const PivotHelpers = {
661 /**
662 * Attaches one or more `ids` or models from a foreign table to the current
663 * table, on a {@linkplain many-to-many} relation. Creates and saves a new
664 * model and attaches the model with the related model.
665 *
666 * var admin1 = new Admin({username: 'user1', password: 'test'});
667 * var admin2 = new Admin({username: 'user2', password: 'test'});
668 *
669 * Promise.all([admin1.save(), admin2.save()])
670 * .then(function() {
671 * return Promise.all([
672 * new Site({id: 1}).admins().attach([admin1, admin2]),
673 * new Site({id: 2}).admins().attach(admin2)
674 * ]);
675 * })
676 *
677 * This method (along with {@link Collection#detach} and {@link
678 * Collection#updatePivot}) are mixed in to a {@link Collection} when
679 * returned by a {@link Model#belongsToMany belongsToMany} relation.
680 *
681 * @method Collection#attach
682 * @param {mixed|mixed[]} ids
683 * One or more ID values or models to be attached to the relation.
684 * @param {Object} options
685 * A hash of options.
686 * @param {Transaction} options.transacting
687 * Optionally run the query in a transaction.
688 * @returns {Promise<Collection>}
689 * A promise resolving to the updated Collection where this method was called.
690 */
691 attach(ids, options) {
692 return Promise.try(() => this.triggerThen('attaching', this, ids, options))
693 .then(() => this._handler('insert', ids, options))
694 .then((response) => this.triggerThen('attached', this, response, options))
695 .return(this);
696 },
697
698 /**
699 * Detach one or more related objects from their pivot tables. If a model or
700 * id is passed, it attempts to remove from the pivot table based on that
701 * foreign key. If no parameters are specified, we assume we will detach all
702 * related associations.
703 *
704 * This method (along with {@link Collection#attach} and {@link
705 * Collection#updatePivot}) are mixed in to a {@link Collection} when returned
706 * by a {@link Model#belongsToMany belongsToMany} relation.
707 *
708 * @method Collection#detach
709 * @param {mixed|mixed[]} [ids]
710 * One or more ID values or models to be detached from the relation.
711 * @param {Object} options
712 * A hash of options.
713 * @param {Transaction} options.transacting
714 * Optionally run the query in a transaction.
715 * @returns {Promise<undefined>}
716 * A promise resolving to the updated Collection where this method was called.
717 */
718 detach(ids, options) {
719 return Promise.try(() => this.triggerThen('detaching', this, ids, options))
720 .then(() => this._handler('delete', ids, options))
721 .then((response) => this.triggerThen('detached', this, response, options))
722 .return(this);
723 },
724
725 /**
726 * The `updatePivot` method is used exclusively on {@link Model#belongsToMany
727 * belongsToMany} relations, and allows for updating pivot rows on the joining
728 * table.
729 *
730 * This method (along with {@link Collection#attach} and {@link
731 * Collection#detach}) are mixed in to a {@link Collection} when returned
732 * by a {@link Model#belongsToMany belongsToMany} relation.
733 *
734 * @method Collection#updatePivot
735 * @param {Object} attributes
736 * Values to be set in the `update` query.
737 * @param {Object} [options]
738 * A hash of options.
739 * @param {function|Object} [options.query]
740 * Constrain the update query. Similar to the `method` argument to {@link
741 * Model#query}.
742 * @param {Boolean} [options.require=false]
743 * Causes promise to be rejected with an Error if no rows were updated.
744 * @param {Transaction} [options.transacting]
745 * Optionally run the query in a transaction.
746 * @returns {Promise<Number>}
747 * A promise resolving to number of rows updated.
748 */
749 updatePivot: function(attributes, options) {
750 return this._handler('update', attributes, options);
751 },
752
753 /**
754 * The `withPivot` method is used exclusively on {@link Model#belongsToMany
755 * belongsToMany} relations, and allows for additional fields to be pulled
756 * from the joining table.
757 *
758 * var Tag = bookshelf.model('Tag', {
759 * comments: function() {
760 * return this.belongsToMany(Comment).withPivot(['created_at', 'order']);
761 * }
762 * });
763 *
764 * @method Collection#withPivot
765 * @param {string[]} columns
766 * Names of columns to be included when retrieving pivot table rows.
767 * @returns {Collection}
768 * Self, this method is chainable.
769 */
770 withPivot: function(columns) {
771 this.relatedData.withPivot(columns);
772 return this;
773 },
774
775 /**
776 * Helper for handling either the {@link Collection#attach attach} or
777 * {@link Collection#detach detach} call on the {@link Model#belongsToMany belongsToMany} or
778 * ({@link Model#hasOne hasOne}/{@link Model#hasMany hasMany}).{@link Model#through through}
779 * relationship.
780 *
781 * @private
782 * @param {string} method
783 * Type of query being handled. This will be `insert` for {@link Collection#attach attach}
784 * calls, `delete` for {@link Collection#detach detach} calls and `update` for
785 * {@link Collection#updatePivot updatePivot} calls.
786 * @param {mixed|null} The ids of the models to attach, detach or update.
787 * @param {object} [options] Query options.
788 * @return {Promise}
789 */
790 _handler: Promise.method(function(method, ids, options) {
791 const pending = [];
792 if (ids == null) {
793 if (method === 'insert') return Promise.resolve(this);
794 if (method === 'delete') pending.push(this._processPivot(method, null, options));
795 }
796 if (!Array.isArray(ids)) ids = ids ? [ids] : [];
797 _.each(ids, (id) => pending.push(this._processPivot(method, id, options)));
798 return Promise.all(pending).return(this);
799 }),
800
801 /**
802 * Handles preparing the appropriate constraints and then delegates the database interaction to
803 * `_processPlainPivot` for non-{@link Model#through through} pivot definitions, or
804 * `_processModelPivot` for {@link Model#through through} models.
805 *
806 * @private
807 * @param {string} method
808 * Type of query being handled. This will be `insert` for {@link Collection#attach attach}
809 * calls, `delete` for {@link Collection#detach detach} calls and `update` for
810 * {@link Collection#updatePivot updatePivot} calls.
811 * @param {Model|object|mixed} item
812 * The item can be an object, in which case it's either a model that we're looking to attach to
813 * this model, or a hash of attributes to set in the relation. Otherwise it's a foreign key.
814 * @return {Promise}
815 */
816 _processPivot: Promise.method(function(method, item) {
817 const relatedData = this.relatedData,
818 args = Array.prototype.slice.call(arguments),
819 fks = {},
820 data = {};
821
822 fks[relatedData.key('foreignKey')] = relatedData.parentFk;
823
824 if (_.isObject(item)) {
825 if (item instanceof ModelBase) {
826 fks[relatedData.key('otherKey')] = item.id;
827 } else if (method !== 'update') {
828 _.extend(data, item);
829 }
830 } else if (item) {
831 fks[relatedData.key('otherKey')] = item;
832 }
833
834 args.push(_.extend(data, fks), fks);
835
836 if (this.relatedData.throughTarget) {
837 return this._processModelPivot.apply(this, args);
838 }
839
840 return this._processPlainPivot.apply(this, args);
841 }),
842
843 /**
844 * Applies constraints to the knex builder and handles shelling out to either the `insert` or
845 * `delete` call for the current model.
846 *
847 * @private
848 * @param {string} method
849 * Type of query being handled. This will be `insert` for {@link Collection#attach attach}
850 * calls, `delete` for {@link Collection#detach detach} calls and `update` for
851 * {@link Collection#updatePivot updatePivot} calls.
852 * @param {Model|object|mixed} item
853 * The item can be an object, in which case it's either a model that we're looking to attach to
854 * this model, or a hash of attributes to set in the relation. Otherwise it's a foreign key.
855 * @param {object} [options] Query options.
856 * @param {object} [data] The model data to constrain the query or attach to the relation.
857 * @return {Promise}
858 */
859 _processPlainPivot: Promise.method(function(method, item, options, data) {
860 const relatedData = this.relatedData;
861
862 // Grab the `knex` query builder for the current model, and
863 // check if we have any additional constraints for the query.
864 const builder = this._builder(relatedData.joinTable());
865 if (options && options.query) {
866 Helpers.query.call(null, {_knex: builder}, [options.query]);
867 }
868
869 if (options) {
870 if (options.transacting) builder.transacting(options.transacting);
871 if (options.debug) builder.debug();
872 }
873
874 const collection = this;
875 if (method === 'delete') {
876 return builder
877 .where(data)
878 .del()
879 .then(function() {
880 if (!item) return collection.reset();
881 const model = collection.get(data[relatedData.key('otherKey')]);
882 if (model) {
883 collection.remove(model);
884 }
885 });
886 }
887 if (method === 'update') {
888 return builder
889 .where(data)
890 .update(item)
891 .then(function(numUpdated) {
892 if (options && options.require === true && numUpdated === 0) {
893 throw new Error('No rows were updated');
894 }
895 return numUpdated;
896 });
897 }
898
899 return this.triggerThen('creating', this, data, options).then(function() {
900 return builder.insert(data).then(function() {
901 collection.add(item);
902 });
903 });
904 }),
905
906 /**
907 * Loads or prepares a pivot model based on the constraints and deals with pivot model changes by
908 * calling the appropriate Bookshelf Model API methods.
909 *
910 * @private
911 * @param {string} method
912 * Type of query being handled. This will be `insert` for {@link Collection#attach attach}
913 * calls, `delete` for {@link Collection#detach detach} calls and `update` for
914 * {@link Collection#updatePivot updatePivot} calls.
915 * @param {Model|object|mixed} item
916 * The item can be an object, in which case it's either a model that we're looking to attach to
917 * this model, or a hash of attributes to set in the relation. Otherwise it's a foreign key.
918 * @param {object} options Query options.
919 * @param {object} data The model data to constrain the query or attach to the relation.
920 * @param {object} fks
921 * @return {Promise}
922 */
923 _processModelPivot: Promise.method(function(method, item, options, data, fks) {
924 const relatedData = this.relatedData,
925 JoinModel = relatedData.throughTarget,
926 joinModel = new JoinModel();
927
928 fks = joinModel.parse(fks);
929 data = joinModel.parse(data);
930
931 if (method === 'insert') {
932 return joinModel.set(data).save(null, options);
933 }
934
935 return joinModel
936 .set(fks)
937 .fetch()
938 .then(function(instance) {
939 if (method === 'delete') {
940 return instance.destroy(options);
941 }
942 return instance.save(item, options);
943 });
944 })
945};
946
947module.exports = Relation;