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