1: <?php
2: /**
3: * CakePHP(tm) : Rapid Development Framework (https://cakephp.org)
4: * Copyright (c) Cake Software Foundation, Inc. (https://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. (https://cakefoundation.org)
11: * @link https://cakephp.org CakePHP(tm) Project
12: * @since 3.0.0
13: * @license https://opensource.org/licenses/mit-license.php MIT License
14: */
15: namespace Cake\ORM\Association;
16:
17: use Cake\Core\App;
18: use Cake\Database\ExpressionInterface;
19: use Cake\Database\Expression\IdentifierExpression;
20: use Cake\Datasource\EntityInterface;
21: use Cake\Datasource\QueryInterface;
22: use Cake\ORM\Association;
23: use Cake\ORM\Association\Loader\SelectWithPivotLoader;
24: use Cake\ORM\Query;
25: use Cake\ORM\Table;
26: use Cake\Utility\Inflector;
27: use InvalidArgumentException;
28: use SplObjectStorage;
29: use Traversable;
30:
31: /**
32: * Represents an M - N relationship where there exists a junction - or join - table
33: * that contains the association fields between the source and the target table.
34: *
35: * An example of a BelongsToMany association would be Article belongs to many Tags.
36: */
37: class BelongsToMany extends Association
38: {
39: /**
40: * Saving strategy that will only append to the links set
41: *
42: * @var string
43: */
44: const SAVE_APPEND = 'append';
45:
46: /**
47: * Saving strategy that will replace the links with the provided set
48: *
49: * @var string
50: */
51: const SAVE_REPLACE = 'replace';
52:
53: /**
54: * The type of join to be used when adding the association to a query
55: *
56: * @var string
57: */
58: protected $_joinType = QueryInterface::JOIN_TYPE_INNER;
59:
60: /**
61: * The strategy name to be used to fetch associated records.
62: *
63: * @var string
64: */
65: protected $_strategy = self::STRATEGY_SELECT;
66:
67: /**
68: * Junction table instance
69: *
70: * @var \Cake\ORM\Table
71: */
72: protected $_junctionTable;
73:
74: /**
75: * Junction table name
76: *
77: * @var string
78: */
79: protected $_junctionTableName;
80:
81: /**
82: * The name of the hasMany association from the target table
83: * to the junction table
84: *
85: * @var string
86: */
87: protected $_junctionAssociationName;
88:
89: /**
90: * The name of the property to be set containing data from the junction table
91: * once a record from the target table is hydrated
92: *
93: * @var string
94: */
95: protected $_junctionProperty = '_joinData';
96:
97: /**
98: * Saving strategy to be used by this association
99: *
100: * @var string
101: */
102: protected $_saveStrategy = self::SAVE_REPLACE;
103:
104: /**
105: * The name of the field representing the foreign key to the target table
106: *
107: * @var string|string[]
108: */
109: protected $_targetForeignKey;
110:
111: /**
112: * The table instance for the junction relation.
113: *
114: * @var string|\Cake\ORM\Table
115: */
116: protected $_through;
117:
118: /**
119: * Valid strategies for this type of association
120: *
121: * @var array
122: */
123: protected $_validStrategies = [
124: self::STRATEGY_SELECT,
125: self::STRATEGY_SUBQUERY
126: ];
127:
128: /**
129: * Whether the records on the joint table should be removed when a record
130: * on the source table is deleted.
131: *
132: * Defaults to true for backwards compatibility.
133: *
134: * @var bool
135: */
136: protected $_dependent = true;
137:
138: /**
139: * Filtered conditions that reference the target table.
140: *
141: * @var array|null
142: */
143: protected $_targetConditions;
144:
145: /**
146: * Filtered conditions that reference the junction table.
147: *
148: * @var array|null
149: */
150: protected $_junctionConditions;
151:
152: /**
153: * Order in which target records should be returned
154: *
155: * @var mixed
156: */
157: protected $_sort;
158:
159: /**
160: * Sets the name of the field representing the foreign key to the target table.
161: *
162: * @param string|string[] $key the key to be used to link both tables together
163: * @return $this
164: */
165: public function setTargetForeignKey($key)
166: {
167: $this->_targetForeignKey = $key;
168:
169: return $this;
170: }
171:
172: /**
173: * Gets the name of the field representing the foreign key to the target table.
174: *
175: * @return string|string[]
176: */
177: public function getTargetForeignKey()
178: {
179: if ($this->_targetForeignKey === null) {
180: $this->_targetForeignKey = $this->_modelKey($this->getTarget()->getAlias());
181: }
182:
183: return $this->_targetForeignKey;
184: }
185:
186: /**
187: * Sets the name of the field representing the foreign key to the target table.
188: * If no parameters are passed current field is returned
189: *
190: * @deprecated 3.4.0 Use setTargetForeignKey()/getTargetForeignKey() instead.
191: * @param string|null $key the key to be used to link both tables together
192: * @return string
193: */
194: public function targetForeignKey($key = null)
195: {
196: deprecationWarning(
197: 'BelongToMany::targetForeignKey() is deprecated. ' .
198: 'Use setTargetForeignKey()/getTargetForeignKey() instead.'
199: );
200: if ($key !== null) {
201: $this->setTargetForeignKey($key);
202: }
203:
204: return $this->getTargetForeignKey();
205: }
206:
207: /**
208: * Whether this association can be expressed directly in a query join
209: *
210: * @param array $options custom options key that could alter the return value
211: * @return bool if the 'matching' key in $option is true then this function
212: * will return true, false otherwise
213: */
214: public function canBeJoined(array $options = [])
215: {
216: return !empty($options['matching']);
217: }
218:
219: /**
220: * Gets the name of the field representing the foreign key to the source table.
221: *
222: * @return string
223: */
224: public function getForeignKey()
225: {
226: if ($this->_foreignKey === null) {
227: $this->_foreignKey = $this->_modelKey($this->getSource()->getTable());
228: }
229:
230: return $this->_foreignKey;
231: }
232:
233: /**
234: * Sets the sort order in which target records should be returned.
235: *
236: * @param mixed $sort A find() compatible order clause
237: * @return $this
238: */
239: public function setSort($sort)
240: {
241: $this->_sort = $sort;
242:
243: return $this;
244: }
245:
246: /**
247: * Gets the sort order in which target records should be returned.
248: *
249: * @return mixed
250: */
251: public function getSort()
252: {
253: return $this->_sort;
254: }
255:
256: /**
257: * Sets the sort order in which target records should be returned.
258: * If no arguments are passed the currently configured value is returned
259: *
260: * @deprecated 3.5.0 Use setSort()/getSort() instead.
261: * @param mixed $sort A find() compatible order clause
262: * @return mixed
263: */
264: public function sort($sort = null)
265: {
266: deprecationWarning(
267: 'BelongToMany::sort() is deprecated. ' .
268: 'Use setSort()/getSort() instead.'
269: );
270: if ($sort !== null) {
271: $this->setSort($sort);
272: }
273:
274: return $this->getSort();
275: }
276:
277: /**
278: * {@inheritDoc}
279: */
280: public function defaultRowValue($row, $joined)
281: {
282: $sourceAlias = $this->getSource()->getAlias();
283: if (isset($row[$sourceAlias])) {
284: $row[$sourceAlias][$this->getProperty()] = $joined ? null : [];
285: }
286:
287: return $row;
288: }
289:
290: /**
291: * Sets the table instance for the junction relation. If no arguments
292: * are passed, the current configured table instance is returned
293: *
294: * @param string|\Cake\ORM\Table|null $table Name or instance for the join table
295: * @return \Cake\ORM\Table
296: */
297: public function junction($table = null)
298: {
299: if ($table === null && $this->_junctionTable) {
300: return $this->_junctionTable;
301: }
302:
303: $tableLocator = $this->getTableLocator();
304: if ($table === null && $this->_through) {
305: $table = $this->_through;
306: } elseif ($table === null) {
307: $tableName = $this->_junctionTableName();
308: $tableAlias = Inflector::camelize($tableName);
309:
310: $config = [];
311: if (!$tableLocator->exists($tableAlias)) {
312: $config = ['table' => $tableName];
313:
314: // Propagate the connection if we'll get an auto-model
315: if (!App::className($tableAlias, 'Model/Table', 'Table')) {
316: $config['connection'] = $this->getSource()->getConnection();
317: }
318: }
319: $table = $tableLocator->get($tableAlias, $config);
320: }
321:
322: if (is_string($table)) {
323: $table = $tableLocator->get($table);
324: }
325: $source = $this->getSource();
326: $target = $this->getTarget();
327:
328: $this->_generateSourceAssociations($table, $source);
329: $this->_generateTargetAssociations($table, $source, $target);
330: $this->_generateJunctionAssociations($table, $source, $target);
331:
332: return $this->_junctionTable = $table;
333: }
334:
335: /**
336: * Generate reciprocal associations as necessary.
337: *
338: * Generates the following associations:
339: *
340: * - target hasMany junction e.g. Articles hasMany ArticlesTags
341: * - target belongsToMany source e.g Articles belongsToMany Tags.
342: *
343: * You can override these generated associations by defining associations
344: * with the correct aliases.
345: *
346: * @param \Cake\ORM\Table $junction The junction table.
347: * @param \Cake\ORM\Table $source The source table.
348: * @param \Cake\ORM\Table $target The target table.
349: * @return void
350: */
351: protected function _generateTargetAssociations($junction, $source, $target)
352: {
353: $junctionAlias = $junction->getAlias();
354: $sAlias = $source->getAlias();
355:
356: if (!$target->hasAssociation($junctionAlias)) {
357: $target->hasMany($junctionAlias, [
358: 'targetTable' => $junction,
359: 'foreignKey' => $this->getTargetForeignKey(),
360: 'strategy' => $this->_strategy,
361: ]);
362: }
363: if (!$target->hasAssociation($sAlias)) {
364: $target->belongsToMany($sAlias, [
365: 'sourceTable' => $target,
366: 'targetTable' => $source,
367: 'foreignKey' => $this->getTargetForeignKey(),
368: 'targetForeignKey' => $this->getForeignKey(),
369: 'through' => $junction,
370: 'conditions' => $this->getConditions(),
371: 'strategy' => $this->_strategy,
372: ]);
373: }
374: }
375:
376: /**
377: * Generate additional source table associations as necessary.
378: *
379: * Generates the following associations:
380: *
381: * - source hasMany junction e.g. Tags hasMany ArticlesTags
382: *
383: * You can override these generated associations by defining associations
384: * with the correct aliases.
385: *
386: * @param \Cake\ORM\Table $junction The junction table.
387: * @param \Cake\ORM\Table $source The source table.
388: * @return void
389: */
390: protected function _generateSourceAssociations($junction, $source)
391: {
392: $junctionAlias = $junction->getAlias();
393: if (!$source->hasAssociation($junctionAlias)) {
394: $source->hasMany($junctionAlias, [
395: 'targetTable' => $junction,
396: 'foreignKey' => $this->getForeignKey(),
397: 'strategy' => $this->_strategy,
398: ]);
399: }
400: }
401:
402: /**
403: * Generate associations on the junction table as necessary
404: *
405: * Generates the following associations:
406: *
407: * - junction belongsTo source e.g. ArticlesTags belongsTo Tags
408: * - junction belongsTo target e.g. ArticlesTags belongsTo Articles
409: *
410: * You can override these generated associations by defining associations
411: * with the correct aliases.
412: *
413: * @param \Cake\ORM\Table $junction The junction table.
414: * @param \Cake\ORM\Table $source The source table.
415: * @param \Cake\ORM\Table $target The target table.
416: * @return void
417: */
418: protected function _generateJunctionAssociations($junction, $source, $target)
419: {
420: $tAlias = $target->getAlias();
421: $sAlias = $source->getAlias();
422:
423: if (!$junction->hasAssociation($tAlias)) {
424: $junction->belongsTo($tAlias, [
425: 'foreignKey' => $this->getTargetForeignKey(),
426: 'targetTable' => $target
427: ]);
428: }
429: if (!$junction->hasAssociation($sAlias)) {
430: $junction->belongsTo($sAlias, [
431: 'foreignKey' => $this->getForeignKey(),
432: 'targetTable' => $source
433: ]);
434: }
435: }
436:
437: /**
438: * Alters a Query object to include the associated target table data in the final
439: * result
440: *
441: * The options array accept the following keys:
442: *
443: * - includeFields: Whether to include target model fields in the result or not
444: * - foreignKey: The name of the field to use as foreign key, if false none
445: * will be used
446: * - conditions: array with a list of conditions to filter the join with
447: * - fields: a list of fields in the target table to include in the result
448: * - type: The type of join to be used (e.g. INNER)
449: *
450: * @param \Cake\ORM\Query $query the query to be altered to include the target table data
451: * @param array $options Any extra options or overrides to be taken in account
452: * @return void
453: */
454: public function attachTo(Query $query, array $options = [])
455: {
456: if (!empty($options['negateMatch'])) {
457: $this->_appendNotMatching($query, $options);
458:
459: return;
460: }
461:
462: $junction = $this->junction();
463: $belongsTo = $junction->getAssociation($this->getSource()->getAlias());
464: $cond = $belongsTo->_joinCondition(['foreignKey' => $belongsTo->getForeignKey()]);
465: $cond += $this->junctionConditions();
466:
467: $includeFields = null;
468: if (isset($options['includeFields'])) {
469: $includeFields = $options['includeFields'];
470: }
471:
472: // Attach the junction table as well we need it to populate _joinData.
473: $assoc = $this->_targetTable->getAssociation($junction->getAlias());
474: $newOptions = array_intersect_key($options, ['joinType' => 1, 'fields' => 1]);
475: $newOptions += [
476: 'conditions' => $cond,
477: 'includeFields' => $includeFields,
478: 'foreignKey' => false,
479: ];
480: $assoc->attachTo($query, $newOptions);
481: $query->getEagerLoader()->addToJoinsMap($junction->getAlias(), $assoc, true);
482:
483: parent::attachTo($query, $options);
484:
485: $foreignKey = $this->getTargetForeignKey();
486: $thisJoin = $query->clause('join')[$this->getName()];
487: $thisJoin['conditions']->add($assoc->_joinCondition(['foreignKey' => $foreignKey]));
488: }
489:
490: /**
491: * {@inheritDoc}
492: */
493: protected function _appendNotMatching($query, $options)
494: {
495: if (empty($options['negateMatch'])) {
496: return;
497: }
498: if (!isset($options['conditions'])) {
499: $options['conditions'] = [];
500: }
501: $junction = $this->junction();
502: $belongsTo = $junction->getAssociation($this->getSource()->getAlias());
503: $conds = $belongsTo->_joinCondition(['foreignKey' => $belongsTo->getForeignKey()]);
504:
505: $subquery = $this->find()
506: ->select(array_values($conds))
507: ->where($options['conditions'])
508: ->andWhere($this->junctionConditions());
509:
510: if (!empty($options['queryBuilder'])) {
511: $subquery = $options['queryBuilder']($subquery);
512: }
513:
514: $assoc = $junction->getAssociation($this->getTarget()->getAlias());
515: $conditions = $assoc->_joinCondition([
516: 'foreignKey' => $this->getTargetForeignKey()
517: ]);
518: $subquery = $this->_appendJunctionJoin($subquery, $conditions);
519:
520: $query
521: ->andWhere(function ($exp) use ($subquery, $conds) {
522: $identifiers = [];
523: foreach (array_keys($conds) as $field) {
524: $identifiers[] = new IdentifierExpression($field);
525: }
526: $identifiers = $subquery->newExpr()->add($identifiers)->setConjunction(',');
527: $nullExp = clone $exp;
528:
529: return $exp
530: ->or_([
531: $exp->notIn($identifiers, $subquery),
532: $nullExp->and(array_map([$nullExp, 'isNull'], array_keys($conds))),
533: ]);
534: });
535: }
536:
537: /**
538: * Get the relationship type.
539: *
540: * @return string
541: */
542: public function type()
543: {
544: return self::MANY_TO_MANY;
545: }
546:
547: /**
548: * Return false as join conditions are defined in the junction table
549: *
550: * @param array $options list of options passed to attachTo method
551: * @return bool false
552: */
553: protected function _joinCondition($options)
554: {
555: return false;
556: }
557:
558: /**
559: * {@inheritDoc}
560: *
561: * @return \Closure
562: */
563: public function eagerLoader(array $options)
564: {
565: $name = $this->_junctionAssociationName();
566: $loader = new SelectWithPivotLoader([
567: 'alias' => $this->getAlias(),
568: 'sourceAlias' => $this->getSource()->getAlias(),
569: 'targetAlias' => $this->getTarget()->getAlias(),
570: 'foreignKey' => $this->getForeignKey(),
571: 'bindingKey' => $this->getBindingKey(),
572: 'strategy' => $this->getStrategy(),
573: 'associationType' => $this->type(),
574: 'sort' => $this->getSort(),
575: 'junctionAssociationName' => $name,
576: 'junctionProperty' => $this->_junctionProperty,
577: 'junctionAssoc' => $this->getTarget()->getAssociation($name),
578: 'junctionConditions' => $this->junctionConditions(),
579: 'finder' => function () {
580: return $this->_appendJunctionJoin($this->find(), []);
581: }
582: ]);
583:
584: return $loader->buildEagerLoader($options);
585: }
586:
587: /**
588: * Clear out the data in the junction table for a given entity.
589: *
590: * @param \Cake\Datasource\EntityInterface $entity The entity that started the cascading delete.
591: * @param array $options The options for the original delete.
592: * @return bool Success.
593: */
594: public function cascadeDelete(EntityInterface $entity, array $options = [])
595: {
596: if (!$this->getDependent()) {
597: return true;
598: }
599: $foreignKey = (array)$this->getForeignKey();
600: $bindingKey = (array)$this->getBindingKey();
601: $conditions = [];
602:
603: if (!empty($bindingKey)) {
604: $conditions = array_combine($foreignKey, $entity->extract($bindingKey));
605: }
606:
607: $table = $this->junction();
608: $hasMany = $this->getSource()->getAssociation($table->getAlias());
609: if ($this->_cascadeCallbacks) {
610: foreach ($hasMany->find('all')->where($conditions)->all()->toList() as $related) {
611: $table->delete($related, $options);
612: }
613:
614: return true;
615: }
616:
617: $conditions = array_merge($conditions, $hasMany->getConditions());
618:
619: $table->deleteAll($conditions);
620:
621: return true;
622: }
623:
624: /**
625: * Returns boolean true, as both of the tables 'own' rows in the other side
626: * of the association via the joint table.
627: *
628: * @param \Cake\ORM\Table $side The potential Table with ownership
629: * @return bool
630: */
631: public function isOwningSide(Table $side)
632: {
633: return true;
634: }
635:
636: /**
637: * Sets the strategy that should be used for saving.
638: *
639: * @param string $strategy the strategy name to be used
640: * @throws \InvalidArgumentException if an invalid strategy name is passed
641: * @return $this
642: */
643: public function setSaveStrategy($strategy)
644: {
645: if (!in_array($strategy, [self::SAVE_APPEND, self::SAVE_REPLACE])) {
646: $msg = sprintf('Invalid save strategy "%s"', $strategy);
647: throw new InvalidArgumentException($msg);
648: }
649:
650: $this->_saveStrategy = $strategy;
651:
652: return $this;
653: }
654:
655: /**
656: * Gets the strategy that should be used for saving.
657: *
658: * @return string the strategy to be used for saving
659: */
660: public function getSaveStrategy()
661: {
662: return $this->_saveStrategy;
663: }
664:
665: /**
666: * Sets the strategy that should be used for saving. If called with no
667: * arguments, it will return the currently configured strategy
668: *
669: * @deprecated 3.4.0 Use setSaveStrategy()/getSaveStrategy() instead.
670: * @param string|null $strategy the strategy name to be used
671: * @throws \InvalidArgumentException if an invalid strategy name is passed
672: * @return string the strategy to be used for saving
673: */
674: public function saveStrategy($strategy = null)
675: {
676: deprecationWarning(
677: 'BelongsToMany::saveStrategy() is deprecated. ' .
678: 'Use setSaveStrategy()/getSaveStrategy() instead.'
679: );
680: if ($strategy !== null) {
681: $this->setSaveStrategy($strategy);
682: }
683:
684: return $this->getSaveStrategy();
685: }
686:
687: /**
688: * Takes an entity from the source table and looks if there is a field
689: * matching the property name for this association. The found entity will be
690: * saved on the target table for this association by passing supplied
691: * `$options`
692: *
693: * When using the 'append' strategy, this function will only create new links
694: * between each side of this association. It will not destroy existing ones even
695: * though they may not be present in the array of entities to be saved.
696: *
697: * When using the 'replace' strategy, existing links will be removed and new links
698: * will be created in the joint table. If there exists links in the database to some
699: * of the entities intended to be saved by this method, they will be updated,
700: * not deleted.
701: *
702: * @param \Cake\Datasource\EntityInterface $entity an entity from the source table
703: * @param array $options options to be passed to the save method in the target table
704: * @throws \InvalidArgumentException if the property representing the association
705: * in the parent entity cannot be traversed
706: * @return bool|\Cake\Datasource\EntityInterface false if $entity could not be saved, otherwise it returns
707: * the saved entity
708: * @see \Cake\ORM\Table::save()
709: * @see \Cake\ORM\Association\BelongsToMany::replaceLinks()
710: */
711: public function saveAssociated(EntityInterface $entity, array $options = [])
712: {
713: $targetEntity = $entity->get($this->getProperty());
714: $strategy = $this->getSaveStrategy();
715:
716: $isEmpty = in_array($targetEntity, [null, [], '', false], true);
717: if ($isEmpty && $entity->isNew()) {
718: return $entity;
719: }
720: if ($isEmpty) {
721: $targetEntity = [];
722: }
723:
724: if ($strategy === self::SAVE_APPEND) {
725: return $this->_saveTarget($entity, $targetEntity, $options);
726: }
727:
728: if ($this->replaceLinks($entity, $targetEntity, $options)) {
729: return $entity;
730: }
731:
732: return false;
733: }
734:
735: /**
736: * Persists each of the entities into the target table and creates links between
737: * the parent entity and each one of the saved target entities.
738: *
739: * @param \Cake\Datasource\EntityInterface $parentEntity the source entity containing the target
740: * entities to be saved.
741: * @param array|\Traversable $entities list of entities to persist in target table and to
742: * link to the parent entity
743: * @param array $options list of options accepted by `Table::save()`
744: * @throws \InvalidArgumentException if the property representing the association
745: * in the parent entity cannot be traversed
746: * @return \Cake\Datasource\EntityInterface|bool The parent entity after all links have been
747: * created if no errors happened, false otherwise
748: */
749: protected function _saveTarget(EntityInterface $parentEntity, $entities, $options)
750: {
751: $joinAssociations = false;
752: if (!empty($options['associated'][$this->_junctionProperty]['associated'])) {
753: $joinAssociations = $options['associated'][$this->_junctionProperty]['associated'];
754: }
755: unset($options['associated'][$this->_junctionProperty]);
756:
757: if (!(is_array($entities) || $entities instanceof Traversable)) {
758: $name = $this->getProperty();
759: $message = sprintf('Could not save %s, it cannot be traversed', $name);
760: throw new InvalidArgumentException($message);
761: }
762:
763: $table = $this->getTarget();
764: $original = $entities;
765: $persisted = [];
766:
767: foreach ($entities as $k => $entity) {
768: if (!($entity instanceof EntityInterface)) {
769: break;
770: }
771:
772: if (!empty($options['atomic'])) {
773: $entity = clone $entity;
774: }
775:
776: $saved = $table->save($entity, $options);
777: if ($saved) {
778: $entities[$k] = $entity;
779: $persisted[] = $entity;
780: continue;
781: }
782:
783: // Saving the new linked entity failed, copy errors back into the
784: // original entity if applicable and abort.
785: if (!empty($options['atomic'])) {
786: $original[$k]->setErrors($entity->getErrors());
787: }
788: if (!$saved) {
789: return false;
790: }
791: }
792:
793: $options['associated'] = $joinAssociations;
794: $success = $this->_saveLinks($parentEntity, $persisted, $options);
795: if (!$success && !empty($options['atomic'])) {
796: $parentEntity->set($this->getProperty(), $original);
797:
798: return false;
799: }
800:
801: $parentEntity->set($this->getProperty(), $entities);
802:
803: return $parentEntity;
804: }
805:
806: /**
807: * Creates links between the source entity and each of the passed target entities
808: *
809: * @param \Cake\Datasource\EntityInterface $sourceEntity the entity from source table in this
810: * association
811: * @param \Cake\Datasource\EntityInterface[] $targetEntities list of entities to link to link to the source entity using the
812: * junction table
813: * @param array $options list of options accepted by `Table::save()`
814: * @return bool success
815: */
816: protected function _saveLinks(EntityInterface $sourceEntity, $targetEntities, $options)
817: {
818: $target = $this->getTarget();
819: $junction = $this->junction();
820: $entityClass = $junction->getEntityClass();
821: $belongsTo = $junction->getAssociation($target->getAlias());
822: $foreignKey = (array)$this->getForeignKey();
823: $assocForeignKey = (array)$belongsTo->getForeignKey();
824: $targetPrimaryKey = (array)$target->getPrimaryKey();
825: $bindingKey = (array)$this->getBindingKey();
826: $jointProperty = $this->_junctionProperty;
827: $junctionRegistryAlias = $junction->getRegistryAlias();
828:
829: foreach ($targetEntities as $e) {
830: $joint = $e->get($jointProperty);
831: if (!$joint || !($joint instanceof EntityInterface)) {
832: $joint = new $entityClass([], ['markNew' => true, 'source' => $junctionRegistryAlias]);
833: }
834: $sourceKeys = array_combine($foreignKey, $sourceEntity->extract($bindingKey));
835: $targetKeys = array_combine($assocForeignKey, $e->extract($targetPrimaryKey));
836:
837: $changedKeys = (
838: $sourceKeys !== $joint->extract($foreignKey) ||
839: $targetKeys !== $joint->extract($assocForeignKey)
840: );
841: // Keys were changed, the junction table record _could_ be
842: // new. By clearing the primary key values, and marking the entity
843: // as new, we let save() sort out whether or not we have a new link
844: // or if we are updating an existing link.
845: if ($changedKeys) {
846: $joint->isNew(true);
847: $joint->unsetProperty($junction->getPrimaryKey())
848: ->set(array_merge($sourceKeys, $targetKeys), ['guard' => false]);
849: }
850: $saved = $junction->save($joint, $options);
851:
852: if (!$saved && !empty($options['atomic'])) {
853: return false;
854: }
855:
856: $e->set($jointProperty, $joint);
857: $e->setDirty($jointProperty, false);
858: }
859:
860: return true;
861: }
862:
863: /**
864: * Associates the source entity to each of the target entities provided by
865: * creating links in the junction table. Both the source entity and each of
866: * the target entities are assumed to be already persisted, if they are marked
867: * as new or their status is unknown then an exception will be thrown.
868: *
869: * When using this method, all entities in `$targetEntities` will be appended to
870: * the source entity's property corresponding to this association object.
871: *
872: * This method does not check link uniqueness.
873: *
874: * ### Example:
875: *
876: * ```
877: * $newTags = $tags->find('relevant')->toArray();
878: * $articles->getAssociation('tags')->link($article, $newTags);
879: * ```
880: *
881: * `$article->get('tags')` will contain all tags in `$newTags` after liking
882: *
883: * @param \Cake\Datasource\EntityInterface $sourceEntity the row belonging to the `source` side
884: * of this association
885: * @param \Cake\Datasource\EntityInterface[] $targetEntities list of entities belonging to the `target` side
886: * of this association
887: * @param array $options list of options to be passed to the internal `save` call
888: * @throws \InvalidArgumentException when any of the values in $targetEntities is
889: * detected to not be already persisted
890: * @return bool true on success, false otherwise
891: */
892: public function link(EntityInterface $sourceEntity, array $targetEntities, array $options = [])
893: {
894: $this->_checkPersistenceStatus($sourceEntity, $targetEntities);
895: $property = $this->getProperty();
896: $links = $sourceEntity->get($property) ?: [];
897: $links = array_merge($links, $targetEntities);
898: $sourceEntity->set($property, $links);
899:
900: return $this->junction()->getConnection()->transactional(
901: function () use ($sourceEntity, $targetEntities, $options) {
902: return $this->_saveLinks($sourceEntity, $targetEntities, $options);
903: }
904: );
905: }
906:
907: /**
908: * Removes all links between the passed source entity and each of the provided
909: * target entities. This method assumes that all passed objects are already persisted
910: * in the database and that each of them contain a primary key value.
911: *
912: * ### Options
913: *
914: * Additionally to the default options accepted by `Table::delete()`, the following
915: * keys are supported:
916: *
917: * - cleanProperty: Whether or not to remove all the objects in `$targetEntities` that
918: * are stored in `$sourceEntity` (default: true)
919: *
920: * By default this method will unset each of the entity objects stored inside the
921: * source entity.
922: *
923: * ### Example:
924: *
925: * ```
926: * $article->tags = [$tag1, $tag2, $tag3, $tag4];
927: * $tags = [$tag1, $tag2, $tag3];
928: * $articles->getAssociation('tags')->unlink($article, $tags);
929: * ```
930: *
931: * `$article->get('tags')` will contain only `[$tag4]` after deleting in the database
932: *
933: * @param \Cake\Datasource\EntityInterface $sourceEntity An entity persisted in the source table for
934: * this association.
935: * @param \Cake\Datasource\EntityInterface[] $targetEntities List of entities persisted in the target table for
936: * this association.
937: * @param array|bool $options List of options to be passed to the internal `delete` call,
938: * or a `boolean` as `cleanProperty` key shortcut.
939: * @throws \InvalidArgumentException If non persisted entities are passed or if
940: * any of them is lacking a primary key value.
941: * @return bool Success
942: */
943: public function unlink(EntityInterface $sourceEntity, array $targetEntities, $options = [])
944: {
945: if (is_bool($options)) {
946: $options = [
947: 'cleanProperty' => $options
948: ];
949: } else {
950: $options += ['cleanProperty' => true];
951: }
952:
953: $this->_checkPersistenceStatus($sourceEntity, $targetEntities);
954: $property = $this->getProperty();
955:
956: $this->junction()->getConnection()->transactional(
957: function () use ($sourceEntity, $targetEntities, $options) {
958: $links = $this->_collectJointEntities($sourceEntity, $targetEntities);
959: foreach ($links as $entity) {
960: $this->_junctionTable->delete($entity, $options);
961: }
962: }
963: );
964:
965: $existing = $sourceEntity->get($property) ?: [];
966: if (!$options['cleanProperty'] || empty($existing)) {
967: return true;
968: }
969:
970: $storage = new SplObjectStorage();
971: foreach ($targetEntities as $e) {
972: $storage->attach($e);
973: }
974:
975: foreach ($existing as $k => $e) {
976: if ($storage->contains($e)) {
977: unset($existing[$k]);
978: }
979: }
980:
981: $sourceEntity->set($property, array_values($existing));
982: $sourceEntity->setDirty($property, false);
983:
984: return true;
985: }
986:
987: /**
988: * {@inheritDoc}
989: */
990: public function setConditions($conditions)
991: {
992: parent::setConditions($conditions);
993: $this->_targetConditions = $this->_junctionConditions = null;
994:
995: return $this;
996: }
997:
998: /**
999: * Sets the current join table, either the name of the Table instance or the instance itself.
1000: *
1001: * @param string|\Cake\ORM\Table $through Name of the Table instance or the instance itself
1002: * @return $this
1003: */
1004: public function setThrough($through)
1005: {
1006: $this->_through = $through;
1007:
1008: return $this;
1009: }
1010:
1011: /**
1012: * Gets the current join table, either the name of the Table instance or the instance itself.
1013: *
1014: * @return string|\Cake\ORM\Table
1015: */
1016: public function getThrough()
1017: {
1018: return $this->_through;
1019: }
1020:
1021: /**
1022: * Returns filtered conditions that reference the target table.
1023: *
1024: * Any string expressions, or expression objects will
1025: * also be returned in this list.
1026: *
1027: * @return mixed Generally an array. If the conditions
1028: * are not an array, the association conditions will be
1029: * returned unmodified.
1030: */
1031: protected function targetConditions()
1032: {
1033: if ($this->_targetConditions !== null) {
1034: return $this->_targetConditions;
1035: }
1036: $conditions = $this->getConditions();
1037: if (!is_array($conditions)) {
1038: return $conditions;
1039: }
1040: $matching = [];
1041: $alias = $this->getAlias() . '.';
1042: foreach ($conditions as $field => $value) {
1043: if (is_string($field) && strpos($field, $alias) === 0) {
1044: $matching[$field] = $value;
1045: } elseif (is_int($field) || $value instanceof ExpressionInterface) {
1046: $matching[$field] = $value;
1047: }
1048: }
1049:
1050: return $this->_targetConditions = $matching;
1051: }
1052:
1053: /**
1054: * Returns filtered conditions that specifically reference
1055: * the junction table.
1056: *
1057: * @return array
1058: */
1059: protected function junctionConditions()
1060: {
1061: if ($this->_junctionConditions !== null) {
1062: return $this->_junctionConditions;
1063: }
1064: $matching = [];
1065: $conditions = $this->getConditions();
1066: if (!is_array($conditions)) {
1067: return $matching;
1068: }
1069: $alias = $this->_junctionAssociationName() . '.';
1070: foreach ($conditions as $field => $value) {
1071: $isString = is_string($field);
1072: if ($isString && strpos($field, $alias) === 0) {
1073: $matching[$field] = $value;
1074: }
1075: // Assume that operators contain junction conditions.
1076: // Trying to manage complex conditions could result in incorrect queries.
1077: if ($isString && in_array(strtoupper($field), ['OR', 'NOT', 'AND', 'XOR'])) {
1078: $matching[$field] = $value;
1079: }
1080: }
1081:
1082: return $this->_junctionConditions = $matching;
1083: }
1084:
1085: /**
1086: * Proxies the finding operation to the target table's find method
1087: * and modifies the query accordingly based of this association
1088: * configuration.
1089: *
1090: * If your association includes conditions, the junction table will be
1091: * included in the query's contained associations.
1092: *
1093: * @param string|array|null $type the type of query to perform, if an array is passed,
1094: * it will be interpreted as the `$options` parameter
1095: * @param array $options The options to for the find
1096: * @see \Cake\ORM\Table::find()
1097: * @return \Cake\ORM\Query
1098: */
1099: public function find($type = null, array $options = [])
1100: {
1101: $type = $type ?: $this->getFinder();
1102: list($type, $opts) = $this->_extractFinder($type);
1103: $query = $this->getTarget()
1104: ->find($type, $options + $opts)
1105: ->where($this->targetConditions())
1106: ->addDefaultTypes($this->getTarget());
1107:
1108: if (!$this->junctionConditions()) {
1109: return $query;
1110: }
1111:
1112: $belongsTo = $this->junction()->getAssociation($this->getTarget()->getAlias());
1113: $conditions = $belongsTo->_joinCondition([
1114: 'foreignKey' => $this->getTargetForeignKey()
1115: ]);
1116: $conditions += $this->junctionConditions();
1117:
1118: return $this->_appendJunctionJoin($query, $conditions);
1119: }
1120:
1121: /**
1122: * Append a join to the junction table.
1123: *
1124: * @param \Cake\ORM\Query $query The query to append.
1125: * @param string|array $conditions The query conditions to use.
1126: * @return \Cake\ORM\Query The modified query.
1127: */
1128: protected function _appendJunctionJoin($query, $conditions)
1129: {
1130: $name = $this->_junctionAssociationName();
1131: /** @var array $joins */
1132: $joins = $query->clause('join');
1133: $matching = [
1134: $name => [
1135: 'table' => $this->junction()->getTable(),
1136: 'conditions' => $conditions,
1137: 'type' => QueryInterface::JOIN_TYPE_INNER
1138: ]
1139: ];
1140:
1141: $assoc = $this->getTarget()->getAssociation($name);
1142: $query
1143: ->addDefaultTypes($assoc->getTarget())
1144: ->join($matching + $joins, [], true);
1145:
1146: return $query;
1147: }
1148:
1149: /**
1150: * Replaces existing association links between the source entity and the target
1151: * with the ones passed. This method does a smart cleanup, links that are already
1152: * persisted and present in `$targetEntities` will not be deleted, new links will
1153: * be created for the passed target entities that are not already in the database
1154: * and the rest will be removed.
1155: *
1156: * For example, if an article is linked to tags 'cake' and 'framework' and you pass
1157: * to this method an array containing the entities for tags 'cake', 'php' and 'awesome',
1158: * only the link for cake will be kept in database, the link for 'framework' will be
1159: * deleted and the links for 'php' and 'awesome' will be created.
1160: *
1161: * Existing links are not deleted and created again, they are either left untouched
1162: * or updated so that potential extra information stored in the joint row is not
1163: * lost. Updating the link row can be done by making sure the corresponding passed
1164: * target entity contains the joint property with its primary key and any extra
1165: * information to be stored.
1166: *
1167: * On success, the passed `$sourceEntity` will contain `$targetEntities` as value
1168: * in the corresponding property for this association.
1169: *
1170: * This method assumes that links between both the source entity and each of the
1171: * target entities are unique. That is, for any given row in the source table there
1172: * can only be one link in the junction table pointing to any other given row in
1173: * the target table.
1174: *
1175: * Additional options for new links to be saved can be passed in the third argument,
1176: * check `Table::save()` for information on the accepted options.
1177: *
1178: * ### Example:
1179: *
1180: * ```
1181: * $article->tags = [$tag1, $tag2, $tag3, $tag4];
1182: * $articles->save($article);
1183: * $tags = [$tag1, $tag3];
1184: * $articles->getAssociation('tags')->replaceLinks($article, $tags);
1185: * ```
1186: *
1187: * `$article->get('tags')` will contain only `[$tag1, $tag3]` at the end
1188: *
1189: * @param \Cake\Datasource\EntityInterface $sourceEntity an entity persisted in the source table for
1190: * this association
1191: * @param array $targetEntities list of entities from the target table to be linked
1192: * @param array $options list of options to be passed to the internal `save`/`delete` calls
1193: * when persisting/updating new links, or deleting existing ones
1194: * @throws \InvalidArgumentException if non persisted entities are passed or if
1195: * any of them is lacking a primary key value
1196: * @return bool success
1197: */
1198: public function replaceLinks(EntityInterface $sourceEntity, array $targetEntities, array $options = [])
1199: {
1200: $bindingKey = (array)$this->getBindingKey();
1201: $primaryValue = $sourceEntity->extract($bindingKey);
1202:
1203: if (count(array_filter($primaryValue, 'strlen')) !== count($bindingKey)) {
1204: $message = 'Could not find primary key value for source entity';
1205: throw new InvalidArgumentException($message);
1206: }
1207:
1208: return $this->junction()->getConnection()->transactional(
1209: function () use ($sourceEntity, $targetEntities, $primaryValue, $options) {
1210: $foreignKey = array_map([$this->_junctionTable, 'aliasField'], (array)$this->getForeignKey());
1211: $hasMany = $this->getSource()->getAssociation($this->_junctionTable->getAlias());
1212: $existing = $hasMany->find('all')
1213: ->where(array_combine($foreignKey, $primaryValue));
1214:
1215: $associationConditions = $this->getConditions();
1216: if ($associationConditions) {
1217: $existing->contain($this->getTarget()->getAlias());
1218: $existing->andWhere($associationConditions);
1219: }
1220:
1221: $jointEntities = $this->_collectJointEntities($sourceEntity, $targetEntities);
1222: $inserts = $this->_diffLinks($existing, $jointEntities, $targetEntities, $options);
1223:
1224: if ($inserts && !$this->_saveTarget($sourceEntity, $inserts, $options)) {
1225: return false;
1226: }
1227:
1228: $property = $this->getProperty();
1229:
1230: if (count($inserts)) {
1231: $inserted = array_combine(
1232: array_keys($inserts),
1233: (array)$sourceEntity->get($property)
1234: );
1235: $targetEntities = $inserted + $targetEntities;
1236: }
1237:
1238: ksort($targetEntities);
1239: $sourceEntity->set($property, array_values($targetEntities));
1240: $sourceEntity->setDirty($property, false);
1241:
1242: return true;
1243: }
1244: );
1245: }
1246:
1247: /**
1248: * Helper method used to delete the difference between the links passed in
1249: * `$existing` and `$jointEntities`. This method will return the values from
1250: * `$targetEntities` that were not deleted from calculating the difference.
1251: *
1252: * @param \Cake\ORM\Query $existing a query for getting existing links
1253: * @param \Cake\Datasource\EntityInterface[] $jointEntities link entities that should be persisted
1254: * @param array $targetEntities entities in target table that are related to
1255: * the `$jointEntities`
1256: * @param array $options list of options accepted by `Table::delete()`
1257: * @return array
1258: */
1259: protected function _diffLinks($existing, $jointEntities, $targetEntities, $options = [])
1260: {
1261: $junction = $this->junction();
1262: $target = $this->getTarget();
1263: $belongsTo = $junction->getAssociation($target->getAlias());
1264: $foreignKey = (array)$this->getForeignKey();
1265: $assocForeignKey = (array)$belongsTo->getForeignKey();
1266:
1267: $keys = array_merge($foreignKey, $assocForeignKey);
1268: $deletes = $indexed = $present = [];
1269:
1270: foreach ($jointEntities as $i => $entity) {
1271: $indexed[$i] = $entity->extract($keys);
1272: $present[$i] = array_values($entity->extract($assocForeignKey));
1273: }
1274:
1275: foreach ($existing as $result) {
1276: $fields = $result->extract($keys);
1277: $found = false;
1278: foreach ($indexed as $i => $data) {
1279: if ($fields === $data) {
1280: unset($indexed[$i]);
1281: $found = true;
1282: break;
1283: }
1284: }
1285:
1286: if (!$found) {
1287: $deletes[] = $result;
1288: }
1289: }
1290:
1291: $primary = (array)$target->getPrimaryKey();
1292: $jointProperty = $this->_junctionProperty;
1293: foreach ($targetEntities as $k => $entity) {
1294: if (!($entity instanceof EntityInterface)) {
1295: continue;
1296: }
1297: $key = array_values($entity->extract($primary));
1298: foreach ($present as $i => $data) {
1299: if ($key === $data && !$entity->get($jointProperty)) {
1300: unset($targetEntities[$k], $present[$i]);
1301: break;
1302: }
1303: }
1304: }
1305:
1306: if ($deletes) {
1307: foreach ($deletes as $entity) {
1308: $junction->delete($entity, $options);
1309: }
1310: }
1311:
1312: return $targetEntities;
1313: }
1314:
1315: /**
1316: * Throws an exception should any of the passed entities is not persisted.
1317: *
1318: * @param \Cake\Datasource\EntityInterface $sourceEntity the row belonging to the `source` side
1319: * of this association
1320: * @param \Cake\Datasource\EntityInterface[] $targetEntities list of entities belonging to the `target` side
1321: * of this association
1322: * @return bool
1323: * @throws \InvalidArgumentException
1324: */
1325: protected function _checkPersistenceStatus($sourceEntity, array $targetEntities)
1326: {
1327: if ($sourceEntity->isNew()) {
1328: $error = 'Source entity needs to be persisted before links can be created or removed.';
1329: throw new InvalidArgumentException($error);
1330: }
1331:
1332: foreach ($targetEntities as $entity) {
1333: if ($entity->isNew()) {
1334: $error = 'Cannot link entities that have not been persisted yet.';
1335: throw new InvalidArgumentException($error);
1336: }
1337: }
1338:
1339: return true;
1340: }
1341:
1342: /**
1343: * Returns the list of joint entities that exist between the source entity
1344: * and each of the passed target entities
1345: *
1346: * @param \Cake\Datasource\EntityInterface $sourceEntity The row belonging to the source side
1347: * of this association.
1348: * @param array $targetEntities The rows belonging to the target side of this
1349: * association.
1350: * @throws \InvalidArgumentException if any of the entities is lacking a primary
1351: * key value
1352: * @return \Cake\Datasource\EntityInterface[]
1353: */
1354: protected function _collectJointEntities($sourceEntity, $targetEntities)
1355: {
1356: $target = $this->getTarget();
1357: $source = $this->getSource();
1358: $junction = $this->junction();
1359: $jointProperty = $this->_junctionProperty;
1360: $primary = (array)$target->getPrimaryKey();
1361:
1362: $result = [];
1363: $missing = [];
1364:
1365: foreach ($targetEntities as $entity) {
1366: if (!($entity instanceof EntityInterface)) {
1367: continue;
1368: }
1369: $joint = $entity->get($jointProperty);
1370:
1371: if (!$joint || !($joint instanceof EntityInterface)) {
1372: $missing[] = $entity->extract($primary);
1373: continue;
1374: }
1375:
1376: $result[] = $joint;
1377: }
1378:
1379: if (empty($missing)) {
1380: return $result;
1381: }
1382:
1383: $belongsTo = $junction->getAssociation($target->getAlias());
1384: $hasMany = $source->getAssociation($junction->getAlias());
1385: $foreignKey = (array)$this->getForeignKey();
1386: $assocForeignKey = (array)$belongsTo->getForeignKey();
1387: $sourceKey = $sourceEntity->extract((array)$source->getPrimaryKey());
1388:
1389: $unions = [];
1390: foreach ($missing as $key) {
1391: $unions[] = $hasMany->find('all')
1392: ->where(array_combine($foreignKey, $sourceKey))
1393: ->andWhere(array_combine($assocForeignKey, $key));
1394: }
1395:
1396: $query = array_shift($unions);
1397: foreach ($unions as $q) {
1398: $query->union($q);
1399: }
1400:
1401: return array_merge($result, $query->toArray());
1402: }
1403:
1404: /**
1405: * Returns the name of the association from the target table to the junction table,
1406: * this name is used to generate alias in the query and to later on retrieve the
1407: * results.
1408: *
1409: * @return string
1410: */
1411: protected function _junctionAssociationName()
1412: {
1413: if (!$this->_junctionAssociationName) {
1414: $this->_junctionAssociationName = $this->getTarget()
1415: ->getAssociation($this->junction()->getAlias())
1416: ->getName();
1417: }
1418:
1419: return $this->_junctionAssociationName;
1420: }
1421:
1422: /**
1423: * Sets the name of the junction table.
1424: * If no arguments are passed the current configured name is returned. A default
1425: * name based of the associated tables will be generated if none found.
1426: *
1427: * @param string|null $name The name of the junction table.
1428: * @return string
1429: */
1430: protected function _junctionTableName($name = null)
1431: {
1432: if ($name === null) {
1433: if (empty($this->_junctionTableName)) {
1434: $tablesNames = array_map('Cake\Utility\Inflector::underscore', [
1435: $this->getSource()->getTable(),
1436: $this->getTarget()->getTable()
1437: ]);
1438: sort($tablesNames);
1439: $this->_junctionTableName = implode('_', $tablesNames);
1440: }
1441:
1442: return $this->_junctionTableName;
1443: }
1444:
1445: return $this->_junctionTableName = $name;
1446: }
1447:
1448: /**
1449: * Parse extra options passed in the constructor.
1450: *
1451: * @param array $opts original list of options passed in constructor
1452: * @return void
1453: */
1454: protected function _options(array $opts)
1455: {
1456: if (!empty($opts['targetForeignKey'])) {
1457: $this->setTargetForeignKey($opts['targetForeignKey']);
1458: }
1459: if (!empty($opts['joinTable'])) {
1460: $this->_junctionTableName($opts['joinTable']);
1461: }
1462: if (!empty($opts['through'])) {
1463: $this->setThrough($opts['through']);
1464: }
1465: if (!empty($opts['saveStrategy'])) {
1466: $this->setSaveStrategy($opts['saveStrategy']);
1467: }
1468: if (isset($opts['sort'])) {
1469: $this->setSort($opts['sort']);
1470: }
1471: }
1472: }
1473: