@depup/bookshelf 1.2.0-depup.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,947 @@
1
+ const _ = require('lodash');
2
+ const inflection = require('inflection');
3
+
4
+ const Helpers = require('./helpers');
5
+ const ModelBase = require('./base/model');
6
+ const RelationBase = require('./base/relation');
7
+ const Promise = require('bluebird');
8
+ const constants = require('./constants');
9
+ const push = Array.prototype.push;
10
+ const removePivotPrefix = (key) => key.slice(constants.PIVOT_PREFIX.length);
11
+ const 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
+ */
21
+ const 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.
643
+ const 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
+ */
660
+ const 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
+
947
+ module.exports = Relation;