InteractsWithPivotTable.php 15 KB

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