InteractsWithPivotTable.php
TLDR
This file, InteractsWithPivotTable.php
, is part of the Illuminate\Database\Eloquent\Relations\Concerns namespace and contains the InteractsWithPivotTable
trait. This trait provides methods for interacting with pivot tables in Eloquent relationships. The methods include toggling models from the parent, syncing the intermediate tables with a list of IDs or models, updating existing pivot records, attaching models to the parent, detaching models from the relationship, and more.
Methods
toggle
Toggles a model (or models) from the parent. Each existing model is detached, and non-existing ones are attached.
syncWithoutDetaching
Syncs the intermediate tables with a list of IDs without detaching.
sync
Syncs the intermediate tables with a list of IDs or collection of models.
syncWithPivotValues
Syncs the intermediate tables with a list of IDs or collection of models with the given pivot values.
formatRecordsList
Formats the sync / toggle record list so that it is keyed by ID.
attachNew
Attaches all of the records that aren't in the given current records.
updateExistingPivot
Updates an existing pivot record on the table.
updateExistingPivotUsingCustomClass
Updates an existing pivot record on the table via a custom class.
attach
Attach a model to the parent.
attachUsingCustomClass
Attach a model to the parent using a custom class.
formatAttachRecords
Create an array of records to insert into the pivot table.
formatAttachRecord
Create a full attachment record payload.
hasPivotColumn
Determine whether the given column is defined as a pivot column.
detach
Detach models from the relationship.
detachUsingCustomClass
Detach models from the relationship using a custom class.
getCurrentlyAttachedPivots
Get the pivot models that are currently attached.
newPivot
Create a new pivot model instance.
newExistingPivot
Create a new existing pivot model instance.
newPivotStatement
Get a new plain query builder for the pivot table.
newPivotStatementForId
Get a new pivot statement for a given "other" ID.
newPivotQuery
Create a new query builder for the pivot table.
withPivot
Set the columns on the pivot table to retrieve.
parseIds
Get all of the IDs from the given mixed value.
parseId
Get the ID from the given mixed value.
castKeys
Cast the given keys to integers if they are numeric and string otherwise.
castKey
Cast the given key to convert to the primary key type.
castAttributes
Cast the given pivot attributes.
getTypeSwapValue
Converts a given value to a given type value.
<?php
namespace Illuminate\Database\Eloquent\Relations\Concerns;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\Pivot;
use Illuminate\Support\Collection as BaseCollection;
trait InteractsWithPivotTable
{
/**
* Toggles a model (or models) from the parent.
*
* Each existing model is detached, and non existing ones are attached.
*
* @param mixed $ids
* @param bool $touch
* @return array
*/
public function toggle($ids, $touch = true)
{
$changes = [
'attached' => [], 'detached' => [],
];
$records = $this->formatRecordsList($this->parseIds($ids));
// Next, we will determine which IDs should get removed from the join table by
// checking which of the given ID/records is in the list of current records
// and removing all of those rows from this "intermediate" joining table.
$detach = array_values(array_intersect(
$this->newPivotQuery()->pluck($this->relatedPivotKey)->all(),
array_keys($records)
));
if (count($detach) > 0) {
$this->detach($detach, false);
$changes['detached'] = $this->castKeys($detach);
}
// Finally, for all of the records which were not "detached", we'll attach the
// records into the intermediate table. Then, we will add those attaches to
// this change list and get ready to return these results to the callers.
$attach = array_diff_key($records, array_flip($detach));
if (count($attach) > 0) {
$this->attach($attach, [], false);
$changes['attached'] = array_keys($attach);
}
// Once we have finished attaching or detaching the records, we will see if we
// have done any attaching or detaching, and if we have we will touch these
// relationships if they are configured to touch on any database updates.
if ($touch && (count($changes['attached']) ||
count($changes['detached']))) {
$this->touchIfTouching();
}
return $changes;
}
/**
* Sync the intermediate tables with a list of IDs without detaching.
*
* @param \Illuminate\Support\Collection|\Illuminate\Database\Eloquent\Model|array $ids
* @return array
*/
public function syncWithoutDetaching($ids)
{
return $this->sync($ids, false);
}
/**
* Sync the intermediate tables with a list of IDs or collection of models.
*
* @param \Illuminate\Support\Collection|\Illuminate\Database\Eloquent\Model|array $ids
* @param bool $detaching
* @return array
*/
public function sync($ids, $detaching = true)
{
$changes = [
'attached' => [], 'detached' => [], 'updated' => [],
];
// First we need to attach any of the associated models that are not currently
// in this joining table. We'll spin through the given IDs, checking to see
// if they exist in the array of current ones, and if not we will insert.
$current = $this->getCurrentlyAttachedPivots()
->pluck($this->relatedPivotKey)->all();
$records = $this->formatRecordsList($this->parseIds($ids));
// Next, we will take the differences of the currents and given IDs and detach
// all of the entities that exist in the "current" array but are not in the
// array of the new IDs given to the method which will complete the sync.
if ($detaching) {
$detach = array_diff($current, array_keys($records));
if (count($detach) > 0) {
$this->detach($detach);
$changes['detached'] = $this->castKeys($detach);
}
}
// Now we are finally ready to attach the new records. Note that we'll disable
// touching until after the entire operation is complete so we don't fire a
// ton of touch operations until we are totally done syncing the records.
$changes = array_merge(
$changes, $this->attachNew($records, $current, false)
);
// Once we have finished attaching or detaching the records, we will see if we
// have done any attaching or detaching, and if we have we will touch these
// relationships if they are configured to touch on any database updates.
if (count($changes['attached']) ||
count($changes['updated']) ||
count($changes['detached'])) {
$this->touchIfTouching();
}
return $changes;
}
/**
* Sync the intermediate tables with a list of IDs or collection of models with the given pivot values.
*
* @param \Illuminate\Support\Collection|\Illuminate\Database\Eloquent\Model|array $ids
* @param array $values
* @param bool $detaching
* @return array
*/
public function syncWithPivotValues($ids, array $values, bool $detaching = true)
{
return $this->sync(collect($this->parseIds($ids))->mapWithKeys(function ($id) use ($values) {
return [$id => $values];
}), $detaching);
}
/**
* Format the sync / toggle record list so that it is keyed by ID.
*
* @param array $records
* @return array
*/
protected function formatRecordsList(array $records)
{
return collect($records)->mapWithKeys(function ($attributes, $id) {
if (! is_array($attributes)) {
[$id, $attributes] = [$attributes, []];
}
return [$id => $attributes];
})->all();
}
/**
* Attach all of the records that aren't in the given current records.
*
* @param array $records
* @param array $current
* @param bool $touch
* @return array
*/
protected function attachNew(array $records, array $current, $touch = true)
{
$changes = ['attached' => [], 'updated' => []];
foreach ($records as $id => $attributes) {
// If the ID is not in the list of existing pivot IDs, we will insert a new pivot
// record, otherwise, we will just update this existing record on this joining
// table, so that the developers will easily update these records pain free.
if (! in_array($id, $current)) {
$this->attach($id, $attributes, $touch);
$changes['attached'][] = $this->castKey($id);
}
// Now we'll try to update an existing pivot record with the attributes that were
// given to the method. If the model is actually updated we will add it to the
// list of updated pivot records so we return them back out to the consumer.
elseif (count($attributes) > 0 &&
$this->updateExistingPivot($id, $attributes, $touch)) {
$changes['updated'][] = $this->castKey($id);
}
}
return $changes;
}
/**
* Update an existing pivot record on the table.
*
* @param mixed $id
* @param array $attributes
* @param bool $touch
* @return int
*/
public function updateExistingPivot($id, array $attributes, $touch = true)
{
if ($this->using &&
empty($this->pivotWheres) &&
empty($this->pivotWhereIns) &&
empty($this->pivotWhereNulls)) {
return $this->updateExistingPivotUsingCustomClass($id, $attributes, $touch);
}
if ($this->hasPivotColumn($this->updatedAt())) {
$attributes = $this->addTimestampsToAttachment($attributes, true);
}
$updated = $this->newPivotStatementForId($this->parseId($id))->update(
$this->castAttributes($attributes)
);
if ($touch) {
$this->touchIfTouching();
}
return $updated;
}
/**
* Update an existing pivot record on the table via a custom class.
*
* @param mixed $id
* @param array $attributes
* @param bool $touch
* @return int
*/
protected function updateExistingPivotUsingCustomClass($id, array $attributes, $touch)
{
$pivot = $this->getCurrentlyAttachedPivots()
->where($this->foreignPivotKey, $this->parent->{$this->parentKey})
->where($this->relatedPivotKey, $this->parseId($id))
->first();
$updated = $pivot ? $pivot->fill($attributes)->isDirty() : false;
if ($updated) {
$pivot->save();
}
if ($touch) {
$this->touchIfTouching();
}
return (int) $updated;
}
/**
* Attach a model to the parent.
*
* @param mixed $id
* @param array $attributes
* @param bool $touch
* @return void
*/
public function attach($id, array $attributes = [], $touch = true)
{
if ($this->using) {
$this->attachUsingCustomClass($id, $attributes);
} else {
// Here we will insert the attachment records into the pivot table. Once we have
// inserted the records, we will touch the relationships if necessary and the
// function will return. We can parse the IDs before inserting the records.
$this->newPivotStatement()->insert($this->formatAttachRecords(
$this->parseIds($id), $attributes
));
}
if ($touch) {
$this->touchIfTouching();
}
}
/**
* Attach a model to the parent using a custom class.
*
* @param mixed $id
* @param array $attributes
* @return void
*/
protected function attachUsingCustomClass($id, array $attributes)
{
$records = $this->formatAttachRecords(
$this->parseIds($id), $attributes
);
foreach ($records as $record) {
$this->newPivot($record, false)->save();
}
}
/**
* Create an array of records to insert into the pivot table.
*
* @param array $ids
* @param array $attributes
* @return array
*/
protected function formatAttachRecords($ids, array $attributes)
{
$records = [];
$hasTimestamps = ($this->hasPivotColumn($this->createdAt()) ||
$this->hasPivotColumn($this->updatedAt()));
// To create the attachment records, we will simply spin through the IDs given
// and create a new record to insert for each ID. Each ID may actually be a
// key in the array, with extra attributes to be placed in other columns.
foreach ($ids as $key => $value) {
$records[] = $this->formatAttachRecord(
$key, $value, $attributes, $hasTimestamps
);
}
return $records;
}
/**
* Create a full attachment record payload.
*
* @param int $key
* @param mixed $value
* @param array $attributes
* @param bool $hasTimestamps
* @return array
*/
protected function formatAttachRecord($key, $value, $attributes, $hasTimestamps)
{
[$id, $attributes] = $this->extractAttachIdAndAttributes($key, $value, $attributes);
return array_merge(
$this->baseAttachRecord($id, $hasTimestamps), $this->castAttributes($attributes)
);
}
/**
* Get the attach record ID and extra attributes.
*
* @param mixed $key
* @param mixed $value
* @param array $attributes
* @return array
*/
protected function extractAttachIdAndAttributes($key, $value, array $attributes)
{
return is_array($value)
? [$key, array_merge($value, $attributes)]
: [$value, $attributes];
}
/**
* Create a new pivot attachment record.
*
* @param int $id
* @param bool $timed
* @return array
*/
protected function baseAttachRecord($id, $timed)
{
$record[$this->relatedPivotKey] = $id;
$record[$this->foreignPivotKey] = $this->parent->{$this->parentKey};
// If the record needs to have creation and update timestamps, we will make
// them by calling the parent model's "freshTimestamp" method which will
// provide us with a fresh timestamp in this model's preferred format.
if ($timed) {
$record = $this->addTimestampsToAttachment($record);
}
foreach ($this->pivotValues as $value) {
$record[$value['column']] = $value['value'];
}
return $record;
}
/**
* Set the creation and update timestamps on an attach record.
*
* @param array $record
* @param bool $exists
* @return array
*/
protected function addTimestampsToAttachment(array $record, $exists = false)
{
$fresh = $this->parent->freshTimestamp();
if ($this->using) {
$pivotModel = new $this->using;
$fresh = $pivotModel->fromDateTime($fresh);
}
if (! $exists && $this->hasPivotColumn($this->createdAt())) {
$record[$this->createdAt()] = $fresh;
}
if ($this->hasPivotColumn($this->updatedAt())) {
$record[$this->updatedAt()] = $fresh;
}
return $record;
}
/**
* Determine whether the given column is defined as a pivot column.
*
* @param string $column
* @return bool
*/
public function hasPivotColumn($column)
{
return in_array($column, $this->pivotColumns);
}
/**
* Detach models from the relationship.
*
* @param mixed $ids
* @param bool $touch
* @return int
*/
public function detach($ids = null, $touch = true)
{
if ($this->using &&
! empty($ids) &&
empty($this->pivotWheres) &&
empty($this->pivotWhereIns) &&
empty($this->pivotWhereNulls)) {
$results = $this->detachUsingCustomClass($ids);
} else {
$query = $this->newPivotQuery();
// If associated IDs were passed to the method we will only delete those
// associations, otherwise all of the association ties will be broken.
// We'll return the numbers of affected rows when we do the deletes.
if (! is_null($ids)) {
$ids = $this->parseIds($ids);
if (empty($ids)) {
return 0;
}
$query->whereIn($this->getQualifiedRelatedPivotKeyName(), (array) $ids);
}
// Once we have all of the conditions set on the statement, we are ready
// to run the delete on the pivot table. Then, if the touch parameter
// is true, we will go ahead and touch all related models to sync.
$results = $query->delete();
}
if ($touch) {
$this->touchIfTouching();
}
return $results;
}
/**
* Detach models from the relationship using a custom class.
*
* @param mixed $ids
* @return int
*/
protected function detachUsingCustomClass($ids)
{
$results = 0;
foreach ($this->parseIds($ids) as $id) {
$results += $this->newPivot([
$this->foreignPivotKey => $this->parent->{$this->parentKey},
$this->relatedPivotKey => $id,
], true)->delete();
}
return $results;
}
/**
* Get the pivot models that are currently attached.
*
* @return \Illuminate\Support\Collection
*/
protected function getCurrentlyAttachedPivots()
{
return $this->newPivotQuery()->get()->map(function ($record) {
$class = $this->using ?: Pivot::class;
$pivot = $class::fromRawAttributes($this->parent, (array) $record, $this->getTable(), true);
return $pivot->setPivotKeys($this->foreignPivotKey, $this->relatedPivotKey);
});
}
/**
* Create a new pivot model instance.
*
* @param array $attributes
* @param bool $exists
* @return \Illuminate\Database\Eloquent\Relations\Pivot
*/
public function newPivot(array $attributes = [], $exists = false)
{
$attributes = array_merge(array_column($this->pivotValues, 'value', 'column'), $attributes);
$pivot = $this->related->newPivot(
$this->parent, $attributes, $this->table, $exists, $this->using
);
return $pivot->setPivotKeys($this->foreignPivotKey, $this->relatedPivotKey);
}
/**
* Create a new existing pivot model instance.
*
* @param array $attributes
* @return \Illuminate\Database\Eloquent\Relations\Pivot
*/
public function newExistingPivot(array $attributes = [])
{
return $this->newPivot($attributes, true);
}
/**
* Get a new plain query builder for the pivot table.
*
* @return \Illuminate\Database\Query\Builder
*/
public function newPivotStatement()
{
return $this->query->getQuery()->newQuery()->from($this->table);
}
/**
* Get a new pivot statement for a given "other" ID.
*
* @param mixed $id
* @return \Illuminate\Database\Query\Builder
*/
public function newPivotStatementForId($id)
{
return $this->newPivotQuery()->whereIn($this->relatedPivotKey, $this->parseIds($id));
}
/**
* Create a new query builder for the pivot table.
*
* @return \Illuminate\Database\Query\Builder
*/
public function newPivotQuery()
{
$query = $this->newPivotStatement();
foreach ($this->pivotWheres as $arguments) {
$query->where(...$arguments);
}
foreach ($this->pivotWhereIns as $arguments) {
$query->whereIn(...$arguments);
}
foreach ($this->pivotWhereNulls as $arguments) {
$query->whereNull(...$arguments);
}
return $query->where($this->getQualifiedForeignPivotKeyName(), $this->parent->{$this->parentKey});
}
/**
* Set the columns on the pivot table to retrieve.
*
* @param array|mixed $columns
* @return $this
*/
public function withPivot($columns)
{
$this->pivotColumns = array_merge(
$this->pivotColumns, is_array($columns) ? $columns : func_get_args()
);
return $this;
}
/**
* Get all of the IDs from the given mixed value.
*
* @param mixed $value
* @return array
*/
protected function parseIds($value)
{
if ($value instanceof Model) {
return [$value->{$this->relatedKey}];
}
if ($value instanceof Collection) {
return $value->pluck($this->relatedKey)->all();
}
if ($value instanceof BaseCollection) {
return $value->toArray();
}
return (array) $value;
}
/**
* Get the ID from the given mixed value.
*
* @param mixed $value
* @return mixed
*/
protected function parseId($value)
{
return $value instanceof Model ? $value->{$this->relatedKey} : $value;
}
/**
* Cast the given keys to integers if they are numeric and string otherwise.
*
* @param array $keys
* @return array
*/
protected function castKeys(array $keys)
{
return array_map(function ($v) {
return $this->castKey($v);
}, $keys);
}
/**
* Cast the given key to convert to primary key type.
*
* @param mixed $key
* @return mixed
*/
protected function castKey($key)
{
return $this->getTypeSwapValue(
$this->related->getKeyType(),
$key
);
}
/**
* Cast the given pivot attributes.
*
* @param array $attributes
* @return array
*/
protected function castAttributes($attributes)
{
return $this->using
? $this->newPivot()->fill($attributes)->getAttributes()
: $attributes;
}
/**
* Converts a given value to a given type value.
*
* @param string $type
* @param mixed $value
* @return mixed
*/
protected function getTypeSwapValue($type, $value)
{
return match (strtolower($type)) {
'int', 'integer' => (int) $value,
'real', 'float', 'double' => (float) $value,
'string' => (string) $value,
default => $value,
};
}
}