/src/ORM/Association/BelongsToMany.php

https://github.com/binondord/cakephp · PHP · 1045 lines · 539 code · 114 blank · 392 comment · 68 complexity · 01a7ffd5961b889503d52fd1b5dc7282 MD5 · raw file

  1. <?php
  2. /**
  3. * CakePHP(tm) : Rapid Development Framework (http://cakephp.org)
  4. * Copyright (c) Cake Software Foundation, Inc. (http://cakefoundation.org)
  5. *
  6. * Licensed under The MIT License
  7. * For full copyright and license information, please see the LICENSE.txt
  8. * Redistributions of files must retain the above copyright notice.
  9. *
  10. * @copyright Copyright (c) Cake Software Foundation, Inc. (http://cakefoundation.org)
  11. * @link http://cakephp.org CakePHP(tm) Project
  12. * @since 3.0.0
  13. * @license http://www.opensource.org/licenses/mit-license.php MIT License
  14. */
  15. namespace Cake\ORM\Association;
  16. use Cake\Datasource\EntityInterface;
  17. use Cake\ORM\Association;
  18. use Cake\ORM\Query;
  19. use Cake\ORM\Table;
  20. use Cake\ORM\TableRegistry;
  21. use Cake\Utility\Inflector;
  22. use InvalidArgumentException;
  23. use RuntimeException;
  24. /**
  25. * Represents an M - N relationship where there exists a junction - or join - table
  26. * that contains the association fields between the source and the target table.
  27. *
  28. * An example of a BelongsToMany association would be Article belongs to many Tags.
  29. */
  30. class BelongsToMany extends Association
  31. {
  32. use ExternalAssociationTrait {
  33. _options as _externalOptions;
  34. _buildQuery as _buildBaseQuery;
  35. }
  36. /**
  37. * Saving strategy that will only append to the links set
  38. *
  39. * @var string
  40. */
  41. const SAVE_APPEND = 'append';
  42. /**
  43. * Saving strategy that will replace the links with the provided set
  44. *
  45. * @var string
  46. */
  47. const SAVE_REPLACE = 'replace';
  48. /**
  49. * The type of join to be used when adding the association to a query
  50. *
  51. * @var string
  52. */
  53. protected $_joinType = 'INNER';
  54. /**
  55. * The strategy name to be used to fetch associated records.
  56. *
  57. * @var string
  58. */
  59. protected $_strategy = self::STRATEGY_SELECT;
  60. /**
  61. * Junction table instance
  62. *
  63. * @var \Cake\ORM\Table
  64. */
  65. protected $_junctionTable;
  66. /**
  67. * Junction table name
  68. *
  69. * @var string
  70. */
  71. protected $_junctionTableName;
  72. /**
  73. * The name of the hasMany association from the target table
  74. * to the junction table
  75. *
  76. * @var string
  77. */
  78. protected $_junctionAssociationName;
  79. /**
  80. * The name of the property to be set containing data from the junction table
  81. * once a record from the target table is hydrated
  82. *
  83. * @var string
  84. */
  85. protected $_junctionProperty = '_joinData';
  86. /**
  87. * Saving strategy to be used by this association
  88. *
  89. * @var string
  90. */
  91. protected $_saveStrategy = self::SAVE_REPLACE;
  92. /**
  93. * The name of the field representing the foreign key to the target table
  94. *
  95. * @var string|array
  96. */
  97. protected $_targetForeignKey;
  98. /**
  99. * The table instance for the junction relation.
  100. *
  101. * @var string|\Cake\ORM\Table
  102. */
  103. protected $_through;
  104. /**
  105. * Valid strategies for this type of association
  106. *
  107. * @var array
  108. */
  109. protected $_validStrategies = [self::STRATEGY_SELECT, self::STRATEGY_SUBQUERY];
  110. /**
  111. * Whether the records on the joint table should be removed when a record
  112. * on the source table is deleted.
  113. *
  114. * Defaults to true for backwards compatibility.
  115. *
  116. * @var bool
  117. */
  118. protected $_dependent = true;
  119. /**
  120. * Sets the name of the field representing the foreign key to the target table.
  121. * If no parameters are passed current field is returned
  122. *
  123. * @param string|null $key the key to be used to link both tables together
  124. * @return string
  125. */
  126. public function targetForeignKey($key = null)
  127. {
  128. if ($key === null) {
  129. if ($this->_targetForeignKey === null) {
  130. $this->_targetForeignKey = $this->_modelKey($this->target()->alias());
  131. }
  132. return $this->_targetForeignKey;
  133. }
  134. return $this->_targetForeignKey = $key;
  135. }
  136. /**
  137. * Sets the table instance for the junction relation. If no arguments
  138. * are passed, the current configured table instance is returned
  139. *
  140. * @param string|\Cake\ORM\Table|null $table Name or instance for the join table
  141. * @return \Cake\ORM\Table
  142. */
  143. public function junction($table = null)
  144. {
  145. $target = $this->target();
  146. $source = $this->source();
  147. $sAlias = $source->alias();
  148. $tAlias = $target->alias();
  149. if ($table === null) {
  150. if (!empty($this->_junctionTable)) {
  151. return $this->_junctionTable;
  152. }
  153. if (!empty($this->_through)) {
  154. $table = $this->_through;
  155. } else {
  156. $tableName = $this->_junctionTableName();
  157. $tableAlias = Inflector::camelize($tableName);
  158. $config = [];
  159. if (!TableRegistry::exists($tableAlias)) {
  160. $config = ['table' => $tableName];
  161. }
  162. $table = TableRegistry::get($tableAlias, $config);
  163. }
  164. }
  165. if (is_string($table)) {
  166. $table = TableRegistry::get($table);
  167. }
  168. $junctionAlias = $table->alias();
  169. if (!$table->association($sAlias)) {
  170. $table
  171. ->belongsTo($sAlias, ['foreignKey' => $this->foreignKey()])
  172. ->target($source);
  173. }
  174. if (!$table->association($tAlias)) {
  175. $table
  176. ->belongsTo($tAlias, ['foreignKey' => $this->targetForeignKey()])
  177. ->target($target);
  178. }
  179. if (!$target->association($junctionAlias)) {
  180. $target->hasMany($junctionAlias, [
  181. 'targetTable' => $table,
  182. 'foreignKey' => $this->targetForeignKey(),
  183. ]);
  184. }
  185. if (!$target->association($sAlias)) {
  186. $target->belongsToMany($sAlias, [
  187. 'sourceTable' => $target,
  188. 'targetTable' => $source,
  189. 'foreignKey' => $this->targetForeignKey(),
  190. 'targetForeignKey' => $this->foreignKey(),
  191. 'through' => $table
  192. ]);
  193. }
  194. if (!$source->association($table->alias())) {
  195. $source->hasMany($junctionAlias)->target($table);
  196. }
  197. return $this->_junctionTable = $table;
  198. }
  199. /**
  200. * Alters a Query object to include the associated target table data in the final
  201. * result
  202. *
  203. * The options array accept the following keys:
  204. *
  205. * - includeFields: Whether to include target model fields in the result or not
  206. * - foreignKey: The name of the field to use as foreign key, if false none
  207. * will be used
  208. * - conditions: array with a list of conditions to filter the join with
  209. * - fields: a list of fields in the target table to include in the result
  210. * - type: The type of join to be used (e.g. INNER)
  211. *
  212. * @param Query $query the query to be altered to include the target table data
  213. * @param array $options Any extra options or overrides to be taken in account
  214. * @return void
  215. */
  216. public function attachTo(Query $query, array $options = [])
  217. {
  218. parent::attachTo($query, $options);
  219. $junction = $this->junction();
  220. $belongsTo = $junction->association($this->source()->alias());
  221. $cond = $belongsTo->_joinCondition(['foreignKey' => $belongsTo->foreignKey()]);
  222. if (isset($options['includeFields'])) {
  223. $includeFields = $options['includeFields'];
  224. }
  225. unset($options['queryBuilder']);
  226. $options = ['conditions' => [$cond]] + compact('includeFields');
  227. $options['foreignKey'] = $this->targetForeignKey();
  228. $assoc = $this->_targetTable->association($junction->alias());
  229. $assoc->attachTo($query, $options);
  230. $query->eagerLoader()->addToJoinsMap($junction->alias(), $assoc, true);
  231. }
  232. /**
  233. * {@inheritDoc}
  234. */
  235. public function transformRow($row, $nestKey, $joined)
  236. {
  237. $alias = $this->junction()->alias();
  238. if ($joined) {
  239. $row[$this->target()->alias()][$this->_junctionProperty] = $row[$alias];
  240. unset($row[$alias]);
  241. }
  242. return parent::transformRow($row, $nestKey, $joined);
  243. }
  244. /**
  245. * Get the relationship type.
  246. *
  247. * @return string
  248. */
  249. public function type()
  250. {
  251. return self::MANY_TO_MANY;
  252. }
  253. /**
  254. * Return false as join conditions are defined in the junction table
  255. *
  256. * @param array $options list of options passed to attachTo method
  257. * @return bool false
  258. */
  259. protected function _joinCondition($options)
  260. {
  261. return false;
  262. }
  263. /**
  264. * Builds an array containing the results from fetchQuery indexed by
  265. * the foreignKey value corresponding to this association.
  266. *
  267. * @param \Cake\ORM\Query $fetchQuery The query to get results from
  268. * @param array $options The options passed to the eager loader
  269. * @return array
  270. * @throws \RuntimeException when the association property is not part of the results set.
  271. */
  272. protected function _buildResultMap($fetchQuery, $options)
  273. {
  274. $resultMap = [];
  275. $key = (array)$options['foreignKey'];
  276. $property = $this->target()->association($this->junction()->alias())->property();
  277. $hydrated = $fetchQuery->hydrate();
  278. foreach ($fetchQuery->all() as $result) {
  279. if (!isset($result[$property])) {
  280. throw new RuntimeException(sprintf(
  281. '"%s" is missing from the belongsToMany results. Results cannot be created.',
  282. $property
  283. ));
  284. }
  285. $result[$this->_junctionProperty] = $result[$property];
  286. unset($result[$property]);
  287. if ($hydrated) {
  288. $result->dirty($this->_junctionProperty, false);
  289. }
  290. $values = [];
  291. foreach ($key as $k) {
  292. $values[] = $result[$this->_junctionProperty][$k];
  293. }
  294. $resultMap[implode(';', $values)][] = $result;
  295. }
  296. return $resultMap;
  297. }
  298. /**
  299. * Clear out the data in the junction table for a given entity.
  300. *
  301. * @param \Cake\Datasource\EntityInterface $entity The entity that started the cascading delete.
  302. * @param array $options The options for the original delete.
  303. * @return bool Success.
  304. */
  305. public function cascadeDelete(EntityInterface $entity, array $options = [])
  306. {
  307. if (!$this->dependent()) {
  308. return true;
  309. }
  310. $foreignKey = (array)$this->foreignKey();
  311. $bindingKey = (array)$this->bindingKey();
  312. $conditions = [];
  313. if ($bindingKey) {
  314. $conditions = array_combine($foreignKey, $entity->extract($bindingKey));
  315. }
  316. $table = $this->junction();
  317. $hasMany = $this->source()->association($table->alias());
  318. if ($this->_cascadeCallbacks) {
  319. foreach ($hasMany->find('all')->where($conditions) as $related) {
  320. $table->delete($related, $options);
  321. }
  322. return true;
  323. }
  324. $conditions = array_merge($conditions, $hasMany->conditions());
  325. return $table->deleteAll($conditions);
  326. }
  327. /**
  328. * Returns boolean true, as both of the tables 'own' rows in the other side
  329. * of the association via the joint table.
  330. *
  331. * @param \Cake\ORM\Table $side The potential Table with ownership
  332. * @return bool
  333. */
  334. public function isOwningSide(Table $side)
  335. {
  336. return true;
  337. }
  338. /**
  339. * Sets the strategy that should be used for saving. If called with no
  340. * arguments, it will return the currently configured strategy
  341. *
  342. * @param string|null $strategy the strategy name to be used
  343. * @throws \InvalidArgumentException if an invalid strategy name is passed
  344. * @return string the strategy to be used for saving
  345. */
  346. public function saveStrategy($strategy = null)
  347. {
  348. if ($strategy === null) {
  349. return $this->_saveStrategy;
  350. }
  351. if (!in_array($strategy, [self::SAVE_APPEND, self::SAVE_REPLACE])) {
  352. $msg = sprintf('Invalid save strategy "%s"', $strategy);
  353. throw new InvalidArgumentException($msg);
  354. }
  355. return $this->_saveStrategy = $strategy;
  356. }
  357. /**
  358. * Takes an entity from the source table and looks if there is a field
  359. * matching the property name for this association. The found entity will be
  360. * saved on the target table for this association by passing supplied
  361. * `$options`
  362. *
  363. * When using the 'append' strategy, this function will only create new links
  364. * between each side of this association. It will not destroy existing ones even
  365. * though they may not be present in the array of entities to be saved.
  366. *
  367. * When using the 'replace' strategy, existing links will be removed and new links
  368. * will be created in the joint table. If there exists links in the database to some
  369. * of the entities intended to be saved by this method, they will be updated,
  370. * not deleted.
  371. *
  372. * @param \Cake\Datasource\EntityInterface $entity an entity from the source table
  373. * @param array|\ArrayObject $options options to be passed to the save method in
  374. * the target table
  375. * @throws \InvalidArgumentException if the property representing the association
  376. * in the parent entity cannot be traversed
  377. * @return bool|\Cake\Datasource\EntityInterface false if $entity could not be saved, otherwise it returns
  378. * the saved entity
  379. * @see Table::save()
  380. * @see BelongsToMany::replaceLinks()
  381. */
  382. public function saveAssociated(EntityInterface $entity, array $options = [])
  383. {
  384. $targetEntity = $entity->get($this->property());
  385. $strategy = $this->saveStrategy();
  386. $isEmpty = in_array($targetEntity, [null, [], '', false], true);
  387. if ($isEmpty && $entity->isNew()) {
  388. return $entity;
  389. }
  390. if ($isEmpty) {
  391. $targetEntity = [];
  392. }
  393. if ($strategy === self::SAVE_APPEND) {
  394. return $this->_saveTarget($entity, $targetEntity, $options);
  395. }
  396. if ($this->replaceLinks($entity, $targetEntity, $options)) {
  397. return $entity;
  398. }
  399. return false;
  400. }
  401. /**
  402. * Persists each of the entities into the target table and creates links between
  403. * the parent entity and each one of the saved target entities.
  404. *
  405. * @param \Cake\Datasource\EntityInterface $parentEntity the source entity containing the target
  406. * entities to be saved.
  407. * @param array|\Traversable $entities list of entities to persist in target table and to
  408. * link to the parent entity
  409. * @param array $options list of options accepted by Table::save()
  410. * @throws \InvalidArgumentException if the property representing the association
  411. * in the parent entity cannot be traversed
  412. * @return \Cake\Datasource\EntityInterface|bool The parent entity after all links have been
  413. * created if no errors happened, false otherwise
  414. */
  415. protected function _saveTarget(EntityInterface $parentEntity, $entities, $options)
  416. {
  417. $joinAssociations = false;
  418. if (!empty($options['associated'][$this->_junctionProperty]['associated'])) {
  419. $joinAssociations = $options['associated'][$this->_junctionProperty]['associated'];
  420. }
  421. unset($options['associated'][$this->_junctionProperty]);
  422. if (!(is_array($entities) || $entities instanceof \Traversable)) {
  423. $name = $this->property();
  424. $message = sprintf('Could not save %s, it cannot be traversed', $name);
  425. throw new InvalidArgumentException($message);
  426. }
  427. $table = $this->target();
  428. $original = $entities;
  429. $persisted = [];
  430. foreach ($entities as $k => $entity) {
  431. if (!($entity instanceof EntityInterface)) {
  432. break;
  433. }
  434. if (!empty($options['atomic'])) {
  435. $entity = clone $entity;
  436. }
  437. if ($table->save($entity, $options)) {
  438. $entities[$k] = $entity;
  439. $persisted[] = $entity;
  440. continue;
  441. }
  442. if (!empty($options['atomic'])) {
  443. $original[$k]->errors($entity->errors());
  444. return false;
  445. }
  446. }
  447. $options['associated'] = $joinAssociations;
  448. $success = $this->_saveLinks($parentEntity, $persisted, $options);
  449. if (!$success && !empty($options['atomic'])) {
  450. $parentEntity->set($this->property(), $original);
  451. return false;
  452. }
  453. $parentEntity->set($this->property(), $entities);
  454. return $parentEntity;
  455. }
  456. /**
  457. * Creates links between the source entity and each of the passed target entities
  458. *
  459. * @param \Cake\Datasource\EntityInterface $sourceEntity the entity from source table in this
  460. * association
  461. * @param array $targetEntities list of entities to link to link to the source entity using the
  462. * junction table
  463. * @param array $options list of options accepted by Table::save()
  464. * @return bool success
  465. */
  466. protected function _saveLinks(EntityInterface $sourceEntity, $targetEntities, $options)
  467. {
  468. $target = $this->target();
  469. $junction = $this->junction();
  470. $source = $this->source();
  471. $entityClass = $junction->entityClass();
  472. $belongsTo = $junction->association($target->alias());
  473. $foreignKey = (array)$this->foreignKey();
  474. $assocForeignKey = (array)$belongsTo->foreignKey();
  475. $targetPrimaryKey = (array)$target->primaryKey();
  476. $bindingKey = (array)$this->bindingKey();
  477. $jointProperty = $this->_junctionProperty;
  478. $junctionAlias = $junction->alias();
  479. foreach ($targetEntities as $e) {
  480. $joint = $e->get($jointProperty);
  481. if (!$joint || !($joint instanceof EntityInterface)) {
  482. $joint = new $entityClass([], ['markNew' => true, 'source' => $junctionAlias]);
  483. }
  484. $joint->set(array_combine(
  485. $foreignKey,
  486. $sourceEntity->extract($bindingKey)
  487. ), ['guard' => false]);
  488. $joint->set(array_combine($assocForeignKey, $e->extract($targetPrimaryKey)), ['guard' => false]);
  489. $saved = $junction->save($joint, $options);
  490. if (!$saved && !empty($options['atomic'])) {
  491. return false;
  492. }
  493. $e->set($jointProperty, $joint);
  494. $e->dirty($jointProperty, false);
  495. }
  496. return true;
  497. }
  498. /**
  499. * Associates the source entity to each of the target entities provided by
  500. * creating links in the junction table. Both the source entity and each of
  501. * the target entities are assumed to be already persisted, if the are marked
  502. * as new or their status is unknown, an exception will be thrown.
  503. *
  504. * When using this method, all entities in `$targetEntities` will be appended to
  505. * the source entity's property corresponding to this association object.
  506. *
  507. * This method does not check link uniqueness.
  508. *
  509. * ### Example:
  510. *
  511. * ```
  512. * $newTags = $tags->find('relevant')->execute();
  513. * $articles->association('tags')->link($article, $newTags);
  514. * ```
  515. *
  516. * `$article->get('tags')` will contain all tags in `$newTags` after liking
  517. *
  518. * @param \Cake\Datasource\EntityInterface $sourceEntity the row belonging to the `source` side
  519. * of this association
  520. * @param array $targetEntities list of entities belonging to the `target` side
  521. * of this association
  522. * @param array $options list of options to be passed to the save method
  523. * @throws \InvalidArgumentException when any of the values in $targetEntities is
  524. * detected to not be already persisted
  525. * @return bool true on success, false otherwise
  526. */
  527. public function link(EntityInterface $sourceEntity, array $targetEntities, array $options = [])
  528. {
  529. $this->_checkPersistenceStatus($sourceEntity, $targetEntities);
  530. $property = $this->property();
  531. $links = $sourceEntity->get($property) ?: [];
  532. $links = array_merge($links, $targetEntities);
  533. $sourceEntity->set($property, $links);
  534. return $this->junction()->connection()->transactional(
  535. function () use ($sourceEntity, $targetEntities, $options) {
  536. return $this->_saveLinks($sourceEntity, $targetEntities, $options);
  537. }
  538. );
  539. }
  540. /**
  541. * Removes all links between the passed source entity and each of the provided
  542. * target entities. This method assumes that all passed objects are already persisted
  543. * in the database and that each of them contain a primary key value.
  544. *
  545. * By default this method will also unset each of the entity objects stored inside
  546. * the source entity.
  547. *
  548. * ### Example:
  549. *
  550. * ```
  551. * $article->tags = [$tag1, $tag2, $tag3, $tag4];
  552. * $tags = [$tag1, $tag2, $tag3];
  553. * $articles->association('tags')->unlink($article, $tags);
  554. * ```
  555. *
  556. * `$article->get('tags')` will contain only `[$tag4]` after deleting in the database
  557. *
  558. * @param \Cake\Datasource\EntityInterface $sourceEntity an entity persisted in the source table for
  559. * this association
  560. * @param array $targetEntities list of entities persisted in the target table for
  561. * this association
  562. * @param bool $cleanProperty whether or not to remove all the objects in $targetEntities
  563. * that are stored in $sourceEntity
  564. * @throws \InvalidArgumentException if non persisted entities are passed or if
  565. * any of them is lacking a primary key value
  566. * @return void
  567. */
  568. public function unlink(EntityInterface $sourceEntity, array $targetEntities, $cleanProperty = true)
  569. {
  570. $this->_checkPersistenceStatus($sourceEntity, $targetEntities);
  571. $property = $this->property();
  572. $this->junction()->connection()->transactional(
  573. function () use ($sourceEntity, $targetEntities) {
  574. $links = $this->_collectJointEntities($sourceEntity, $targetEntities);
  575. foreach ($links as $entity) {
  576. $this->_junctionTable->delete($entity);
  577. }
  578. }
  579. );
  580. $existing = $sourceEntity->get($property) ?: [];
  581. if (!$cleanProperty || empty($existing)) {
  582. return;
  583. }
  584. $storage = new \SplObjectStorage;
  585. foreach ($targetEntities as $e) {
  586. $storage->attach($e);
  587. }
  588. foreach ($existing as $k => $e) {
  589. if ($storage->contains($e)) {
  590. unset($existing[$k]);
  591. }
  592. }
  593. $sourceEntity->set($property, array_values($existing));
  594. $sourceEntity->dirty($property, false);
  595. }
  596. /**
  597. * Replaces existing association links between the source entity and the target
  598. * with the ones passed. This method does a smart cleanup, links that are already
  599. * persisted and present in `$targetEntities` will not be deleted, new links will
  600. * be created for the passed target entities that are not already in the database
  601. * and the rest will be removed.
  602. *
  603. * For example, if an article is linked to tags 'cake' and 'framework' and you pass
  604. * to this method an array containing the entities for tags 'cake', 'php' and 'awesome',
  605. * only the link for cake will be kept in database, the link for 'framework' will be
  606. * deleted and the links for 'php' and 'awesome' will be created.
  607. *
  608. * Existing links are not deleted and created again, they are either left untouched
  609. * or updated so that potential extra information stored in the joint row is not
  610. * lost. Updating the link row can be done by making sure the corresponding passed
  611. * target entity contains the joint property with its primary key and any extra
  612. * information to be stored.
  613. *
  614. * On success, the passed `$sourceEntity` will contain `$targetEntities` as value
  615. * in the corresponding property for this association.
  616. *
  617. * This method assumes that links between both the source entity and each of the
  618. * target entities are unique. That is, for any given row in the source table there
  619. * can only be one link in the junction table pointing to any other given row in
  620. * the target table.
  621. *
  622. * Additional options for new links to be saved can be passed in the third argument,
  623. * check `Table::save()` for information on the accepted options.
  624. *
  625. * ### Example:
  626. *
  627. * ```
  628. * $article->tags = [$tag1, $tag2, $tag3, $tag4];
  629. * $articles->save($article);
  630. * $tags = [$tag1, $tag3];
  631. * $articles->association('tags')->replaceLinks($article, $tags);
  632. * ```
  633. *
  634. * `$article->get('tags')` will contain only `[$tag1, $tag3]` at the end
  635. *
  636. * @param \Cake\Datasource\EntityInterface $sourceEntity an entity persisted in the source table for
  637. * this association
  638. * @param array $targetEntities list of entities from the target table to be linked
  639. * @param array $options list of options to be passed to `save` persisting or
  640. * updating new links
  641. * @throws \InvalidArgumentException if non persisted entities are passed or if
  642. * any of them is lacking a primary key value
  643. * @return bool success
  644. */
  645. public function replaceLinks(EntityInterface $sourceEntity, array $targetEntities, array $options = [])
  646. {
  647. $bindingKey = (array)$this->bindingKey();
  648. $primaryValue = $sourceEntity->extract($bindingKey);
  649. if (count(array_filter($primaryValue, 'strlen')) !== count($bindingKey)) {
  650. $message = 'Could not find primary key value for source entity';
  651. throw new InvalidArgumentException($message);
  652. }
  653. return $this->junction()->connection()->transactional(
  654. function () use ($sourceEntity, $targetEntities, $primaryValue, $options) {
  655. $foreignKey = (array)$this->foreignKey();
  656. $hasMany = $this->source()->association($this->_junctionTable->alias());
  657. $existing = $hasMany->find('all')
  658. ->where(array_combine($foreignKey, $primaryValue));
  659. $associationConditions = $this->conditions();
  660. if ($associationConditions) {
  661. $existing->andWhere($associationConditions);
  662. }
  663. $jointEntities = $this->_collectJointEntities($sourceEntity, $targetEntities);
  664. $inserts = $this->_diffLinks($existing, $jointEntities, $targetEntities);
  665. if ($inserts && !$this->_saveTarget($sourceEntity, $inserts, $options)) {
  666. return false;
  667. }
  668. $property = $this->property();
  669. if (count($inserts)) {
  670. $inserted = array_combine(
  671. array_keys($inserts),
  672. (array)$sourceEntity->get($property)
  673. );
  674. $targetEntities = $inserted + $targetEntities;
  675. }
  676. ksort($targetEntities);
  677. $sourceEntity->set($property, array_values($targetEntities));
  678. $sourceEntity->dirty($property, false);
  679. return true;
  680. }
  681. );
  682. }
  683. /**
  684. * Helper method used to delete the difference between the links passed in
  685. * `$existing` and `$jointEntities`. This method will return the values from
  686. * `$targetEntities` that were not deleted from calculating the difference.
  687. *
  688. * @param \Cake\ORM\Query $existing a query for getting existing links
  689. * @param array $jointEntities link entities that should be persisted
  690. * @param array $targetEntities entities in target table that are related to
  691. * the `$jointEntitites`
  692. * @return array
  693. */
  694. protected function _diffLinks($existing, $jointEntities, $targetEntities)
  695. {
  696. $junction = $this->junction();
  697. $target = $this->target();
  698. $belongsTo = $junction->association($target->alias());
  699. $foreignKey = (array)$this->foreignKey();
  700. $assocForeignKey = (array)$belongsTo->foreignKey();
  701. $keys = array_merge($foreignKey, $assocForeignKey);
  702. $deletes = $indexed = $present = [];
  703. foreach ($jointEntities as $i => $entity) {
  704. $indexed[$i] = $entity->extract($keys);
  705. $present[$i] = array_values($entity->extract($assocForeignKey));
  706. }
  707. foreach ($existing as $result) {
  708. $fields = $result->extract($keys);
  709. $found = false;
  710. foreach ($indexed as $i => $data) {
  711. if ($fields === $data) {
  712. unset($indexed[$i]);
  713. $found = true;
  714. break;
  715. }
  716. }
  717. if (!$found) {
  718. $deletes[] = $result;
  719. }
  720. }
  721. $primary = (array)$target->primaryKey();
  722. $jointProperty = $this->_junctionProperty;
  723. foreach ($targetEntities as $k => $entity) {
  724. if (!($entity instanceof EntityInterface)) {
  725. continue;
  726. }
  727. $key = array_values($entity->extract($primary));
  728. foreach ($present as $i => $data) {
  729. if ($key === $data && !$entity->get($jointProperty)) {
  730. unset($targetEntities[$k], $present[$i]);
  731. break;
  732. }
  733. }
  734. }
  735. if ($deletes) {
  736. foreach ($deletes as $entity) {
  737. $junction->delete($entity);
  738. }
  739. }
  740. return $targetEntities;
  741. }
  742. /**
  743. * Throws an exception should any of the passed entities is not persisted.
  744. *
  745. * @param \Cake\ORM\Entity $sourceEntity the row belonging to the `source` side
  746. * of this association
  747. * @param array $targetEntities list of entities belonging to the `target` side
  748. * of this association
  749. * @return bool
  750. * @throws \InvalidArgumentException
  751. */
  752. protected function _checkPersistenceStatus($sourceEntity, array $targetEntities)
  753. {
  754. if ($sourceEntity->isNew()) {
  755. $error = 'Source entity needs to be persisted before proceeding';
  756. throw new InvalidArgumentException($error);
  757. }
  758. foreach ($targetEntities as $entity) {
  759. if ($entity->isNew()) {
  760. $error = 'Cannot link not persisted entities';
  761. throw new InvalidArgumentException($error);
  762. }
  763. }
  764. return true;
  765. }
  766. /**
  767. * Returns the list of joint entities that exist between the source entity
  768. * and each of the passed target entities
  769. *
  770. * @param \Cake\Datasource\EntityInterface $sourceEntity The row belonging to the source side
  771. * of this association.
  772. * @param array $targetEntities The rows belonging to the target side of this
  773. * association.
  774. * @throws \InvalidArgumentException if any of the entities is lacking a primary
  775. * key value
  776. * @return array
  777. */
  778. protected function _collectJointEntities($sourceEntity, $targetEntities)
  779. {
  780. $target = $this->target();
  781. $source = $this->source();
  782. $junction = $this->junction();
  783. $jointProperty = $this->_junctionProperty;
  784. $primary = (array)$target->primaryKey();
  785. $result = [];
  786. $missing = [];
  787. foreach ($targetEntities as $entity) {
  788. if (!($entity instanceof EntityInterface)) {
  789. continue;
  790. }
  791. $joint = $entity->get($jointProperty);
  792. if (!$joint || !($joint instanceof EntityInterface)) {
  793. $missing[] = $entity->extract($primary);
  794. continue;
  795. }
  796. $result[] = $joint;
  797. }
  798. if (empty($missing)) {
  799. return $result;
  800. }
  801. $belongsTo = $junction->association($target->alias());
  802. $hasMany = $source->association($junction->alias());
  803. $foreignKey = (array)$this->foreignKey();
  804. $assocForeignKey = (array)$belongsTo->foreignKey();
  805. $sourceKey = $sourceEntity->extract((array)$source->primaryKey());
  806. foreach ($missing as $key) {
  807. $unions[] = $hasMany->find('all')
  808. ->where(array_combine($foreignKey, $sourceKey))
  809. ->andWhere(array_combine($assocForeignKey, $key));
  810. }
  811. $query = array_shift($unions);
  812. foreach ($unions as $q) {
  813. $query->union($q);
  814. }
  815. return array_merge($result, $query->toArray());
  816. }
  817. /**
  818. * Auxiliary function to construct a new Query object to return all the records
  819. * in the target table that are associated to those specified in $options from
  820. * the source table.
  821. *
  822. * This is used for eager loading records on the target table based on conditions.
  823. *
  824. * @param array $options options accepted by eagerLoader()
  825. * @return \Cake\ORM\Query
  826. * @throws \InvalidArgumentException When a key is required for associations but not selected.
  827. */
  828. protected function _buildQuery($options)
  829. {
  830. $name = $this->_junctionAssociationName();
  831. $query = $this->_buildBaseQuery($options);
  832. $joins = $query->join() ?: [];
  833. $keys = $this->_linkField($options);
  834. $matching = [
  835. $name => [
  836. 'table' => $this->junction()->table(),
  837. 'conditions' => $keys,
  838. 'type' => 'INNER'
  839. ]
  840. ];
  841. $assoc = $this->target()->association($name);
  842. $query
  843. ->addDefaultTypes($assoc->target())
  844. ->join($matching + $joins, [], true)
  845. ->autoFields($query->clause('select') === [])
  846. ->select($query->aliasFields((array)$assoc->foreignKey(), $name));
  847. $query->eagerLoader()->addToJoinsMap($name, $assoc);
  848. $assoc->attachTo($query);
  849. return $query;
  850. }
  851. /**
  852. * Generates a string used as a table field that contains the values upon
  853. * which the filter should be applied
  854. *
  855. * @param array $options the options to use for getting the link field.
  856. * @return string
  857. */
  858. protected function _linkField($options)
  859. {
  860. $links = [];
  861. $name = $this->_junctionAssociationName();
  862. foreach ((array)$options['foreignKey'] as $key) {
  863. $links[] = sprintf('%s.%s', $name, $key);
  864. }
  865. if (count($links) === 1) {
  866. return $links[0];
  867. }
  868. return $links;
  869. }
  870. /**
  871. * Returns the name of the association from the target table to the junction table,
  872. * this name is used to generate alias in the query and to later on retrieve the
  873. * results.
  874. *
  875. * @return string
  876. */
  877. protected function _junctionAssociationName()
  878. {
  879. if (!$this->_junctionAssociationName) {
  880. $this->_junctionAssociationName = $this->target()
  881. ->association($this->junction()->alias())
  882. ->name();
  883. }
  884. return $this->_junctionAssociationName;
  885. }
  886. /**
  887. * Sets the name of the junction table.
  888. * If no arguments are passed the current configured name is returned. A default
  889. * name based of the associated tables will be generated if none found.
  890. *
  891. * @param string|null $name The name of the junction table.
  892. * @return string
  893. */
  894. protected function _junctionTableName($name = null)
  895. {
  896. if ($name === null) {
  897. if (empty($this->_junctionTableName)) {
  898. $tablesNames = array_map('\Cake\Utility\Inflector::underscore', [
  899. $this->source()->table(),
  900. $this->target()->table()
  901. ]);
  902. sort($tablesNames);
  903. $this->_junctionTableName = implode('_', $tablesNames);
  904. }
  905. return $this->_junctionTableName;
  906. }
  907. return $this->_junctionTableName = $name;
  908. }
  909. /**
  910. * Parse extra options passed in the constructor.
  911. *
  912. * @param array $opts original list of options passed in constructor
  913. * @return void
  914. */
  915. protected function _options(array $opts)
  916. {
  917. $this->_externalOptions($opts);
  918. if (!empty($opts['targetForeignKey'])) {
  919. $this->targetForeignKey($opts['targetForeignKey']);
  920. }
  921. if (!empty($opts['joinTable'])) {
  922. $this->_junctionTableName($opts['joinTable']);
  923. }
  924. if (!empty($opts['through'])) {
  925. $this->_through = $opts['through'];
  926. }
  927. if (!empty($opts['saveStrategy'])) {
  928. $this->saveStrategy($opts['saveStrategy']);
  929. }
  930. }
  931. }