948 lines
33 KiB
JavaScript
948 lines
33 KiB
JavaScript
const _ = require('lodash');
|
|
const inflection = require('inflection');
|
|
|
|
const Helpers = require('./helpers');
|
|
const ModelBase = require('./base/model');
|
|
const RelationBase = require('./base/relation');
|
|
const Promise = require('bluebird');
|
|
const constants = require('./constants');
|
|
const push = Array.prototype.push;
|
|
const removePivotPrefix = (key) => key.slice(constants.PIVOT_PREFIX.length);
|
|
const hasPivotPrefix = (key) => _.startsWith(key, constants.PIVOT_PREFIX);
|
|
|
|
/**
|
|
* @classdesc
|
|
* Used internally, the `Relation` class helps in simplifying the relationship building,
|
|
* centralizing all logic dealing with type and option handling.
|
|
*
|
|
* @extends RelationBase
|
|
* @class
|
|
*/
|
|
const Relation = RelationBase.extend(
|
|
/** @lends Relation.prototype */ {
|
|
/**
|
|
* Assembles the new model or collection we're creating an instance of, gathering any relevant
|
|
* primitives from the parent object without keeping any hard references.
|
|
*
|
|
* @param {Model} parent The parent to which this relation belongs to.
|
|
* @return {Model|Collection|Object} The new model or collection instance.
|
|
*/
|
|
init(parent) {
|
|
this.parentId = parent.id;
|
|
this.parentTableName = _.result(parent, 'tableName');
|
|
this.parentIdAttribute = this.attribute('parentIdAttribute', parent);
|
|
|
|
// Use formatted attributes so that morphKey and foreignKey will match attribute keys.
|
|
this.parentAttributes = parent.format(_.clone(parent.attributes));
|
|
|
|
if (this.type === 'morphTo' && !parent._isEager) {
|
|
// If the parent object is eager loading, and it's a polymorphic `morphTo` relation, we
|
|
// can't know what the target will be until the models are sorted and matched.
|
|
this.target = Helpers.morphCandidate(this.candidates, this.parentAttributes[this.key('morphKey')]);
|
|
this.targetTableName = _.result(this.target.prototype, 'tableName');
|
|
}
|
|
|
|
this.targetIdAttribute = this.attribute('targetIdAttribute', parent);
|
|
this.parentFk = this.attribute('parentFk');
|
|
|
|
const target = this.target ? this.relatedInstance() : {};
|
|
target.relatedData = this;
|
|
|
|
if (this.type === 'belongsToMany') {
|
|
_.extend(target, PivotHelpers);
|
|
}
|
|
|
|
return target;
|
|
},
|
|
|
|
/**
|
|
* Initializes a `through` relation, setting the `Target` model and `options`, which includes
|
|
* any additional keys for the relation.
|
|
*
|
|
* @param {Model|Collection} source
|
|
* @param {Model} Target The pivot model the related models or collections run through.
|
|
* @param {object} options Additional properties to set on the relation object.
|
|
*/
|
|
through(source, Target, options) {
|
|
const type = this.type;
|
|
if (type !== 'hasOne' && type !== 'hasMany' && type !== 'belongsToMany' && type !== 'belongsTo') {
|
|
throw new Error('`through` is only chainable from `hasOne`, `belongsTo`, `hasMany`, or `belongsToMany`');
|
|
}
|
|
|
|
this.throughTarget = Target;
|
|
this.throughTableName = _.result(Target.prototype, 'tableName');
|
|
|
|
_.extend(this, options);
|
|
_.extend(source, PivotHelpers);
|
|
|
|
this.parentIdAttribute = this.attribute('parentIdAttribute');
|
|
this.targetIdAttribute = this.attribute('targetIdAttribute');
|
|
this.throughIdAttribute = this.attribute('throughIdAttribute', Target);
|
|
this.parentFk = this.attribute('parentFk');
|
|
|
|
// Set the appropriate foreign key if we're doing a belongsToMany, for convenience.
|
|
if (this.type === 'belongsToMany') {
|
|
this.foreignKey = this.throughForeignKey;
|
|
} else if (this.otherKey) {
|
|
this.foreignKey = this.otherKey;
|
|
}
|
|
|
|
return source;
|
|
},
|
|
|
|
/**
|
|
* Generates and returns a specified key.
|
|
*
|
|
* @param {string} keyName
|
|
* Can be one of `foreignKey`, `morphKey`, `morphValue`, `otherKey` or `throughForeignKey`.
|
|
* @return {string|undefined}
|
|
*/
|
|
key(keyName) {
|
|
if (this[keyName]) return this[keyName];
|
|
switch (keyName) {
|
|
case 'otherKey':
|
|
this[keyName] = singularMemo(this.targetTableName) + '_' + this.targetIdAttribute;
|
|
break;
|
|
case 'throughForeignKey':
|
|
this[keyName] = singularMemo(this.joinTable()) + '_' + this.throughIdAttribute;
|
|
break;
|
|
case 'foreignKey':
|
|
switch (this.type) {
|
|
case 'morphTo': {
|
|
const idKeyName = this.columnNames && this.columnNames[1] ? this.columnNames[1] : this.morphName + '_id';
|
|
this[keyName] = idKeyName;
|
|
break;
|
|
}
|
|
case 'belongsTo':
|
|
this[keyName] = singularMemo(this.targetTableName) + '_' + this.targetIdAttribute;
|
|
break;
|
|
default:
|
|
if (this.isMorph()) {
|
|
this[keyName] = this.columnNames && this.columnNames[1] ? this.columnNames[1] : this.morphName + '_id';
|
|
break;
|
|
}
|
|
this[keyName] = singularMemo(this.parentTableName) + '_' + this.parentIdAttribute;
|
|
break;
|
|
}
|
|
break;
|
|
case 'morphKey':
|
|
this[keyName] = this.columnNames && this.columnNames[0] ? this.columnNames[0] : this.morphName + '_type';
|
|
break;
|
|
case 'morphValue':
|
|
this[keyName] = this.morphValue || this.parentTableName || this.targetTableName;
|
|
break;
|
|
}
|
|
return this[keyName];
|
|
},
|
|
|
|
/**
|
|
* Get the correct name for the following attributes:
|
|
* - parentIdAttribute
|
|
* - targetIdAttribute
|
|
* - throughIdAttribute
|
|
* - parentFk
|
|
*
|
|
* @param {string} attribute The attribute name being requested.
|
|
* @param {Model} [parent] The parent model.
|
|
* @return {string}
|
|
*/
|
|
attribute(attribute, parent) {
|
|
switch (attribute) {
|
|
case 'parentIdAttribute':
|
|
if (this.isThrough()) {
|
|
if (this.type === 'belongsTo' && this.throughForeignKey) {
|
|
return this.throughForeignKey;
|
|
}
|
|
|
|
if (this.type === 'belongsToMany' && this.isThroughForeignKeyTargeted()) {
|
|
return this.throughForeignKeyTarget;
|
|
}
|
|
|
|
if (this.isOtherKeyTargeted()) {
|
|
return this.otherKeyTarget;
|
|
}
|
|
|
|
return this.parentIdAttribute; // Return attribute calculated on `init` by default.
|
|
}
|
|
|
|
if (this.type === 'belongsTo' && this.foreignKey) {
|
|
return this.foreignKey;
|
|
}
|
|
|
|
if (this.type !== 'belongsTo' && this.isForeignKeyTargeted()) {
|
|
return this.foreignKeyTarget;
|
|
}
|
|
|
|
return _.result(parent, 'idAttribute');
|
|
|
|
case 'targetIdAttribute':
|
|
if (this.isThrough()) {
|
|
if ((this.type === 'belongsToMany' || this.type === 'belongsTo') && this.isOtherKeyTargeted()) {
|
|
return this.otherKeyTarget;
|
|
}
|
|
|
|
return this.targetIdAttribute; // Return attribute calculated on `init` by default.
|
|
}
|
|
|
|
if (this.type === 'morphTo' && !parent._isEager) {
|
|
return _.result(this.target.prototype, 'idAttribute');
|
|
}
|
|
|
|
if (this.type === 'belongsTo' && this.isForeignKeyTargeted()) {
|
|
return this.foreignKeyTarget;
|
|
}
|
|
|
|
if (this.type === 'belongsToMany' && this.isOtherKeyTargeted()) {
|
|
return this.otherKeyTarget;
|
|
}
|
|
|
|
return this.targetIdAttribute;
|
|
|
|
case 'throughIdAttribute':
|
|
if (this.type !== 'belongsToMany' && this.isThroughForeignKeyTargeted()) {
|
|
return this.throughForeignKeyTarget;
|
|
}
|
|
|
|
if (this.type === 'belongsToMany' && this.throughForeignKey) {
|
|
return this.throughForeignKey;
|
|
}
|
|
|
|
return _.result(parent.prototype, 'idAttribute');
|
|
|
|
case 'parentFk':
|
|
if (!this.hasParentAttributes()) {
|
|
return;
|
|
}
|
|
|
|
if (this.isThrough()) {
|
|
if (this.type === 'belongsToMany' && this.isThroughForeignKeyTargeted()) {
|
|
return this.parentAttributes[this.throughForeignKeyTarget];
|
|
}
|
|
|
|
if (this.type === 'belongsTo') {
|
|
return this.throughForeignKey ? this.parentAttributes[this.parentIdAttribute] : this.parentId;
|
|
}
|
|
|
|
if (this.isOtherKeyTargeted()) {
|
|
return this.parentAttributes[this.otherKeyTarget];
|
|
}
|
|
|
|
return this.parentFk; // Return attribute calculated on `init` by default.
|
|
}
|
|
|
|
return this.parentAttributes[this.isInverse() ? this.key('foreignKey') : this.parentIdAttribute];
|
|
}
|
|
},
|
|
|
|
/**
|
|
* Injects the necessary `select` constraints into a `knex` query builder.
|
|
*
|
|
* @param {Knex} knex Knex instance.
|
|
* @param {object} options
|
|
* @return {undefined}
|
|
*/
|
|
selectConstraints(knex, options) {
|
|
const resp = options.parentResponse;
|
|
|
|
// The `belongsToMany` and `through` relations have joins & pivot columns.
|
|
if (this.isJoined()) this.joinClauses(knex);
|
|
|
|
// Call the function, if one exists, to constrain the eager loaded query.
|
|
if (options._beforeFn) options._beforeFn.call(knex, knex);
|
|
|
|
// The base select column
|
|
if (Array.isArray(options.columns)) {
|
|
knex.columns(options.columns);
|
|
}
|
|
|
|
const currentColumns = _.find(knex._statements, {grouping: 'columns'});
|
|
|
|
if (!currentColumns || currentColumns.length === 0) {
|
|
knex.distinct(this.targetTableName + '.*');
|
|
}
|
|
|
|
if (this.isJoined()) this.joinColumns(knex);
|
|
|
|
// If this is a single relation and we're not eager loading limit the query to a single item.
|
|
if (this.isSingle() && !resp) knex.limit(1);
|
|
|
|
// Finally, add (and validate) the WHERE conditions necessary for constraining the relation.
|
|
this.whereClauses(knex, resp);
|
|
},
|
|
|
|
/**
|
|
* Injects and validates necessary `through` constraints for the current model.
|
|
*
|
|
* @param {Knex} knex Knex instance.
|
|
* @return {undefined}
|
|
*/
|
|
joinColumns(knex) {
|
|
const columns = [];
|
|
const joinTable = this.joinTable();
|
|
if (this.isThrough()) columns.push(this.throughIdAttribute);
|
|
columns.push(this.key('foreignKey'));
|
|
if (this.type === 'belongsToMany') columns.push(this.key('otherKey'));
|
|
push.apply(columns, this.pivotColumns);
|
|
knex.columns(
|
|
_.map(columns, function(col) {
|
|
return joinTable + '.' + col + ' as _pivot_' + col;
|
|
})
|
|
);
|
|
},
|
|
|
|
/**
|
|
* Generates the join clauses necessary for the current relation.
|
|
*
|
|
* @param {Knex} knex Knex instance.
|
|
* @return {undefined}
|
|
*/
|
|
joinClauses(knex) {
|
|
const joinTable = this.joinTable();
|
|
|
|
if (this.type === 'belongsTo' || this.type === 'belongsToMany') {
|
|
const targetKey = this.type === 'belongsTo' ? this.key('foreignKey') : this.key('otherKey');
|
|
|
|
knex.join(joinTable, joinTable + '.' + targetKey, '=', this.targetTableName + '.' + this.targetIdAttribute);
|
|
|
|
// A `belongsTo` -> `through` is currently the only relation with two joins.
|
|
if (this.type === 'belongsTo') {
|
|
knex.join(
|
|
this.parentTableName,
|
|
joinTable + '.' + this.throughIdAttribute,
|
|
'=',
|
|
this.parentTableName + '.' + this.key('throughForeignKey')
|
|
);
|
|
}
|
|
} else {
|
|
knex.join(
|
|
joinTable,
|
|
joinTable + '.' + this.throughIdAttribute,
|
|
'=',
|
|
this.targetTableName + '.' + this.key('throughForeignKey')
|
|
);
|
|
}
|
|
},
|
|
|
|
/**
|
|
* Check that there isn't an incorrect foreign key set, versus the one passed in when the
|
|
* relation was formed.
|
|
*
|
|
* @param {Knex} knex Knex instance.
|
|
* @param {object} response
|
|
* @return {undefined}
|
|
*/
|
|
whereClauses(knex, response) {
|
|
let key;
|
|
|
|
if (this.isJoined()) {
|
|
const isBelongsTo = this.type === 'belongsTo';
|
|
const targetTable = isBelongsTo ? this.parentTableName : this.joinTable();
|
|
|
|
const column = isBelongsTo ? this.parentIdAttribute : this.key('foreignKey');
|
|
|
|
key = `${targetTable}.${column}`;
|
|
} else {
|
|
const column = this.isInverse() ? this.targetIdAttribute : this.key('foreignKey');
|
|
|
|
key = `${this.targetTableName}.${column}`;
|
|
}
|
|
|
|
const method = response ? 'whereIn' : 'where';
|
|
const ids = response ? this.eagerKeys(response) : this.parentFk;
|
|
knex[method](key, ids);
|
|
|
|
if (this.isMorph()) {
|
|
const table = this.targetTableName;
|
|
const key = this.key('morphKey');
|
|
const value = this.key('morphValue');
|
|
knex.where(`${table}.${key}`, value);
|
|
}
|
|
},
|
|
|
|
/**
|
|
* Fetches all eagerly loaded foreign keys from the current relation.
|
|
*
|
|
* @param {object} response
|
|
* @return {array} The requested eager keys.
|
|
*/
|
|
eagerKeys(response) {
|
|
const key = this.isInverse() && !this.isThrough() ? this.key('foreignKey') : this.parentIdAttribute;
|
|
return _.reject(
|
|
_(response)
|
|
.map(key)
|
|
.uniq()
|
|
.value(),
|
|
_.isNil
|
|
);
|
|
},
|
|
|
|
/**
|
|
* Generates the appropriate default join table name for a
|
|
* {@link Model#belongsToMany belongsToMany} or {@link Model#through through} relation.
|
|
* The default name is composed of the two table names ordered alphabetically and joined by an
|
|
* underscore.
|
|
*
|
|
* @return {string} The table name.
|
|
*/
|
|
joinTable() {
|
|
if (this.isThrough()) return this.throughTableName;
|
|
return this.joinTableName || [this.parentTableName, this.targetTableName].sort().join('_');
|
|
},
|
|
|
|
/**
|
|
* Creates a new model or collection instance, depending on the `relatedData` settings and the
|
|
* models passed in.
|
|
*
|
|
* @param {Model[]} [models]
|
|
* @param {object} [options]
|
|
* @return {Model|Collection} The new instance.
|
|
*/
|
|
relatedInstance(models, options) {
|
|
models = models || [];
|
|
options = options || {};
|
|
const Target = this.target;
|
|
|
|
// If it's a single model, check whether there's already a model we can pick from, otherwise
|
|
// create a new instance.
|
|
if (this.isSingle()) {
|
|
if (!(Target.prototype instanceof ModelBase)) {
|
|
throw new Error(`The ${this.type} related object must be a Bookshelf.Model`);
|
|
}
|
|
return models[0] || new Target();
|
|
}
|
|
|
|
// Allows us to just use a model, but create a temporary collection for a "*-many" relation.
|
|
if (Target.prototype instanceof ModelBase) {
|
|
return Target.collection(models, {
|
|
parse: true,
|
|
merge: options.merge,
|
|
remove: options.remove
|
|
});
|
|
}
|
|
return new Target(models, {parse: true});
|
|
},
|
|
|
|
/**
|
|
* Groups the eagerly loaded relations according to the type of relationship we're handling for
|
|
* easy attachment to the parent models.
|
|
*
|
|
* @param {string} relationName The relation name being paired to its parent models.
|
|
* @param {Model[]} related The related models obtained from the eager load fetch call.
|
|
* @param {Model[]} parentModels The parent models of the eager fetched relation.
|
|
* @param {options} options Eager fetch query options.
|
|
* @return {Model[]} The eager fetch models.
|
|
*/
|
|
eagerPair(relationName, related, parentModels, options) {
|
|
// If this is a morphTo, we only want to pair on the morphValue for the current relation.
|
|
if (this.type === 'morphTo') {
|
|
parentModels = _.filter(parentModels, (m) => {
|
|
return m.get(this.key('morphKey')) === this.key('morphValue');
|
|
});
|
|
}
|
|
|
|
// If this is a `through` or `belongsToMany` relation, we need to cleanup and setup the
|
|
// `interim` model.
|
|
if (this.isJoined()) related = this.parsePivot(related);
|
|
|
|
// Group all of the related models for easier association with their parent models.
|
|
const idKey = (key) => (_.isBuffer(key) ? key.toString('hex') : key);
|
|
const grouped = _.groupBy(related, (m) => {
|
|
let key;
|
|
if (m.pivot) {
|
|
if (this.isInverse() && this.isThrough()) {
|
|
key = this.isThroughForeignKeyTargeted() ? m.pivot.get(this.throughForeignKeyTarget) : m.pivot.id;
|
|
} else {
|
|
key = m.pivot.get(this.key('foreignKey'));
|
|
}
|
|
} else if (this.isInverse()) {
|
|
key = this.isForeignKeyTargeted() ? m.get(this.foreignKeyTarget) : m.id;
|
|
} else {
|
|
key = m.get(this.key('foreignKey'));
|
|
}
|
|
return idKey(key);
|
|
});
|
|
|
|
// Loop over the `parentModels` and attach the grouped sub-models, keeping the `relatedData`
|
|
// on the new related instance.
|
|
_.each(parentModels, (model) => {
|
|
let groupedKey;
|
|
if (!this.isInverse()) {
|
|
const parsedKey = Object.keys(model.parse({[this.parentIdAttribute]: null}))[0];
|
|
groupedKey = idKey(model.get(parsedKey));
|
|
} else {
|
|
const keyColumn = this.key(this.isThrough() ? 'throughForeignKey' : 'foreignKey');
|
|
const formatted = model.format(_.clone(model.attributes));
|
|
groupedKey = idKey(formatted[keyColumn]);
|
|
}
|
|
if (groupedKey != null) {
|
|
const relation = (model.relations[relationName] = this.relatedInstance(grouped[groupedKey], options));
|
|
if (this.type === 'belongsToMany') {
|
|
// If type is `belongsToMany` then the relatedData needs to be recreated through the
|
|
// parent model
|
|
relation.relatedData = model[relationName]().relatedData;
|
|
} else {
|
|
relation.relatedData = this;
|
|
}
|
|
if (this.isJoined()) _.extend(relation, PivotHelpers);
|
|
}
|
|
});
|
|
|
|
// Now that related models have been successfully paired, update each with its parsed
|
|
// attributes
|
|
related.map((model) => {
|
|
model.attributes = model.parse(model.attributes);
|
|
model.formatTimestamps()._previousAttributes = _.cloneDeep(model.attributes);
|
|
model._reset();
|
|
});
|
|
|
|
return related;
|
|
},
|
|
|
|
/**
|
|
* Creates new pivot models in case any of the models being processed have pivot attributes.
|
|
* This is only true for models belonging to {@link Model#belongsToMany belongsToMany} and
|
|
* {@link Model#through through} relations. All other models will discard any existing pivot
|
|
* attributes if present.
|
|
*
|
|
* @param {Model[]} models List of models being processed.
|
|
* @return {Model[]} Parsed model list possibly containing additional pivot models.
|
|
*/
|
|
parsePivot(models) {
|
|
return _.map(models, (model) => {
|
|
// Separate pivot attributes.
|
|
const grouped = _.reduce(
|
|
model.attributes,
|
|
(acc, value, key) => {
|
|
if (hasPivotPrefix(key)) {
|
|
acc.pivot[removePivotPrefix(key)] = value;
|
|
} else {
|
|
acc.model[key] = value;
|
|
}
|
|
return acc;
|
|
},
|
|
{model: {}, pivot: {}}
|
|
);
|
|
|
|
// Assign non-pivot attributes to model.
|
|
model.attributes = grouped.model;
|
|
|
|
// If there are any pivot attributes create a new pivot model with these attributes.
|
|
if (!_.isEmpty(grouped.pivot)) {
|
|
const Through = this.throughTarget;
|
|
const tableName = this.joinTable();
|
|
model.pivot = Through != null ? new Through(grouped.pivot) : new this.Model(grouped.pivot, {tableName});
|
|
}
|
|
|
|
return model;
|
|
});
|
|
},
|
|
|
|
/**
|
|
* Sets the pivot column names to be retrieved along with the current model. This allows for
|
|
* additional fields to be pulled from the joining table.
|
|
*
|
|
* @param {string|string[]} columns Extra column names to fetch.
|
|
* @return {undefined}
|
|
*/
|
|
withPivot(columns) {
|
|
if (!Array.isArray(columns)) columns = [columns];
|
|
this.pivotColumns = this.pivotColumns || [];
|
|
push.apply(this.pivotColumns, columns);
|
|
},
|
|
|
|
/**
|
|
* Checks whether or not a relation is of the {@link Relation#through through} type.
|
|
*
|
|
* @return {boolean}
|
|
*/
|
|
isThrough() {
|
|
return this.throughTarget != null;
|
|
},
|
|
|
|
/**
|
|
* Checks whether or not a relation has joins. Only {@link Model#belongsToMany belongsToMany}
|
|
* and {@link Model#through through} relations make use of joins currently.
|
|
*
|
|
* @return {boolean}
|
|
*/
|
|
isJoined() {
|
|
return this.type === 'belongsToMany' || this.isThrough();
|
|
},
|
|
|
|
/**
|
|
* Checks whether or not a relation is of the {@link Model#morphOne morphOne} or
|
|
* {@link Model#morphMany morphMany} type.
|
|
*
|
|
* @return {boolean}
|
|
*/
|
|
isMorph() {
|
|
return this.type === 'morphOne' || this.type === 'morphMany';
|
|
},
|
|
|
|
/**
|
|
* Checks whether or not a relation is of the single type (one to one).
|
|
*
|
|
* @return {boolean}
|
|
*/
|
|
isSingle() {
|
|
const type = this.type;
|
|
return type === 'hasOne' || type === 'belongsTo' || type === 'morphOne' || type === 'morphTo';
|
|
},
|
|
|
|
/**
|
|
* Checks whether or not the relation is the inverse of a {@link Model#morphOne morphOne},
|
|
* {@link Model#morphMany morphMany}, {@link Model#hasOne hasOne} or
|
|
* {@link Model#hasMany hasMany} relation.
|
|
*
|
|
* @return {boolean}
|
|
*/
|
|
isInverse() {
|
|
return this.type === 'belongsTo' || this.type === 'morphTo';
|
|
},
|
|
|
|
/**
|
|
* Checks whether or not the relation has a foreign key target set.
|
|
*
|
|
* @return {boolean}
|
|
*/
|
|
isForeignKeyTargeted() {
|
|
return this.foreignKeyTarget != null;
|
|
},
|
|
|
|
/**
|
|
* Checks whether or not the {@link Model#through through} relation has a foreign key target
|
|
* set.
|
|
*
|
|
* @return {boolean}
|
|
*/
|
|
isThroughForeignKeyTargeted() {
|
|
return this.throughForeignKeyTarget != null;
|
|
},
|
|
|
|
/**
|
|
* Checks whether or not the relation has a the `other` foreign key target set.
|
|
*
|
|
* @return {boolean}
|
|
*/
|
|
isOtherKeyTargeted() {
|
|
return this.otherKeyTarget != null;
|
|
},
|
|
|
|
/**
|
|
* Checks whether or not the relation has the parent attributes set.
|
|
*
|
|
* @return {boolean}
|
|
*/
|
|
hasParentAttributes() {
|
|
return this.parentAttributes != null;
|
|
}
|
|
}
|
|
);
|
|
|
|
// Simple memoization of the singularize call.
|
|
const singularMemo = (function() {
|
|
const cache = Object.create(null);
|
|
return function(arg) {
|
|
if (!(arg in cache)) {
|
|
cache[arg] = inflection.singularize(arg);
|
|
}
|
|
return cache[arg];
|
|
};
|
|
})();
|
|
|
|
/**
|
|
* Specific to many-to-many relationships, these methods are mixed into the
|
|
* {@link Model#belongsToMany belongsToMany} relationships when they are created, providing helpers
|
|
* for attaching and detaching related models.
|
|
*
|
|
* @mixin
|
|
*/
|
|
const PivotHelpers = {
|
|
/**
|
|
* Attaches one or more `ids` or models from a foreign table to the current
|
|
* table, on a {@linkplain many-to-many} relation. Creates and saves a new
|
|
* model and attaches the model with the related model.
|
|
*
|
|
* var admin1 = new Admin({username: 'user1', password: 'test'});
|
|
* var admin2 = new Admin({username: 'user2', password: 'test'});
|
|
*
|
|
* Promise.all([admin1.save(), admin2.save()])
|
|
* .then(function() {
|
|
* return Promise.all([
|
|
* new Site({id: 1}).admins().attach([admin1, admin2]),
|
|
* new Site({id: 2}).admins().attach(admin2)
|
|
* ]);
|
|
* })
|
|
*
|
|
* This method (along with {@link Collection#detach} and {@link
|
|
* Collection#updatePivot}) are mixed in to a {@link Collection} when
|
|
* returned by a {@link Model#belongsToMany belongsToMany} relation.
|
|
*
|
|
* @method Collection#attach
|
|
* @param {mixed|mixed[]} ids
|
|
* One or more ID values or models to be attached to the relation.
|
|
* @param {Object} options
|
|
* A hash of options.
|
|
* @param {Transaction} options.transacting
|
|
* Optionally run the query in a transaction.
|
|
* @returns {Promise<Collection>}
|
|
* A promise resolving to the updated Collection where this method was called.
|
|
*/
|
|
attach(ids, options) {
|
|
return Promise.try(() => this.triggerThen('attaching', this, ids, options))
|
|
.then(() => this._handler('insert', ids, options))
|
|
.then((response) => this.triggerThen('attached', this, response, options))
|
|
.return(this);
|
|
},
|
|
|
|
/**
|
|
* Detach one or more related objects from their pivot tables. If a model or
|
|
* id is passed, it attempts to remove from the pivot table based on that
|
|
* foreign key. If no parameters are specified, we assume we will detach all
|
|
* related associations.
|
|
*
|
|
* This method (along with {@link Collection#attach} and {@link
|
|
* Collection#updatePivot}) are mixed in to a {@link Collection} when returned
|
|
* by a {@link Model#belongsToMany belongsToMany} relation.
|
|
*
|
|
* @method Collection#detach
|
|
* @param {mixed|mixed[]} [ids]
|
|
* One or more ID values or models to be detached from the relation.
|
|
* @param {Object} options
|
|
* A hash of options.
|
|
* @param {Transaction} options.transacting
|
|
* Optionally run the query in a transaction.
|
|
* @returns {Promise<undefined>}
|
|
* A promise resolving to the updated Collection where this method was called.
|
|
*/
|
|
detach(ids, options) {
|
|
return Promise.try(() => this.triggerThen('detaching', this, ids, options))
|
|
.then(() => this._handler('delete', ids, options))
|
|
.then((response) => this.triggerThen('detached', this, response, options))
|
|
.return(this);
|
|
},
|
|
|
|
/**
|
|
* The `updatePivot` method is used exclusively on {@link Model#belongsToMany
|
|
* belongsToMany} relations, and allows for updating pivot rows on the joining
|
|
* table.
|
|
*
|
|
* This method (along with {@link Collection#attach} and {@link
|
|
* Collection#detach}) are mixed in to a {@link Collection} when returned
|
|
* by a {@link Model#belongsToMany belongsToMany} relation.
|
|
*
|
|
* @method Collection#updatePivot
|
|
* @param {Object} attributes
|
|
* Values to be set in the `update` query.
|
|
* @param {Object} [options]
|
|
* A hash of options.
|
|
* @param {function|Object} [options.query]
|
|
* Constrain the update query. Similar to the `method` argument to {@link
|
|
* Model#query}.
|
|
* @param {Boolean} [options.require=false]
|
|
* Causes promise to be rejected with an Error if no rows were updated.
|
|
* @param {Transaction} [options.transacting]
|
|
* Optionally run the query in a transaction.
|
|
* @returns {Promise<Number>}
|
|
* A promise resolving to number of rows updated.
|
|
*/
|
|
updatePivot: function(attributes, options) {
|
|
return this._handler('update', attributes, options);
|
|
},
|
|
|
|
/**
|
|
* The `withPivot` method is used exclusively on {@link Model#belongsToMany
|
|
* belongsToMany} relations, and allows for additional fields to be pulled
|
|
* from the joining table.
|
|
*
|
|
* var Tag = bookshelf.model('Tag', {
|
|
* comments: function() {
|
|
* return this.belongsToMany(Comment).withPivot(['created_at', 'order']);
|
|
* }
|
|
* });
|
|
*
|
|
* @method Collection#withPivot
|
|
* @param {string[]} columns
|
|
* Names of columns to be included when retrieving pivot table rows.
|
|
* @returns {Collection}
|
|
* Self, this method is chainable.
|
|
*/
|
|
withPivot: function(columns) {
|
|
this.relatedData.withPivot(columns);
|
|
return this;
|
|
},
|
|
|
|
/**
|
|
* Helper for handling either the {@link Collection#attach attach} or
|
|
* {@link Collection#detach detach} call on the {@link Model#belongsToMany belongsToMany} or
|
|
* ({@link Model#hasOne hasOne}/{@link Model#hasMany hasMany}).{@link Model#through through}
|
|
* relationship.
|
|
*
|
|
* @private
|
|
* @param {string} method
|
|
* Type of query being handled. This will be `insert` for {@link Collection#attach attach}
|
|
* calls, `delete` for {@link Collection#detach detach} calls and `update` for
|
|
* {@link Collection#updatePivot updatePivot} calls.
|
|
* @param {mixed|null} The ids of the models to attach, detach or update.
|
|
* @param {object} [options] Query options.
|
|
* @return {Promise}
|
|
*/
|
|
_handler: Promise.method(function(method, ids, options) {
|
|
const pending = [];
|
|
if (ids == null) {
|
|
if (method === 'insert') return Promise.resolve(this);
|
|
if (method === 'delete') pending.push(this._processPivot(method, null, options));
|
|
}
|
|
if (!Array.isArray(ids)) ids = ids ? [ids] : [];
|
|
_.each(ids, (id) => pending.push(this._processPivot(method, id, options)));
|
|
return Promise.all(pending).return(this);
|
|
}),
|
|
|
|
/**
|
|
* Handles preparing the appropriate constraints and then delegates the database interaction to
|
|
* `_processPlainPivot` for non-{@link Model#through through} pivot definitions, or
|
|
* `_processModelPivot` for {@link Model#through through} models.
|
|
*
|
|
* @private
|
|
* @param {string} method
|
|
* Type of query being handled. This will be `insert` for {@link Collection#attach attach}
|
|
* calls, `delete` for {@link Collection#detach detach} calls and `update` for
|
|
* {@link Collection#updatePivot updatePivot} calls.
|
|
* @param {Model|object|mixed} item
|
|
* The item can be an object, in which case it's either a model that we're looking to attach to
|
|
* this model, or a hash of attributes to set in the relation. Otherwise it's a foreign key.
|
|
* @return {Promise}
|
|
*/
|
|
_processPivot: Promise.method(function(method, item) {
|
|
const relatedData = this.relatedData,
|
|
args = Array.prototype.slice.call(arguments),
|
|
fks = {},
|
|
data = {};
|
|
|
|
fks[relatedData.key('foreignKey')] = relatedData.parentFk;
|
|
|
|
if (_.isObject(item)) {
|
|
if (item instanceof ModelBase) {
|
|
fks[relatedData.key('otherKey')] = item.id;
|
|
} else if (method !== 'update') {
|
|
_.extend(data, item);
|
|
}
|
|
} else if (item) {
|
|
fks[relatedData.key('otherKey')] = item;
|
|
}
|
|
|
|
args.push(_.extend(data, fks), fks);
|
|
|
|
if (this.relatedData.throughTarget) {
|
|
return this._processModelPivot.apply(this, args);
|
|
}
|
|
|
|
return this._processPlainPivot.apply(this, args);
|
|
}),
|
|
|
|
/**
|
|
* Applies constraints to the knex builder and handles shelling out to either the `insert` or
|
|
* `delete` call for the current model.
|
|
*
|
|
* @private
|
|
* @param {string} method
|
|
* Type of query being handled. This will be `insert` for {@link Collection#attach attach}
|
|
* calls, `delete` for {@link Collection#detach detach} calls and `update` for
|
|
* {@link Collection#updatePivot updatePivot} calls.
|
|
* @param {Model|object|mixed} item
|
|
* The item can be an object, in which case it's either a model that we're looking to attach to
|
|
* this model, or a hash of attributes to set in the relation. Otherwise it's a foreign key.
|
|
* @param {object} [options] Query options.
|
|
* @param {object} [data] The model data to constrain the query or attach to the relation.
|
|
* @return {Promise}
|
|
*/
|
|
_processPlainPivot: Promise.method(function(method, item, options, data) {
|
|
const relatedData = this.relatedData;
|
|
|
|
// Grab the `knex` query builder for the current model, and
|
|
// check if we have any additional constraints for the query.
|
|
const builder = this._builder(relatedData.joinTable());
|
|
if (options && options.query) {
|
|
Helpers.query.call(null, {_knex: builder}, [options.query]);
|
|
}
|
|
|
|
if (options) {
|
|
if (options.transacting) builder.transacting(options.transacting);
|
|
if (options.debug) builder.debug();
|
|
}
|
|
|
|
const collection = this;
|
|
if (method === 'delete') {
|
|
return builder
|
|
.where(data)
|
|
.del()
|
|
.then(function() {
|
|
if (!item) return collection.reset();
|
|
const model = collection.get(data[relatedData.key('otherKey')]);
|
|
if (model) {
|
|
collection.remove(model);
|
|
}
|
|
});
|
|
}
|
|
if (method === 'update') {
|
|
return builder
|
|
.where(data)
|
|
.update(item)
|
|
.then(function(numUpdated) {
|
|
if (options && options.require === true && numUpdated === 0) {
|
|
throw new Error('No rows were updated');
|
|
}
|
|
return numUpdated;
|
|
});
|
|
}
|
|
|
|
return this.triggerThen('creating', this, data, options).then(function() {
|
|
return builder.insert(data).then(function() {
|
|
collection.add(item);
|
|
});
|
|
});
|
|
}),
|
|
|
|
/**
|
|
* Loads or prepares a pivot model based on the constraints and deals with pivot model changes by
|
|
* calling the appropriate Bookshelf Model API methods.
|
|
*
|
|
* @private
|
|
* @param {string} method
|
|
* Type of query being handled. This will be `insert` for {@link Collection#attach attach}
|
|
* calls, `delete` for {@link Collection#detach detach} calls and `update` for
|
|
* {@link Collection#updatePivot updatePivot} calls.
|
|
* @param {Model|object|mixed} item
|
|
* The item can be an object, in which case it's either a model that we're looking to attach to
|
|
* this model, or a hash of attributes to set in the relation. Otherwise it's a foreign key.
|
|
* @param {object} options Query options.
|
|
* @param {object} data The model data to constrain the query or attach to the relation.
|
|
* @param {object} fks
|
|
* @return {Promise}
|
|
*/
|
|
_processModelPivot: Promise.method(function(method, item, options, data, fks) {
|
|
const relatedData = this.relatedData,
|
|
JoinModel = relatedData.throughTarget,
|
|
joinModel = new JoinModel();
|
|
|
|
fks = joinModel.parse(fks);
|
|
data = joinModel.parse(data);
|
|
|
|
if (method === 'insert') {
|
|
return joinModel.set(data).save(null, options);
|
|
}
|
|
|
|
return joinModel
|
|
.set(fks)
|
|
.fetch()
|
|
.then(function(instance) {
|
|
if (method === 'delete') {
|
|
return instance.destroy(options);
|
|
}
|
|
return instance.save(item, options);
|
|
});
|
|
})
|
|
};
|
|
|
|
module.exports = Relation;
|