diff --git a/src/Database/Relations/BelongsTo.php b/src/Database/Relations/BelongsTo.php index 798906eac..b5d68e031 100644 --- a/src/Database/Relations/BelongsTo.php +++ b/src/Database/Relations/BelongsTo.php @@ -52,6 +52,90 @@ public function remove(Model $model, $sessionKey = null) } } + /** + * Associate the model instance to the given parent. + * + * @param \Illuminate\Database\Eloquent\Model|int|string $model + * @return \Illuminate\Database\Eloquent\Model + */ + public function associate($model) + { + /** + * @event model.relation.beforeAssociate + * Called before associating a relation to the model (only for BelongsTo/MorphTo relations) + * + * Example usage: + * + * $model->bindEvent('model.relation.beforeAssociate', function (string $relationName, \October\Rain\Database\Model $relatedModel) use (\October\Rain\Database\Model $model) { + * if ($relationName === 'dummyRelation') { + * throw new \Exception("Invalid relation!"); + * } + * }); + * + */ + $this->parent->fireEvent('model.relation.beforeAssociate', [$this->relationName, $model]); + + $result = parent::associate($model); + + /** + * @event model.relation.afterAssociate + * Called after associating a relation to the model (only for BelongsTo/MorphTo relations) + * + * Example usage: + * + * $model->bindEvent('model.relation.afterAssociate', function (string $relationName, \October\Rain\Database\Model $relatedModel) use (\October\Rain\Database\Model $model) { + * $relatedClass = get_class($relatedModel); + * $modelClass = get_class($model); + * traceLog("{$relatedClass} was associated as {$relationName} to {$modelClass}."); + * }); + * + */ + $this->parent->fireEvent('model.relation.afterAssociate', [$this->relationName, $model]); + + return $result; + } + + /** + * Dissociate previously dissociated model from the given parent. + * + * @return \Illuminate\Database\Eloquent\Model + */ + public function dissociate() + { + /** + * @event model.relation.beforeDissociate + * Called before dissociating a relation to the model (only for BelongsTo/MorphTo relations) + * + * Example usage: + * + * $model->bindEvent('model.relation.beforeDissociate', function (string $relationName) use (\October\Rain\Database\Model $model) { + * if ($relationName === 'permanentRelation') { + * throw new \Exception("Cannot dissociate a permanent relation!"); + * } + * }); + * + */ + $this->parent->fireEvent('model.relation.beforeDissociate', [$this->relationName]); + + $result = parent::dissociate(); + + /** + * @event model.relation.afterDissociate + * Called after dissociating a relation to the model (only for BelongsTo/MorphTo relations) + * + * Example usage: + * + * $model->bindEvent('model.relation.afterDissociate', function (string $relationName) use (\October\Rain\Database\Model $model) { + * $modelClass = get_class($model); + * traceLog("{$relationName} was dissociated from {$modelClass}."); + * }); + * + */ + $this->parent->fireEvent('model.relation.afterDissociate', [$this->relationName]); + + return $result; + } + /** * Helper for setting this relationship using various expected * values. For example, $model->relation = $value; diff --git a/src/Database/Relations/Concerns/AttachOneOrMany.php b/src/Database/Relations/Concerns/AttachOneOrMany.php index 441e6b218..f90f10ea4 100644 --- a/src/Database/Relations/Concerns/AttachOneOrMany.php +++ b/src/Database/Relations/Concerns/AttachOneOrMany.php @@ -167,6 +167,21 @@ public function add(Model $model, $sessionKey = null) } if ($sessionKey === null) { + /** + * @event model.relation.beforeAdd + * Called before adding a relation to the model (for AttachOneOrMany, HasOneOrMany & MorphOneOrMany relations) + * + * Example usage: + * + * $model->bindEvent('model.relation.beforeAdd', function (string $relationName, \October\Rain\Database\Model $relatedModel) use (\October\Rain\Database\Model $model) { + * if ($relationName === 'dummyRelation') { + * throw new \Exception("Invalid relation!"); + * } + * }); + * + */ + $this->parent->fireEvent('model.relation.beforeAdd', [$this->relationName, $model]); + // Delete siblings for single attachments if ($this instanceof AttachOne) { $this->delete(); @@ -186,6 +201,21 @@ public function add(Model $model, $sessionKey = null) else { $this->parent->reloadRelations($this->relationName); } + + /** + * @event model.relation.afterAdd + * Called after adding a relation to the model (for AttachOneOrMany, HasOneOrMany & MorphOneOrMany relations) + * + * Example usage: + * + * $model->bindEvent('model.relation.afterAdd', function (string $relationName, \October\Rain\Database\Model $relatedModel) use (\October\Rain\Database\Model $model) { + * $relatedClass = get_class($relatedModel); + * $modelClass = get_class($model); + * traceLog("{$relatedClass} was added as {$relationName} to {$modelClass}."); + * }); + * + */ + $this->parent->fireEvent('model.relation.afterAdd', [$this->relationName, $model]); } else { $this->parent->bindDeferred($this->relationName, $model, $sessionKey); @@ -210,6 +240,21 @@ public function addMany($models, $sessionKey = null) public function remove(Model $model, $sessionKey = null) { if ($sessionKey === null) { + /** + * @event model.relation.beforeRemove + * Called before removing a relation to the model (for AttachOneOrMany, HasOneOrMany & MorphOneOrMany relations) + * + * Example usage: + * + * $model->bindEvent('model.relation.beforeRemove', function (string $relationName, \October\Rain\Database\Model $relatedModel) use (\October\Rain\Database\Model $model) { + * if ($relationName === 'permanentRelation') { + * throw new \Exception("Cannot dissociate a permanent relation!"); + * } + * }); + * + */ + $this->parent->fireEvent('model.relation.beforeRemove', [$this->relationName, $model]); + $options = $this->parent->getRelationDefinition($this->relationName); if (array_get($options, 'delete', false)) { @@ -234,6 +279,21 @@ public function remove(Model $model, $sessionKey = null) else { $this->parent->reloadRelations($this->relationName); } + + /** + * @event model.relation.afterRemove + * Called after removing a relation to the model (for AttachOneOrMany, HasOneOrMany & MorphOneOrMany relations) + * + * Example usage: + * + * $model->bindEvent('model.relation.afterRemove', function (string $relationName, \October\Rain\Database\Model $relatedModel) use (\October\Rain\Database\Model $model) { + * $relatedClass = get_class($relatedModel); + * $modelClass = get_class($model); + * traceLog("{$relatedClass} was removed from {$modelClass}."); + * }); + * + */ + $this->parent->fireEvent('model.relation.afterRemove', [$this->relationName, $model]); } else { $this->parent->unbindDeferred($this->relationName, $model, $sessionKey); diff --git a/src/Database/Relations/Concerns/HasOneOrMany.php b/src/Database/Relations/Concerns/HasOneOrMany.php index 24cb271e8..b27eb5978 100644 --- a/src/Database/Relations/Concerns/HasOneOrMany.php +++ b/src/Database/Relations/Concerns/HasOneOrMany.php @@ -57,6 +57,21 @@ public function create(array $attributes = [], $sessionKey = null) public function add(Model $model, $sessionKey = null) { if ($sessionKey === null) { + /** + * @event model.relation.beforeAdd + * Called before adding a relation to the model (for AttachOneOrMany, HasOneOrMany & MorphOneOrMany relations) + * + * Example usage: + * + * $model->bindEvent('model.relation.beforeAdd', function (string $relationName, \October\Rain\Database\Model $relatedModel) use (\October\Rain\Database\Model $model) { + * if ($relationName === 'dummyRelation') { + * throw new \Exception("Invalid relation!"); + * } + * }); + * + */ + $this->parent->fireEvent('model.relation.beforeAdd', [$this->relationName, $model]); + $model->setAttribute($this->getForeignKeyName(), $this->getParentKey()); if (!$model->exists || $model->isDirty()) { @@ -72,6 +87,21 @@ public function add(Model $model, $sessionKey = null) else { $this->parent->reloadRelations($this->relationName); } + + /** + * @event model.relation.afterAdd + * Called after adding a relation to the model (for AttachOneOrMany, HasOneOrMany & MorphOneOrMany relations) + * + * Example usage: + * + * $model->bindEvent('model.relation.afterAdd', function (string $relationName, \October\Rain\Database\Model $relatedModel) use (\October\Rain\Database\Model $model) { + * $relatedClass = get_class($relatedModel); + * $modelClass = get_class($model); + * traceLog("{$relatedClass} was added as {$relationName} to {$modelClass}."); + * }); + * + */ + $this->parent->fireEvent('model.relation.afterAdd', [$this->relationName, $model]); } else { $this->parent->bindDeferred($this->relationName, $model, $sessionKey); @@ -96,6 +126,21 @@ public function addMany($models, $sessionKey = null) public function remove(Model $model, $sessionKey = null) { if ($sessionKey === null) { + /** + * @event model.relation.beforeRemove + * Called before removing a relation to the model (for AttachOneOrMany, HasOneOrMany & MorphOneOrMany relations) + * + * Example usage: + * + * $model->bindEvent('model.relation.beforeRemove', function (string $relationName, \October\Rain\Database\Model $relatedModel) use (\October\Rain\Database\Model $model) { + * if ($relationName === 'permanentRelation') { + * throw new \Exception("Cannot dissociate a permanent relation!"); + * } + * }); + * + */ + $this->parent->fireEvent('model.relation.beforeRemove', [$this->relationName, $model]); + $model->setAttribute($this->getForeignKeyName(), null); $model->save(); @@ -108,6 +153,20 @@ public function remove(Model $model, $sessionKey = null) else { $this->parent->reloadRelations($this->relationName); } + /** + * @event model.relation.afterRemove + * Called after removing a relation to the model (for AttachOneOrMany, HasOneOrMany & MorphOneOrMany relations) + * + * Example usage: + * + * $model->bindEvent('model.relation.afterRemove', function (string $relationName, \October\Rain\Database\Model $relatedModel) use (\October\Rain\Database\Model $model) { + * $relatedClass = get_class($relatedModel); + * $modelClass = get_class($model); + * traceLog("{$relatedClass} was removed from {$modelClass}."); + * }); + * + */ + $this->parent->fireEvent('model.relation.afterRemove', [$this->relationName, $model]); } else { $this->parent->unbindDeferred($this->relationName, $model, $sessionKey); diff --git a/src/Database/Relations/Concerns/MorphOneOrMany.php b/src/Database/Relations/Concerns/MorphOneOrMany.php index b7166aeea..7e9880579 100644 --- a/src/Database/Relations/Concerns/MorphOneOrMany.php +++ b/src/Database/Relations/Concerns/MorphOneOrMany.php @@ -45,6 +45,21 @@ public function create(array $attributes = [], $sessionKey = null) public function add(Model $model, $sessionKey = null) { if ($sessionKey === null) { + /** + * @event model.relation.beforeAdd + * Called before adding a relation to the model (for AttachOneOrMany, HasOneOrMany & MorphOneOrMany relations) + * + * Example usage: + * + * $model->bindEvent('model.relation.beforeAdd', function (string $relationName, \October\Rain\Database\Model $relatedModel) use (\October\Rain\Database\Model $model) { + * if ($relationName === 'dummyRelation') { + * throw new \Exception("Invalid relation!"); + * } + * }); + * + */ + $this->parent->fireEvent('model.relation.beforeAdd', [$this->relationName, $model]); + $model->setAttribute($this->getForeignKeyName(), $this->getParentKey()); $model->setAttribute($this->getMorphType(), $this->morphClass); $model->save(); @@ -58,6 +73,21 @@ public function add(Model $model, $sessionKey = null) else { $this->parent->reloadRelations($this->relationName); } + + /** + * @event model.relation.afterAdd + * Called after adding a relation to the model (for AttachOneOrMany, HasOneOrMany & MorphOneOrMany relations) + * + * Example usage: + * + * $model->bindEvent('model.relation.afterAdd', function (string $relationName, \October\Rain\Database\Model $relatedModel) use (\October\Rain\Database\Model $model) { + * $relatedClass = get_class($relatedModel); + * $modelClass = get_class($model); + * traceLog("{$relatedClass} was added as {$relationName} to {$modelClass}."); + * }); + * + */ + $this->parent->fireEvent('model.relation.afterAdd', [$this->relationName, $model]); } else { $this->parent->bindDeferred($this->relationName, $model, $sessionKey); @@ -70,6 +100,21 @@ public function add(Model $model, $sessionKey = null) public function remove(Model $model, $sessionKey = null) { if ($sessionKey === null) { + /** + * @event model.relation.beforeRemove + * Called before removing a relation to the model (for AttachOneOrMany, HasOneOrMany & MorphOneOrMany relations) + * + * Example usage: + * + * $model->bindEvent('model.relation.beforeRemove', function (string $relationName, \October\Rain\Database\Model $relatedModel) use (\October\Rain\Database\Model $model) { + * if ($relationName === 'permanentRelation') { + * throw new \Exception("Cannot dissociate a permanent relation!"); + * } + * }); + * + */ + $this->parent->fireEvent('model.relation.beforeRemove', [$this->relationName, $model]); + $options = $this->parent->getRelationDefinition($this->relationName); if (array_get($options, 'delete', false)) { @@ -93,6 +138,21 @@ public function remove(Model $model, $sessionKey = null) else { $this->parent->reloadRelations($this->relationName); } + + /** + * @event model.relation.afterRemove + * Called after removing a relation to the model (for AttachOneOrMany, HasOneOrMany & MorphOneOrMany relations) + * + * Example usage: + * + * $model->bindEvent('model.relation.afterRemove', function (string $relationName, \October\Rain\Database\Model $relatedModel) use (\October\Rain\Database\Model $model) { + * $relatedClass = get_class($relatedModel); + * $modelClass = get_class($model); + * traceLog("{$relatedClass} was removed from {$modelClass}."); + * }); + * + */ + $this->parent->fireEvent('model.relation.afterRemove', [$this->relationName, $model]); } else { $this->parent->unbindDeferred($this->relationName, $model, $sessionKey); diff --git a/src/Database/Relations/MorphTo.php b/src/Database/Relations/MorphTo.php index 661cdc337..b159cfde2 100644 --- a/src/Database/Relations/MorphTo.php +++ b/src/Database/Relations/MorphTo.php @@ -26,6 +26,90 @@ public function __construct(Builder $query, Model $parent, $foreignKey, $otherKe $this->addDefinedConstraints(); } + /** + * Associate the model instance to the given parent. + * + * @param \Illuminate\Database\Eloquent\Model $model + * @return \Illuminate\Database\Eloquent\Model + */ + public function associate($model) + { + /** + * @event model.relation.beforeAssociate + * Called before associating a relation to the model (only for BelongsTo/MorphTo relations) + * + * Example usage: + * + * $model->bindEvent('model.relation.beforeAssociate', function (string $relationName, \October\Rain\Database\Model $relatedModel) use (\October\Rain\Database\Model $model) { + * if ($relationName === 'dummyRelation') { + * throw new \Exception("Invalid relation!"); + * } + * }); + * + */ + $this->parent->fireEvent('model.relation.beforeAssociate', [$this->relationName, $model]); + + $result = parent::associate($model); + + /** + * @event model.relation.afterAssociate + * Called after associating a relation to the model (only for BelongsTo/MorphTo relations) + * + * Example usage: + * + * $model->bindEvent('model.relation.afterAssociate', function (string $relationName, \October\Rain\Database\Model $relatedModel) use (\October\Rain\Database\Model $model) { + * $relatedClass = get_class($relatedModel); + * $modelClass = get_class($model); + * traceLog("{$relatedClass} was associated as {$relationName} to {$modelClass}."); + * }); + * + */ + $this->parent->fireEvent('model.relation.afterAssociate', [$this->relationName, $model]); + + return $result; + } + + /** + * Dissociate previously dissociated model from the given parent. + * + * @return \Illuminate\Database\Eloquent\Model + */ + public function dissociate() + { + /** + * @event model.relation.beforeDissociate + * Called before dissociating a relation to the model (only for BelongsTo/MorphTo relations) + * + * Example usage: + * + * $model->bindEvent('model.relation.beforeDissociate', function (string $relationName) use (\October\Rain\Database\Model $model) { + * if ($relationName === 'permanentRelation') { + * throw new \Exception("Cannot dissociate a permanent relation!"); + * } + * }); + * + */ + $this->parent->fireEvent('model.relation.beforeDissociate', [$this->relationName]); + + $result = parent::dissociate(); + + /** + * @event model.relation.afterDissociate + * Called after dissociating a relation to the model (only for BelongsTo/MorphTo relations) + * + * Example usage: + * + * $model->bindEvent('model.relation.afterDissociate', function (string $relationName) use (\October\Rain\Database\Model $model) { + * $modelClass = get_class($model); + * traceLog("{$relationName} was dissociated from {$modelClass}."); + * }); + * + */ + $this->parent->fireEvent('model.relation.afterDissociate', [$this->relationName]); + + return $result; + } + /** * Helper for setting this relationship using various expected * values. For example, $model->relation = $value; diff --git a/src/Database/Traits/HasSortableRelations.php b/src/Database/Traits/HasSortableRelations.php new file mode 100644 index 000000000..c3a65ba3b --- /dev/null +++ b/src/Database/Traits/HasSortableRelations.php @@ -0,0 +1,138 @@ + 'sort_order_column']; + * + * To set orders: + * + * $model->setSortableRelationOrder($relationName, $recordIds, $recordOrders); + * + */ +trait HasSortableRelations +{ + /** + * @var array The array of all sortable relations with their sort_order pivot column. + * + * public $sortableRelations = ['related_model' => 'sort_order']; + */ + + /** + * Initialize the HasSortableRelations trait for this model. + * Sets the sort_order value if a related model has been attached. + */ + public function initializeHasSortableRelations() : void + { + $sortableRelations = $this->getSortableRelations(); + + $this->bindEvent('model.relation.afterAttach', function ($relationName, $attached, $data) use ($sortableRelations) { + // Only for pivot-based relations + if (array_key_exists($relationName, $sortableRelations)) { + $column = $this->getRelationSortOrderColumn($relationName); + + foreach ($attached as $id) { + $this->updateRelationOrder($relationName, $id, $column); + } + } + }); + + $this->bindEvent('model.relation.afterAdd', function ($relationName, $relatedModel) use ($sortableRelations) { + // Only for non pivot-based relations + if (array_key_exists($relationName, $sortableRelations)) { + $column = $this->getRelationSortOrderColumn($relationName); + + $this->updateRelationOrder($relationName, $relatedModel->id, $column); + } + }); + + foreach ($sortableRelations as $relationName => $column) { + $relation = $this->$relationName(); + if (method_exists($relation, 'updateExistingPivot')) { + // Make sure all pivot-based defined sortable relations load the sort_order column as pivot data. + $definition = $this->getRelationDefinition($relationName); + $pivot = array_wrap(array_get($definition, 'pivot', [])); + + if (!in_array($column, $pivot)) { + $pivot[] = $column; + $definition['pivot'] = $pivot; + + $relationType = $this->getRelationType($relationName); + $this->$relationType[$relationName] = $definition; + } + } + } + } + + /** + * Set the sort order of records to the specified orders. If the orders is + * undefined, the record identifier is used. + */ + public function setRelationOrder(string $relationName, string|int|array $itemIds, array $itemOrders = []) : void + { + if (!is_array($itemIds)) { + $itemIds = [$itemIds]; + } + + if (empty($itemOrders)) { + $itemOrders = $itemIds; + } + + if (count($itemIds) != count($itemOrders)) { + throw new Exception('Invalid setRelationOrder call - count of itemIds do not match count of itemOrders'); + } + + $column = $this->getRelationSortOrderColumn($relationName); + + foreach ($itemIds as $index => $id) { + $order = (int)$itemOrders[$index]; + $this->updateRelationOrder($relationName, $id, $column, $order); + } + } + + /** + * Update relation record sort_order. + */ + protected function updateRelationOrder(string $relationName, int $id, string $column, int $order = 0) : void + { + $relation = $this->{$relationName}(); + + if (!$order) { + $order = $relation->count(); + } + if (method_exists($relation, 'updateExistingPivot')) { + $relation->updateExistingPivot($id, [ $column => (int)$order ]); + } else { + $record = $relation->find($id); + $record->{$column} = (int)$order; + $record->save(); + } + } + + /** + * Get the name of the "sort_order" column. + */ + public function getRelationSortOrderColumn(string $relationName) : string + { + return $this->getSortableRelations()[$relationName] ?? 'sort_order'; + } + + /** + * Return all configured sortable relations. + */ + protected function getSortableRelations() : array + { + if (property_exists($this, 'sortableRelations')) { + return $this->sortableRelations; + } + return []; + } +}