InteractsWithPivotTable.php 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688
  1. <?php
  2. namespace Illuminate\Database\Eloquent\Relations\Concerns;
  3. use BackedEnum;
  4. use Illuminate\Database\Eloquent\Collection;
  5. use Illuminate\Database\Eloquent\Model;
  6. use Illuminate\Database\Eloquent\Relations\Pivot;
  7. use Illuminate\Support\Collection as BaseCollection;
  8. trait InteractsWithPivotTable
  9. {
  10. /**
  11. * Toggles a model (or models) from the parent.
  12. *
  13. * Each existing model is detached, and non existing ones are attached.
  14. *
  15. * @param mixed $ids
  16. * @param bool $touch
  17. * @return array
  18. */
  19. public function toggle($ids, $touch = true)
  20. {
  21. $changes = [
  22. 'attached' => [], 'detached' => [],
  23. ];
  24. $records = $this->formatRecordsList($this->parseIds($ids));
  25. // Next, we will determine which IDs should get removed from the join table by
  26. // checking which of the given ID/records is in the list of current records
  27. // and removing all of those rows from this "intermediate" joining table.
  28. $detach = array_values(array_intersect(
  29. $this->newPivotQuery()->pluck($this->relatedPivotKey)->all(),
  30. array_keys($records)
  31. ));
  32. if (count($detach) > 0) {
  33. $this->detach($detach, false);
  34. $changes['detached'] = $this->castKeys($detach);
  35. }
  36. // Finally, for all of the records which were not "detached", we'll attach the
  37. // records into the intermediate table. Then, we will add those attaches to
  38. // this change list and get ready to return these results to the callers.
  39. $attach = array_diff_key($records, array_flip($detach));
  40. if (count($attach) > 0) {
  41. $this->attach($attach, [], false);
  42. $changes['attached'] = array_keys($attach);
  43. }
  44. // Once we have finished attaching or detaching the records, we will see if we
  45. // have done any attaching or detaching, and if we have we will touch these
  46. // relationships if they are configured to touch on any database updates.
  47. if ($touch && (count($changes['attached']) ||
  48. count($changes['detached']))) {
  49. $this->touchIfTouching();
  50. }
  51. return $changes;
  52. }
  53. /**
  54. * Sync the intermediate tables with a list of IDs without detaching.
  55. *
  56. * @param \Illuminate\Support\Collection|\Illuminate\Database\Eloquent\Model|array $ids
  57. * @return array
  58. */
  59. public function syncWithoutDetaching($ids)
  60. {
  61. return $this->sync($ids, false);
  62. }
  63. /**
  64. * Sync the intermediate tables with a list of IDs or collection of models.
  65. *
  66. * @param \Illuminate\Support\Collection|\Illuminate\Database\Eloquent\Model|array $ids
  67. * @param bool $detaching
  68. * @return array
  69. */
  70. public function sync($ids, $detaching = true)
  71. {
  72. $changes = [
  73. 'attached' => [], 'detached' => [], 'updated' => [],
  74. ];
  75. // First we need to attach any of the associated models that are not currently
  76. // in this joining table. We'll spin through the given IDs, checking to see
  77. // if they exist in the array of current ones, and if not we will insert.
  78. $current = $this->getCurrentlyAttachedPivots()
  79. ->pluck($this->relatedPivotKey)->all();
  80. $records = $this->formatRecordsList($this->parseIds($ids));
  81. // Next, we will take the differences of the currents and given IDs and detach
  82. // all of the entities that exist in the "current" array but are not in the
  83. // array of the new IDs given to the method which will complete the sync.
  84. if ($detaching) {
  85. $detach = array_diff($current, array_keys($records));
  86. if (count($detach) > 0) {
  87. $this->detach($detach, false);
  88. $changes['detached'] = $this->castKeys($detach);
  89. }
  90. }
  91. // Now we are finally ready to attach the new records. Note that we'll disable
  92. // touching until after the entire operation is complete so we don't fire a
  93. // ton of touch operations until we are totally done syncing the records.
  94. $changes = array_merge(
  95. $changes, $this->attachNew($records, $current, false)
  96. );
  97. // Once we have finished attaching or detaching the records, we will see if we
  98. // have done any attaching or detaching, and if we have we will touch these
  99. // relationships if they are configured to touch on any database updates.
  100. if (count($changes['attached']) ||
  101. count($changes['updated']) ||
  102. count($changes['detached'])) {
  103. $this->touchIfTouching();
  104. }
  105. return $changes;
  106. }
  107. /**
  108. * Sync the intermediate tables with a list of IDs or collection of models with the given pivot values.
  109. *
  110. * @param \Illuminate\Support\Collection|\Illuminate\Database\Eloquent\Model|array $ids
  111. * @param array $values
  112. * @param bool $detaching
  113. * @return array
  114. */
  115. public function syncWithPivotValues($ids, array $values, bool $detaching = true)
  116. {
  117. return $this->sync(collect($this->parseIds($ids))->mapWithKeys(function ($id) use ($values) {
  118. return [$id => $values];
  119. }), $detaching);
  120. }
  121. /**
  122. * Format the sync / toggle record list so that it is keyed by ID.
  123. *
  124. * @param array $records
  125. * @return array
  126. */
  127. protected function formatRecordsList(array $records)
  128. {
  129. return collect($records)->mapWithKeys(function ($attributes, $id) {
  130. if (! is_array($attributes)) {
  131. [$id, $attributes] = [$attributes, []];
  132. }
  133. if ($id instanceof BackedEnum) {
  134. $id = $id->value;
  135. }
  136. return [$id => $attributes];
  137. })->all();
  138. }
  139. /**
  140. * Attach all of the records that aren't in the given current records.
  141. *
  142. * @param array $records
  143. * @param array $current
  144. * @param bool $touch
  145. * @return array
  146. */
  147. protected function attachNew(array $records, array $current, $touch = true)
  148. {
  149. $changes = ['attached' => [], 'updated' => []];
  150. foreach ($records as $id => $attributes) {
  151. // If the ID is not in the list of existing pivot IDs, we will insert a new pivot
  152. // record, otherwise, we will just update this existing record on this joining
  153. // table, so that the developers will easily update these records pain free.
  154. if (! in_array($id, $current)) {
  155. $this->attach($id, $attributes, $touch);
  156. $changes['attached'][] = $this->castKey($id);
  157. }
  158. // Now we'll try to update an existing pivot record with the attributes that were
  159. // given to the method. If the model is actually updated we will add it to the
  160. // list of updated pivot records so we return them back out to the consumer.
  161. elseif (count($attributes) > 0 &&
  162. $this->updateExistingPivot($id, $attributes, $touch)) {
  163. $changes['updated'][] = $this->castKey($id);
  164. }
  165. }
  166. return $changes;
  167. }
  168. /**
  169. * Update an existing pivot record on the table.
  170. *
  171. * @param mixed $id
  172. * @param array $attributes
  173. * @param bool $touch
  174. * @return int
  175. */
  176. public function updateExistingPivot($id, array $attributes, $touch = true)
  177. {
  178. if ($this->using &&
  179. empty($this->pivotWheres) &&
  180. empty($this->pivotWhereIns) &&
  181. empty($this->pivotWhereNulls)) {
  182. return $this->updateExistingPivotUsingCustomClass($id, $attributes, $touch);
  183. }
  184. if ($this->hasPivotColumn($this->updatedAt())) {
  185. $attributes = $this->addTimestampsToAttachment($attributes, true);
  186. }
  187. $updated = $this->newPivotStatementForId($this->parseId($id))->update(
  188. $this->castAttributes($attributes)
  189. );
  190. if ($touch) {
  191. $this->touchIfTouching();
  192. }
  193. return $updated;
  194. }
  195. /**
  196. * Update an existing pivot record on the table via a custom class.
  197. *
  198. * @param mixed $id
  199. * @param array $attributes
  200. * @param bool $touch
  201. * @return int
  202. */
  203. protected function updateExistingPivotUsingCustomClass($id, array $attributes, $touch)
  204. {
  205. $pivot = $this->getCurrentlyAttachedPivots()
  206. ->where($this->foreignPivotKey, $this->parent->{$this->parentKey})
  207. ->where($this->relatedPivotKey, $this->parseId($id))
  208. ->first();
  209. $updated = $pivot ? $pivot->fill($attributes)->isDirty() : false;
  210. if ($updated) {
  211. $pivot->save();
  212. }
  213. if ($touch) {
  214. $this->touchIfTouching();
  215. }
  216. return (int) $updated;
  217. }
  218. /**
  219. * Attach a model to the parent.
  220. *
  221. * @param mixed $id
  222. * @param array $attributes
  223. * @param bool $touch
  224. * @return void
  225. */
  226. public function attach($id, array $attributes = [], $touch = true)
  227. {
  228. if ($this->using) {
  229. $this->attachUsingCustomClass($id, $attributes);
  230. } else {
  231. // Here we will insert the attachment records into the pivot table. Once we have
  232. // inserted the records, we will touch the relationships if necessary and the
  233. // function will return. We can parse the IDs before inserting the records.
  234. $this->newPivotStatement()->insert($this->formatAttachRecords(
  235. $this->parseIds($id), $attributes
  236. ));
  237. }
  238. if ($touch) {
  239. $this->touchIfTouching();
  240. }
  241. }
  242. /**
  243. * Attach a model to the parent using a custom class.
  244. *
  245. * @param mixed $id
  246. * @param array $attributes
  247. * @return void
  248. */
  249. protected function attachUsingCustomClass($id, array $attributes)
  250. {
  251. $records = $this->formatAttachRecords(
  252. $this->parseIds($id), $attributes
  253. );
  254. foreach ($records as $record) {
  255. $this->newPivot($record, false)->save();
  256. }
  257. }
  258. /**
  259. * Create an array of records to insert into the pivot table.
  260. *
  261. * @param array $ids
  262. * @param array $attributes
  263. * @return array
  264. */
  265. protected function formatAttachRecords($ids, array $attributes)
  266. {
  267. $records = [];
  268. $hasTimestamps = ($this->hasPivotColumn($this->createdAt()) ||
  269. $this->hasPivotColumn($this->updatedAt()));
  270. // To create the attachment records, we will simply spin through the IDs given
  271. // and create a new record to insert for each ID. Each ID may actually be a
  272. // key in the array, with extra attributes to be placed in other columns.
  273. foreach ($ids as $key => $value) {
  274. $records[] = $this->formatAttachRecord(
  275. $key, $value, $attributes, $hasTimestamps
  276. );
  277. }
  278. return $records;
  279. }
  280. /**
  281. * Create a full attachment record payload.
  282. *
  283. * @param int $key
  284. * @param mixed $value
  285. * @param array $attributes
  286. * @param bool $hasTimestamps
  287. * @return array
  288. */
  289. protected function formatAttachRecord($key, $value, $attributes, $hasTimestamps)
  290. {
  291. [$id, $attributes] = $this->extractAttachIdAndAttributes($key, $value, $attributes);
  292. return array_merge(
  293. $this->baseAttachRecord($id, $hasTimestamps), $this->castAttributes($attributes)
  294. );
  295. }
  296. /**
  297. * Get the attach record ID and extra attributes.
  298. *
  299. * @param mixed $key
  300. * @param mixed $value
  301. * @param array $attributes
  302. * @return array
  303. */
  304. protected function extractAttachIdAndAttributes($key, $value, array $attributes)
  305. {
  306. return is_array($value)
  307. ? [$key, array_merge($value, $attributes)]
  308. : [$value, $attributes];
  309. }
  310. /**
  311. * Create a new pivot attachment record.
  312. *
  313. * @param int $id
  314. * @param bool $timed
  315. * @return array
  316. */
  317. protected function baseAttachRecord($id, $timed)
  318. {
  319. $record[$this->relatedPivotKey] = $id;
  320. $record[$this->foreignPivotKey] = $this->parent->{$this->parentKey};
  321. // If the record needs to have creation and update timestamps, we will make
  322. // them by calling the parent model's "freshTimestamp" method which will
  323. // provide us with a fresh timestamp in this model's preferred format.
  324. if ($timed) {
  325. $record = $this->addTimestampsToAttachment($record);
  326. }
  327. foreach ($this->pivotValues as $value) {
  328. $record[$value['column']] = $value['value'];
  329. }
  330. return $record;
  331. }
  332. /**
  333. * Set the creation and update timestamps on an attach record.
  334. *
  335. * @param array $record
  336. * @param bool $exists
  337. * @return array
  338. */
  339. protected function addTimestampsToAttachment(array $record, $exists = false)
  340. {
  341. $fresh = $this->parent->freshTimestamp();
  342. if ($this->using) {
  343. $pivotModel = new $this->using;
  344. $fresh = $pivotModel->fromDateTime($fresh);
  345. }
  346. if (! $exists && $this->hasPivotColumn($this->createdAt())) {
  347. $record[$this->createdAt()] = $fresh;
  348. }
  349. if ($this->hasPivotColumn($this->updatedAt())) {
  350. $record[$this->updatedAt()] = $fresh;
  351. }
  352. return $record;
  353. }
  354. /**
  355. * Determine whether the given column is defined as a pivot column.
  356. *
  357. * @param string $column
  358. * @return bool
  359. */
  360. public function hasPivotColumn($column)
  361. {
  362. return in_array($column, $this->pivotColumns);
  363. }
  364. /**
  365. * Detach models from the relationship.
  366. *
  367. * @param mixed $ids
  368. * @param bool $touch
  369. * @return int
  370. */
  371. public function detach($ids = null, $touch = true)
  372. {
  373. if ($this->using &&
  374. ! empty($ids) &&
  375. empty($this->pivotWheres) &&
  376. empty($this->pivotWhereIns) &&
  377. empty($this->pivotWhereNulls)) {
  378. $results = $this->detachUsingCustomClass($ids);
  379. } else {
  380. $query = $this->newPivotQuery();
  381. // If associated IDs were passed to the method we will only delete those
  382. // associations, otherwise all of the association ties will be broken.
  383. // We'll return the numbers of affected rows when we do the deletes.
  384. if (! is_null($ids)) {
  385. $ids = $this->parseIds($ids);
  386. if (empty($ids)) {
  387. return 0;
  388. }
  389. $query->whereIn($this->getQualifiedRelatedPivotKeyName(), (array) $ids);
  390. }
  391. // Once we have all of the conditions set on the statement, we are ready
  392. // to run the delete on the pivot table. Then, if the touch parameter
  393. // is true, we will go ahead and touch all related models to sync.
  394. $results = $query->delete();
  395. }
  396. if ($touch) {
  397. $this->touchIfTouching();
  398. }
  399. return $results;
  400. }
  401. /**
  402. * Detach models from the relationship using a custom class.
  403. *
  404. * @param mixed $ids
  405. * @return int
  406. */
  407. protected function detachUsingCustomClass($ids)
  408. {
  409. $results = 0;
  410. foreach ($this->parseIds($ids) as $id) {
  411. $results += $this->newPivot([
  412. $this->foreignPivotKey => $this->parent->{$this->parentKey},
  413. $this->relatedPivotKey => $id,
  414. ], true)->delete();
  415. }
  416. return $results;
  417. }
  418. /**
  419. * Get the pivot models that are currently attached.
  420. *
  421. * @return \Illuminate\Support\Collection
  422. */
  423. protected function getCurrentlyAttachedPivots()
  424. {
  425. return $this->newPivotQuery()->get()->map(function ($record) {
  426. $class = $this->using ?: Pivot::class;
  427. $pivot = $class::fromRawAttributes($this->parent, (array) $record, $this->getTable(), true);
  428. return $pivot->setPivotKeys($this->foreignPivotKey, $this->relatedPivotKey);
  429. });
  430. }
  431. /**
  432. * Create a new pivot model instance.
  433. *
  434. * @param array $attributes
  435. * @param bool $exists
  436. * @return \Illuminate\Database\Eloquent\Relations\Pivot
  437. */
  438. public function newPivot(array $attributes = [], $exists = false)
  439. {
  440. $attributes = array_merge(array_column($this->pivotValues, 'value', 'column'), $attributes);
  441. $pivot = $this->related->newPivot(
  442. $this->parent, $attributes, $this->table, $exists, $this->using
  443. );
  444. return $pivot->setPivotKeys($this->foreignPivotKey, $this->relatedPivotKey);
  445. }
  446. /**
  447. * Create a new existing pivot model instance.
  448. *
  449. * @param array $attributes
  450. * @return \Illuminate\Database\Eloquent\Relations\Pivot
  451. */
  452. public function newExistingPivot(array $attributes = [])
  453. {
  454. return $this->newPivot($attributes, true);
  455. }
  456. /**
  457. * Get a new plain query builder for the pivot table.
  458. *
  459. * @return \Illuminate\Database\Query\Builder
  460. */
  461. public function newPivotStatement()
  462. {
  463. return $this->query->getQuery()->newQuery()->from($this->table);
  464. }
  465. /**
  466. * Get a new pivot statement for a given "other" ID.
  467. *
  468. * @param mixed $id
  469. * @return \Illuminate\Database\Query\Builder
  470. */
  471. public function newPivotStatementForId($id)
  472. {
  473. return $this->newPivotQuery()->whereIn($this->relatedPivotKey, $this->parseIds($id));
  474. }
  475. /**
  476. * Create a new query builder for the pivot table.
  477. *
  478. * @return \Illuminate\Database\Query\Builder
  479. */
  480. public function newPivotQuery()
  481. {
  482. $query = $this->newPivotStatement();
  483. foreach ($this->pivotWheres as $arguments) {
  484. $query->where(...$arguments);
  485. }
  486. foreach ($this->pivotWhereIns as $arguments) {
  487. $query->whereIn(...$arguments);
  488. }
  489. foreach ($this->pivotWhereNulls as $arguments) {
  490. $query->whereNull(...$arguments);
  491. }
  492. return $query->where($this->getQualifiedForeignPivotKeyName(), $this->parent->{$this->parentKey});
  493. }
  494. /**
  495. * Set the columns on the pivot table to retrieve.
  496. *
  497. * @param array|mixed $columns
  498. * @return $this
  499. */
  500. public function withPivot($columns)
  501. {
  502. $this->pivotColumns = array_merge(
  503. $this->pivotColumns, is_array($columns) ? $columns : func_get_args()
  504. );
  505. return $this;
  506. }
  507. /**
  508. * Get all of the IDs from the given mixed value.
  509. *
  510. * @param mixed $value
  511. * @return array
  512. */
  513. protected function parseIds($value)
  514. {
  515. if ($value instanceof Model) {
  516. return [$value->{$this->relatedKey}];
  517. }
  518. if ($value instanceof Collection) {
  519. return $value->pluck($this->relatedKey)->all();
  520. }
  521. if ($value instanceof BaseCollection) {
  522. return $value->toArray();
  523. }
  524. return (array) $value;
  525. }
  526. /**
  527. * Get the ID from the given mixed value.
  528. *
  529. * @param mixed $value
  530. * @return mixed
  531. */
  532. protected function parseId($value)
  533. {
  534. return $value instanceof Model ? $value->{$this->relatedKey} : $value;
  535. }
  536. /**
  537. * Cast the given keys to integers if they are numeric and string otherwise.
  538. *
  539. * @param array $keys
  540. * @return array
  541. */
  542. protected function castKeys(array $keys)
  543. {
  544. return array_map(function ($v) {
  545. return $this->castKey($v);
  546. }, $keys);
  547. }
  548. /**
  549. * Cast the given key to convert to primary key type.
  550. *
  551. * @param mixed $key
  552. * @return mixed
  553. */
  554. protected function castKey($key)
  555. {
  556. return $this->getTypeSwapValue(
  557. $this->related->getKeyType(),
  558. $key
  559. );
  560. }
  561. /**
  562. * Cast the given pivot attributes.
  563. *
  564. * @param array $attributes
  565. * @return array
  566. */
  567. protected function castAttributes($attributes)
  568. {
  569. return $this->using
  570. ? $this->newPivot()->fill($attributes)->getAttributes()
  571. : $attributes;
  572. }
  573. /**
  574. * Converts a given value to a given type value.
  575. *
  576. * @param string $type
  577. * @param mixed $value
  578. * @return mixed
  579. */
  580. protected function getTypeSwapValue($type, $value)
  581. {
  582. return match (strtolower($type)) {
  583. 'int', 'integer' => (int) $value,
  584. 'real', 'float', 'double' => (float) $value,
  585. 'string' => (string) $value,
  586. default => $value,
  587. };
  588. }
  589. }