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;
|