InteractsWithPivotTable.php 16 KB

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