BelongsToMany::unlink()   B
last analyzed

Complexity

Conditions 9
Paths 28

Size

Total Lines 43

Duplication

Lines 7
Ratio 16.28 %

Importance

Changes 0
Metric Value
cc 9
nc 28
nop 3
dl 7
loc 43
rs 7.6764
c 0
b 0
f 0
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\Database\Expression\QueryExpression;
21
use Cake\Datasource\EntityInterface;
22
use Cake\Datasource\QueryInterface;
23
use Cake\ORM\Association;
24
use Cake\ORM\Association\Loader\SelectWithPivotLoader;
25
use Cake\ORM\Query;
26
use Cake\ORM\Table;
27
use Cake\Utility\Inflector;
28
use InvalidArgumentException;
29
use SplObjectStorage;
30
use Traversable;
31
32
/**
33
 * Represents an M - N relationship where there exists a junction - or join - table
34
 * that contains the association fields between the source and the target table.
35
 *
36
 * An example of a BelongsToMany association would be Article belongs to many Tags.
37
 */
38
class BelongsToMany extends Association
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|string[]
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 string[]
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 array|null
143
     */
144
    protected $_targetConditions;
145
146
    /**
147
     * Filtered conditions that reference the junction table.
148
     *
149
     * @var array|null
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|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|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 View Code Duplication
    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 View Code Duplication
    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 View Code Duplication
    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 View Code Duplication
        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 View Code Duplication
        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 View Code Duplication
        if (!$junction->hasAssociation($tAlias)) {
425
            $junction->belongsTo($tAlias, [
426
                'foreignKey' => $this->getTargetForeignKey(),
427
                'targetTable' => $target,
428
            ]);
429
        }
430 View Code Duplication
        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 (QueryExpression $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;
0 ignored issues
show
Bug Best Practice introduced by
The return type of return false; (false) is incompatible with the return type of the parent method Cake\ORM\Association::_joinCondition of type array.

If you return a value from a function or method, it should be a sub-type of the type that is given by the parent type f.e. an interface, or abstract method. This is more formally defined by the Lizkov substitution principle, and guarantees that classes that depend on the parent type can use any instance of a child type interchangably. This principle also belongs to the SOLID principles for object oriented design.

Let’s take a look at an example:

class Author {
    private $name;

    public function __construct($name) {
        $this->name = $name;
    }

    public function getName() {
        return $this->name;
    }
}

abstract class Post {
    public function getAuthor() {
        return 'Johannes';
    }
}

class BlogPost extends Post {
    public function getAuthor() {
        return new Author('Johannes');
    }
}

class ForumPost extends Post { /* ... */ }

function my_function(Post $post) {
    echo strtoupper($post->getAuthor());
}

Our function my_function expects a Post object, and outputs the author of the post. The base class Post returns a simple string and outputting a simple string will work just fine. However, the child class BlogPost which is a sub-type of Post instead decided to return an object, and is therefore violating the SOLID principles. If a BlogPost were passed to my_function, PHP would not complain, but ultimately fail when executing the strtoupper call in its body.

Loading history...
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(),
0 ignored issues
show
Documentation Bug introduced by
The method getAlias does not exist on object<Cake\ORM\Association\BelongsToMany>? Since you implemented __call, maybe consider adding a @method annotation.

If you implement __call and you know which methods are available, you can improve IDE auto-completion and static analysis by adding a @method annotation to the class.

This is often the case, when __call is implemented by a parent class and only the child class knows which methods exist:

class ParentClass {
    private $data = array();

    public function __call($method, array $args) {
        if (0 === strpos($method, 'get')) {
            return $this->data[strtolower(substr($method, 3))];
        }

        throw new \LogicException(sprintf('Unsupported method: %s', $method));
    }
}

/**
 * If this class knows which fields exist, you can specify the methods here:
 *
 * @method string getName()
 */
class SomeClass extends ParentClass { }
Loading history...
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 View Code Duplication
    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 View Code Duplication
    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 \Cake\Datasource\EntityInterface|false 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 View Code Duplication
        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());
0 ignored issues
show
Bug introduced by
The method getErrors() does not exist on Cake\Datasource\EntityInterface. Did you maybe mean errors()?

This check marks calls to methods that do not seem to exist on an object.

This is most likely the result of a method being renamed without all references to it being renamed likewise.

Loading history...
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 \Cake\Datasource\EntityInterface[] $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 \Cake\Datasource\EntityInterface[] $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 \Cake\Datasource\EntityInterface[] $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 View Code Duplication
        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() . '.';
0 ignored issues
show
Documentation Bug introduced by
The method getAlias does not exist on object<Cake\ORM\Association\BelongsToMany>? Since you implemented __call, maybe consider adding a @method annotation.

If you implement __call and you know which methods are available, you can improve IDE auto-completion and static analysis by adding a @method annotation to the class.

This is often the case, when __call is implemented by a parent class and only the child class knows which methods exist:

class ParentClass {
    private $data = array();

    public function __call($method, array $args) {
        if (0 === strpos($method, 'get')) {
            return $this->data[strtolower(substr($method, 3))];
        }

        throw new \LogicException(sprintf('Unsupported method: %s', $method));
    }
}

/**
 * If this class knows which fields exist, you can specify the methods here:
 *
 * @method string getName()
 */
class SomeClass extends ParentClass { }
Loading history...
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)) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $inserts of type array is implicitly converted to a boolean; are you sure this is intended? If so, consider using ! empty($expr) instead to make it clear that you intend to check for an array without elements.

This check marks implicit conversions of arrays to boolean values in a comparison. While in PHP an empty array is considered to be equal (but not identical) to false, this is not always apparent.

Consider making the comparison explicit by using empty(..) or ! empty(...) instead.

Loading history...
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;
0 ignored issues
show
Bug introduced by
Consider using a different name than the imported variable $targetEntities, or did you forget to import by reference?

It seems like you are assigning to a variable which was imported through a use statement which was not imported by reference.

For clarity, we suggest to use a different name or import by reference depending on whether you would like to have the change visibile in outer-scope.

Change not visible in outer-scope

$x = 1;
$callable = function() use ($x) {
    $x = 2; // Not visible in outer scope. If you would like this, how
            // about using a different variable name than $x?
};

$callable();
var_dump($x); // integer(1)

Change visible in outer-scope

$x = 1;
$callable = function() use (&$x) {
    $x = 2;
};

$callable();
var_dump($x); // integer(2)
Loading history...
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 \Cake\Datasource\EntityInterface[] $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) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $deletes of type array is implicitly converted to a boolean; are you sure this is intended? If so, consider using ! empty($expr) instead to make it clear that you intend to check for an array without elements.

This check marks implicit conversions of arrays to boolean values in a comparison. While in PHP an empty array is considered to be equal (but not identical) to false, this is not always apparent.

Consider making the comparison explicit by using empty(..) or ! empty(...) instead.

Loading history...
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 \Cake\Datasource\EntityInterface[] $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 \Cake\Datasource\EntityInterface[]
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