InteractsWithPivotTable.php 16KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539
  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($this->parseId($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. foreach ($this->pivotValues as $value) {
  259. $record[$value['column']] = $value['value'];
  260. }
  261. return $record;
  262. }
  263. /**
  264. * Set the creation and update timestamps on an attach record.
  265. *
  266. * @param array $record
  267. * @param bool $exists
  268. * @return array
  269. */
  270. protected function addTimestampsToAttachment(array $record, $exists = false)
  271. {
  272. $fresh = $this->parent->freshTimestamp();
  273. if ($this->using) {
  274. $pivotModel = new $this->using;
  275. $fresh = $fresh->format($pivotModel->getDateFormat());
  276. }
  277. if (! $exists && $this->hasPivotColumn($this->createdAt())) {
  278. $record[$this->createdAt()] = $fresh;
  279. }
  280. if ($this->hasPivotColumn($this->updatedAt())) {
  281. $record[$this->updatedAt()] = $fresh;
  282. }
  283. return $record;
  284. }
  285. /**
  286. * Determine whether the given column is defined as a pivot column.
  287. *
  288. * @param string $column
  289. * @return bool
  290. */
  291. protected function hasPivotColumn($column)
  292. {
  293. return in_array($column, $this->pivotColumns);
  294. }
  295. /**
  296. * Detach models from the relationship.
  297. *
  298. * @param mixed $ids
  299. * @param bool $touch
  300. * @return int
  301. */
  302. public function detach($ids = null, $touch = true)
  303. {
  304. $query = $this->newPivotQuery();
  305. // If associated IDs were passed to the method we will only delete those
  306. // associations, otherwise all of the association ties will be broken.
  307. // We'll return the numbers of affected rows when we do the deletes.
  308. if (! is_null($ids)) {
  309. $ids = $this->parseIds($ids);
  310. if (empty($ids)) {
  311. return 0;
  312. }
  313. $query->whereIn($this->relatedPivotKey, (array) $ids);
  314. }
  315. // Once we have all of the conditions set on the statement, we are ready
  316. // to run the delete on the pivot table. Then, if the touch parameter
  317. // is true, we will go ahead and touch all related models to sync.
  318. $results = $query->delete();
  319. if ($touch) {
  320. $this->touchIfTouching();
  321. }
  322. return $results;
  323. }
  324. /**
  325. * Create a new pivot model instance.
  326. *
  327. * @param array $attributes
  328. * @param bool $exists
  329. * @return \Illuminate\Database\Eloquent\Relations\Pivot
  330. */
  331. public function newPivot(array $attributes = [], $exists = false)
  332. {
  333. $pivot = $this->related->newPivot(
  334. $this->parent, $attributes, $this->table, $exists, $this->using
  335. );
  336. return $pivot->setPivotKeys($this->foreignPivotKey, $this->relatedPivotKey);
  337. }
  338. /**
  339. * Create a new existing pivot model instance.
  340. *
  341. * @param array $attributes
  342. * @return \Illuminate\Database\Eloquent\Relations\Pivot
  343. */
  344. public function newExistingPivot(array $attributes = [])
  345. {
  346. return $this->newPivot($attributes, true);
  347. }
  348. /**
  349. * Get a new plain query builder for the pivot table.
  350. *
  351. * @return \Illuminate\Database\Query\Builder
  352. */
  353. public function newPivotStatement()
  354. {
  355. return $this->query->getQuery()->newQuery()->from($this->table);
  356. }
  357. /**
  358. * Get a new pivot statement for a given "other" ID.
  359. *
  360. * @param mixed $id
  361. * @return \Illuminate\Database\Query\Builder
  362. */
  363. public function newPivotStatementForId($id)
  364. {
  365. return $this->newPivotQuery()->where($this->relatedPivotKey, $id);
  366. }
  367. /**
  368. * Create a new query builder for the pivot table.
  369. *
  370. * @return \Illuminate\Database\Query\Builder
  371. */
  372. protected function newPivotQuery()
  373. {
  374. $query = $this->newPivotStatement();
  375. foreach ($this->pivotWheres as $arguments) {
  376. call_user_func_array([$query, 'where'], $arguments);
  377. }
  378. foreach ($this->pivotWhereIns as $arguments) {
  379. call_user_func_array([$query, 'whereIn'], $arguments);
  380. }
  381. return $query->where($this->foreignPivotKey, $this->parent->{$this->parentKey});
  382. }
  383. /**
  384. * Set the columns on the pivot table to retrieve.
  385. *
  386. * @param array|mixed $columns
  387. * @return $this
  388. */
  389. public function withPivot($columns)
  390. {
  391. $this->pivotColumns = array_merge(
  392. $this->pivotColumns, is_array($columns) ? $columns : func_get_args()
  393. );
  394. return $this;
  395. }
  396. /**
  397. * Get all of the IDs from the given mixed value.
  398. *
  399. * @param mixed $value
  400. * @return array
  401. */
  402. protected function parseIds($value)
  403. {
  404. if ($value instanceof Model) {
  405. return [$value->getKey()];
  406. }
  407. if ($value instanceof Collection) {
  408. return $value->modelKeys();
  409. }
  410. if ($value instanceof BaseCollection) {
  411. return $value->toArray();
  412. }
  413. return (array) $value;
  414. }
  415. /**
  416. * Get the ID from the given mixed value.
  417. *
  418. * @param mixed $value
  419. * @return mixed
  420. */
  421. protected function parseId($value)
  422. {
  423. return $value instanceof Model ? $value->getKey() : $value;
  424. }
  425. /**
  426. * Cast the given keys to integers if they are numeric and string otherwise.
  427. *
  428. * @param array $keys
  429. * @return array
  430. */
  431. protected function castKeys(array $keys)
  432. {
  433. return (array) array_map(function ($v) {
  434. return $this->castKey($v);
  435. }, $keys);
  436. }
  437. /**
  438. * Cast the given key to an integer if it is numeric.
  439. *
  440. * @param mixed $key
  441. * @return mixed
  442. */
  443. protected function castKey($key)
  444. {
  445. return is_numeric($key) ? (int) $key : (string) $key;
  446. }
  447. /**
  448. * Cast the given pivot attributes.
  449. *
  450. * @param array $attributes
  451. * @return array
  452. */
  453. protected function castAttributes($attributes)
  454. {
  455. return $this->using
  456. ? $this->newPivot()->fill($attributes)->getAttributes()
  457. : $attributes;
  458. }
  459. }