UNPKG

@adonisjs/lucid

Version:

- [x] Paginate method - [x] forPage method - [ ] chunk ( removed ) - [ ] pluckAll ( removed ) - [x] withPrefix - [x] transactions - [x] global transactions

802 lines (725 loc) 18.7 kB
'use strict' /* * adonis-lucid * * (c) Harminder Virk <virk@adonisjs.com> * * For the full copyright and license information, please view the LICENSE * file that was distributed with this source code. * * ==== Keys for User and Post model * primaryKey - user.primaryKey - id * relatedPrimaryKey - post.primaryKey - id * foreignKey - user.foreignKey - user_id * relatedForeignKey - post.foreignKey - post_id * */ const _ = require('lodash') const BaseRelation = require('./BaseRelation') const util = require('../../../lib/util') const CE = require('../../Exceptions') const PivotModel = require('../Model/PivotModel') /** * BelongsToMany class builds relationship between * two models with the help of pivot table/model * * @class BelongsToMany * @constructor */ class BelongsToMany extends BaseRelation { constructor (parentInstance, relatedModel, primaryKey, foreignKey, relatedPrimaryKey, relatedForeignKey) { super(parentInstance, relatedModel, primaryKey, foreignKey) this.relatedForeignKey = relatedForeignKey this.relatedPrimaryKey = relatedPrimaryKey /** * Since user can define a fully qualified model for * pivot table, we store it under this variable. * * @type {[type]} */ this._PivotModel = null /** * Settings related to pivot table only * * @type {Object} */ this._pivot = { table: util.makePivotTableName(parentInstance.constructor.name, relatedModel.name), withTimestamps: false, withFields: [] } this._relatedFields = [] /** * Here we store the existing pivot rows, to make * sure we are not inserting duplicates. * * @type {Array} */ this._existingPivotInstances = [] } /** * The colums to be selected from the related * query * * @method select * * @param {Array} columns * * @chainable */ select (columns) { this._relatedFields = _.isArray(columns) ? columns : _.toArray(arguments) return this } /** * Returns the pivot table name. The pivot model is * given preference over the default table name. * * @attribute $pivotTable * * @return {String} */ get $pivotTable () { return this._PivotModel ? this._PivotModel.table : this._pivot.table } /** * The pivot columns to be selected * * @attribute $pivotColumns * * @return {Array} */ get $pivotColumns () { return [this.relatedForeignKey, this.foreignKey].concat(this._pivot.withFields) } /** * Returns the name of select statement on pivot table * * @method _selectForPivot * * @param {String} field * * @return {String} * * @private */ _selectForPivot (field) { return `${this.$pivotTable}.${field} as pivot_${field}` } /** * Adds a where clause on pivot table by prefixing * the pivot table name. * * @method _whereForPivot * * @param {String} operator * @param {String} key * @param {...Spread} args * * @return {void} * * @private */ _whereForPivot (method, key, ...args) { this.relatedQuery[method](`${this.$pivotTable}.${key}`, ...args) } /** * Selecting fields from foriegn table and pivot * table * * @method _selectFields * * @return {void} */ _selectFields () { const pivotColumns = this.$pivotColumns /** * The list of pivotFields to be selected * * @type {Array} */ const pivotFields = _.map(pivotColumns, (column) => { this.relatedQuery._sideLoaded.push(`pivot_${column}`) return this._selectForPivot(column) }) const relatedFields = _.size(this._relatedFields) ? this._relatedFields.map((field) => { return `${this.$foreignTable}.${field}` }) : `${this.$foreignTable}.*` this.relatedQuery.select(relatedFields).select(pivotFields) } /** * Makes the join query * * @method _makeJoinQuery * * @return {void} * * @private */ _makeJoinQuery () { const self = this /** * Inner join to limit the rows */ this.relatedQuery.innerJoin(this.$pivotTable, function () { this.on(`${self.$foreignTable}.${self.relatedPrimaryKey}`, `${self.$pivotTable}.${self.relatedForeignKey}`) }) } /** * Decorates the query for read/update/delete * operations * * @method _decorateQuery * * @return {void} * * @private */ _decorateQuery () { this._selectFields() this._makeJoinQuery() this.wherePivot(this.foreignKey, this.$primaryKeyValue) } /** * Newup the pivot model set by user or the default * pivot model * * @method _newUpPivotModel * * @return {Object} * * @private */ _newUpPivotModel () { return new (this._PivotModel || PivotModel)() } /** * The pivot table values are sideloaded, so we need to remove * them sideload and instead set it as a relationship on * model instance * * @method _addPivotValuesAsRelation * * @param {Object} row * * @private */ _addPivotValuesAsRelation (row) { const pivotAttributes = {} /** * Removing pivot key/value pair from sideloaded object. * This is only quirky part. */ row.$sideLoaded = _.omitBy(row.$sideLoaded, (value, key) => { if (key.startsWith('pivot_')) { pivotAttributes[key.replace('pivot_', '')] = value return true } }) const pivotModel = this._newUpPivotModel() pivotModel.newUp(pivotAttributes) row.setRelated('pivot', pivotModel) } /** * Saves the relationship to the pivot table * * @method _attachSingle * @async * * @param {Number|String} value * @param {Function} [pivotCallback] * * @return {Object} Instance of pivot model * * @private */ async _attachSingle (value, pivotCallback) { /** * The relationship values * * @type {Object} */ const pivotValues = { [this.relatedForeignKey]: value, [this.foreignKey]: this.$primaryKeyValue } const pivotModel = this._newUpPivotModel() this._existingPivotInstances.push(pivotModel) pivotModel.fill(pivotValues) /** * Set $table, $timestamps, $connection when there * is no pre-defined pivot model. */ if (!this._PivotModel) { pivotModel.$table = this.$pivotTable pivotModel.$connection = this.RelatedModel.connection pivotModel.$withTimestamps = this._pivot.withTimestamps } /** * If pivot callback is defined, do call it. This gives * chance to the user to set additional fields to the * model. */ if (typeof (pivotCallback) === 'function') { pivotCallback(pivotModel) } await pivotModel.save() return pivotModel } /** * Persists the parent model instance if it's not * persisted already. This is done before saving * the related instance * * @method _persistParentIfRequired * @async * * @return {void} * * @private */ async _persistParentIfRequired () { if (this.parentInstance.isNew) { await this.parentInstance.save() } } /** * Loads the pivot relationship and then caches * it inside memory, so that more calls to * this function are not hitting database. * * @method _loadAndCachePivot * @async * * @return {void} * * @private */ async _loadAndCachePivot () { if (_.size(this._existingPivotInstances) === 0) { this._existingPivotInstances = (await this .pivotQuery().fetch() ).rows } } /** * Returns the existing pivot instance for a given * value. * * @method _getPivotInstance * * @param {String|Number} value * * @return {Object|Null} * * @private */ _getPivotInstance (value) { return _.find(this._existingPivotInstances, (instance) => instance[this.relatedForeignKey] === value) } /** * Define a fully qualified model to be used for * making pivot table queries and using defining * pivot table settings. * * @method pivotModel * * @param {Model} pivotModel * * @chainable */ pivotModel (pivotModel) { this._PivotModel = pivotModel return this } /** * Define the pivot table * * @method pivotTable * * @param {String} table * * @chainable */ pivotTable (table) { if (this._PivotModel) { throw CE.ModelRelationException.pivotModelIsDefined('pivotTable') } this._pivot.table = table return this } /** * Make sure `created_at` and `updated_at` timestamps * are being used * * @method withTimestamps * * @chainable */ withTimestamps () { if (this._PivotModel) { throw CE.ModelRelationException.pivotModelIsDefined('withTimestamps') } this._pivot.withTimestamps = true return this } /** * Fields to be selected from pivot table * * @method withPivot * * @param {Array} fields * * @chainable */ withPivot (fields) { fields = _.isArray(fields) ? fields : [fields] this._pivot.withFields = this._pivot.withFields.concat(fields) return this } /** * Returns an array of values to be used for running * whereIn query when eagerloading relationships. * * @method mapValues * * @param {Array} modelInstances - An array of model instances * * @return {Array} */ mapValues (modelInstances) { return _.map(modelInstances, (modelInstance) => modelInstance[this.primaryKey]) } /** * Make a where clause on the pivot table * * @method whereInPivot * * @param {String} key * @param {...Spread} args * * @chainable */ whereInPivot (key, ...args) { this._whereForPivot('whereIn', key, ...args) return this } /** * Make a orWhere clause on the pivot table * * @method orWherePivot * * @param {String} key * @param {...Spread} args * * @chainable */ orWherePivot (key, ...args) { this._whereForPivot('orWhere', key, ...args) return this } /** * Where clause on pivot table * * @method wherePivot * * @param {String} key * @param {...Spread} args * * @chainable */ wherePivot (key, ...args) { this._whereForPivot('where', key, ...args) return this } /** * Returns the eagerLoad query for the relationship * * @method eagerLoad * @async * * @param {Array} rows * * @return {Object} */ async eagerLoad (rows) { this._selectFields() this._makeJoinQuery() this.whereInPivot(this.foreignKey, this.mapValues(rows)) const relatedInstances = await this.relatedQuery.fetch() return this.group(relatedInstances.rows) } /** * Method called when eagerloading for a single * instance * * @method load * @async * * @return {Promise} */ load () { return this.fetch() } /** * Execute the query and setup pivot values * as a relation * * @method fetch * @async * * @return {Serializer} */ async fetch () { const rows = await super.fetch() rows.rows.forEach((row) => { this._addPivotValuesAsRelation(row) }) return rows } /** * Groups related instances with their foriegn keys * * @method group * * @param {Array} relatedInstances * * @return {Object} @multiple([key=String, values=Array, defaultValue=Null]) */ group (relatedInstances) { const Serializer = this.RelatedModel.Serializer const transformedValues = _.transform(relatedInstances, (result, relatedInstance) => { const foreignKeyValue = relatedInstance.$sideLoaded[`pivot_${this.foreignKey}`] const existingRelation = _.find(result, (row) => row.identity === foreignKeyValue) /** * If there is an existing relation, add row to * the relationship */ if (existingRelation) { existingRelation.value.addRow(relatedInstance) return result } result.push({ identity: foreignKeyValue, value: new Serializer([relatedInstance]) }) return result }, []) return { key: this.primaryKey, values: transformedValues, defaultValue: new Serializer([]) } } /** * Returns the query for pivot table * * @method pivotQuery * * @param {Boolean} selectFields * * @return {Object} */ pivotQuery (selectFields = true) { const query = this._PivotModel ? this._PivotModel.query() : new PivotModel().query(this.$pivotTable, this.RelatedModel.$connection) if (selectFields) { query.select(this.$pivotColumns) } query.where(this.foreignKey, this.$primaryKeyValue) return query } /** * Adds a where clause to limit the select search * to related rows only. * * @method relatedWhere * * @param {Boolean} count * * @return {Object} */ relatedWhere (count) { this._makeJoinQuery() this.relatedQuery.whereRaw(`${this.$primaryTable}.${this.primaryKey} = ${this.$pivotTable}.${this.foreignKey}`) /** * Add count clause if count is required */ if (count) { this.relatedQuery.count('*') } return this.relatedQuery.query } addWhereOn (context) { this._makeJoinQuery() context.on(`${this.$primaryTable}.${this.primaryKey}`, '=', `${this.$pivotTable}.${this.foreignKey}`) } /** * Attach existing rows inside pivot table as a relationship * * @method attach * * @param {Number|String|Array} relatedPrimaryKeyValue * @param {Function} [pivotCallback] * * @return {Promise} */ async attach (references, pivotCallback = null) { await this._loadAndCachePivot() const rows = references instanceof Array === false ? [references] : references return Promise.all(rows.map((row) => { const pivotInstance = this._getPivotInstance(row) return pivotInstance ? Promise.resolve(pivotInstance) : this._attachSingle(row, pivotCallback) })) } /** * Delete related model rows in bulk and also detach * them from the pivot table. * * NOTE: This method will run 3 queries in total. First is to * fetch the related rows, next is to delete them and final * is to remove the relationship from pivot table. * * @method delete * @async * * @return {Number} Number of effected rows */ async delete () { const foreignKeyValues = await this.ids() const effectedRows = await this.RelatedModel .query() .whereIn(this.RelatedModel.primaryKey, foreignKeyValues) .delete() await this.detach(foreignKeyValues) return effectedRows } /** * Update related rows * * @method update * * @param {Object} values * * @return {Number} Number of effected rows */ async update (values) { const foreignKeyValues = await this.ids() return this.RelatedModel .query() .whereIn(this.RelatedModel.primaryKey, foreignKeyValues) .update(values) } /** * Detach existing relations from the pivot table * * @method detach * @async * * @param {Array} references * * @return {Number} The number of effected rows */ detach (references) { const query = this.pivotQuery(false) if (references) { const rows = references instanceof Array === false ? [references] : references query.whereIn(this.relatedForeignKey, rows) _.remove(this._existingPivotInstances, (pivotInstance) => { return _.includes(rows, pivotInstance[this.relatedForeignKey]) }) } else { this._existingPivotInstances = [] } return query.delete() } /** * Save the related model instance and setup the relationship * inside pivot table * * @method save * * @param {Object} relatedInstance * @param {Function} pivotCallback * * @return {void} */ async save (relatedInstance, pivotCallback) { await this._persistParentIfRequired() /** * Only save related instance when not persisted already. This is * only required in belongsToMany since relatedInstance is not * made dirty by this method. */ if (relatedInstance.isNew || relatedInstance.isDirty) { await relatedInstance.save() } /** * Attach the pivot rows */ const pivotRows = await this.attach(relatedInstance.primaryKeyValue, pivotCallback) /** * Set saved pivot row as a relationship */ relatedInstance.setRelated('pivot', pivotRows[0]) } /** * Save multiple relationships to the database. This method * will run queries in parallel * * @method saveMany * @async * * @param {Array} arrayOfRelatedInstances * @param {Function} [pivotCallback] * * @return {void} */ async saveMany (arrayOfRelatedInstances, pivotCallback) { if (arrayOfRelatedInstances instanceof Array === false) { throw CE .InvalidArgumentException .invalidParameter('belongsToMany.saveMany expects an array of related model instances') } await this._persistParentIfRequired() return Promise.all(arrayOfRelatedInstances.map((relatedInstance) => this.save(relatedInstance, pivotCallback))) } /** * Creates a new related model instance and persist * the relationship inside pivot table * * @method create * @async * * @param {Object} row * @param {Function} [pivotCallback] * * @return {Object} Instance of related model */ async create (row, pivotCallback) { await this._persistParentIfRequired() const relatedInstance = new this.RelatedModel() relatedInstance.fill(row) await this.save(relatedInstance, pivotCallback) return relatedInstance } /** * Creates multiple related relationships. This method will * call all queries in parallel * * @method createMany * @async * * @param {Array} rows * @param {Function} pivotCallback * * @return {Array} */ async createMany (rows, pivotCallback) { if (rows instanceof Array === false) { throw CE .InvalidArgumentException .invalidParameter('belongsToMany.createMany expects an array of related model instances') } await this._persistParentIfRequired() return Promise.all(rows.map((relatedInstance) => this.create(relatedInstance, pivotCallback))) } } module.exports = BelongsToMany