Completed
Push — master ( 84dee3...084275 )
by Michal
36:03
created

BelongsToMany::targetConditions()   B

Complexity

Conditions 8
Paths 6

Size

Total Lines 20
Code Lines 14

Duplication

Lines 0
Ratio 0 %

Importance

Changes 1
Bugs 0 Features 0
Metric Value
c 1
b 0
f 0
dl 0
loc 20
rs 7.7777
cc 8
eloc 14
nc 6
nop 0
1
<?php
2
/**
3
 * CakePHP(tm) : Rapid Development Framework (http://cakephp.org)
4
 * Copyright (c) Cake Software Foundation, Inc. (http://cakefoundation.org)
5
 *
6
 * Licensed under The MIT License
7
 * For full copyright and license information, please see the LICENSE.txt
8
 * Redistributions of files must retain the above copyright notice.
9
 *
10
 * @copyright     Copyright (c) Cake Software Foundation, Inc. (http://cakefoundation.org)
11
 * @link          http://cakephp.org CakePHP(tm) Project
12
 * @since         3.0.0
13
 * @license       http://www.opensource.org/licenses/mit-license.php MIT License
14
 */
15
namespace Cake\ORM\Association;
16
17
use Cake\Database\ExpressionInterface;
18
use Cake\Datasource\EntityInterface;
19
use Cake\ORM\Association;
20
use Cake\ORM\Query;
21
use Cake\ORM\Table;
22
use Cake\Utility\Inflector;
23
use InvalidArgumentException;
24
use RuntimeException;
25
use SplObjectStorage;
26
use Traversable;
27
28
/**
29
 * Represents an M - N relationship where there exists a junction - or join - table
30
 * that contains the association fields between the source and the target table.
31
 *
32
 * An example of a BelongsToMany association would be Article belongs to many Tags.
33
 */
34
class BelongsToMany extends Association
35
{
36
37
    use ExternalAssociationTrait {
38
        _options as _externalOptions;
39
        _buildQuery as _buildBaseQuery;
40
    }
41
42
    /**
43
     * Saving strategy that will only append to the links set
44
     *
45
     * @var string
46
     */
47
    const SAVE_APPEND = 'append';
48
49
    /**
50
     * Saving strategy that will replace the links with the provided set
51
     *
52
     * @var string
53
     */
54
    const SAVE_REPLACE = 'replace';
55
56
    /**
57
     * The type of join to be used when adding the association to a query
58
     *
59
     * @var string
60
     */
61
    protected $_joinType = 'INNER';
62
63
    /**
64
     * The strategy name to be used to fetch associated records.
65
     *
66
     * @var string
67
     */
68
    protected $_strategy = self::STRATEGY_SELECT;
69
70
    /**
71
     * Junction table instance
72
     *
73
     * @var \Cake\ORM\Table
74
     */
75
    protected $_junctionTable;
76
77
    /**
78
     * Junction table name
79
     *
80
     * @var string
81
     */
82
    protected $_junctionTableName;
83
84
    /**
85
     * The name of the hasMany association from the target table
86
     * to the junction table
87
     *
88
     * @var string
89
     */
90
    protected $_junctionAssociationName;
91
92
    /**
93
     * The name of the property to be set containing data from the junction table
94
     * once a record from the target table is hydrated
95
     *
96
     * @var string
97
     */
98
    protected $_junctionProperty = '_joinData';
99
100
    /**
101
     * Saving strategy to be used by this association
102
     *
103
     * @var string
104
     */
105
    protected $_saveStrategy = self::SAVE_REPLACE;
106
107
    /**
108
     * The name of the field representing the foreign key to the target table
109
     *
110
     * @var string|array
111
     */
112
    protected $_targetForeignKey;
113
114
    /**
115
     * The table instance for the junction relation.
116
     *
117
     * @var string|\Cake\ORM\Table
118
     */
119
    protected $_through;
120
121
    /**
122
     * Valid strategies for this type of association
123
     *
124
     * @var array
125
     */
126
    protected $_validStrategies = [self::STRATEGY_SELECT, self::STRATEGY_SUBQUERY];
127
128
    /**
129
     * Whether the records on the joint table should be removed when a record
130
     * on the source table is deleted.
131
     *
132
     * Defaults to true for backwards compatibility.
133
     *
134
     * @var bool
135
     */
136
    protected $_dependent = true;
137
138
    /**
139
     * Filtered conditions that reference the target table.
140
     *
141
     * @var null|array
142
     */
143
    protected $_targetConditions;
144
145
    /**
146
     * Filtered conditions that reference the junction table.
147
     *
148
     * @var null|array
149
     */
150
    protected $_junctionConditions;
151
152
    /**
153
     * Sets the name of the field representing the foreign key to the target table.
154
     * If no parameters are passed current field is returned
155
     *
156
     * @param string|null $key the key to be used to link both tables together
157
     * @return string
158
     */
159 View Code Duplication
    public function targetForeignKey($key = null)
160
    {
161
        if ($key === null) {
162
            if ($this->_targetForeignKey === null) {
163
                $this->_targetForeignKey = $this->_modelKey($this->target()->alias());
164
            }
165
            return $this->_targetForeignKey;
166
        }
167
        return $this->_targetForeignKey = $key;
168
    }
169
170
    /**
171
     * Sets the table instance for the junction relation. If no arguments
172
     * are passed, the current configured table instance is returned
173
     *
174
     * @param string|\Cake\ORM\Table|null $table Name or instance for the join table
175
     * @return \Cake\ORM\Table
176
     */
177
    public function junction($table = null)
178
    {
179
        $tableLocator = $this->tableLocator();
180
181
        if ($table === null) {
182
            if (!empty($this->_junctionTable)) {
183
                return $this->_junctionTable;
184
            }
185
186
            if (!empty($this->_through)) {
187
                $table = $this->_through;
188
            } else {
189
                $tableName = $this->_junctionTableName();
190
                $tableAlias = Inflector::camelize($tableName);
191
192
                $config = [];
193
                if (!$tableLocator->exists($tableAlias)) {
0 ignored issues
show
Bug introduced by
It seems like $tableAlias defined by \Cake\Utility\Inflector::camelize($tableName) on line 190 can also be of type boolean; however, Cake\ORM\Locator\LocatorInterface::exists() does only seem to accept string, maybe add an additional type check?

If a method or function can return multiple different values and unless you are sure that you only can receive a single value in this context, we recommend to add an additional type check:

/**
 * @return array|string
 */
function returnsDifferentValues($x) {
    if ($x) {
        return 'foo';
    }

    return array();
}

$x = returnsDifferentValues($y);
if (is_array($x)) {
    // $x is an array.
}

If this a common case that PHP Analyzer should handle natively, please let us know by opening an issue.

Loading history...
194
                    $config = ['table' => $tableName];
195
                }
196
                $table = $tableLocator->get($tableAlias, $config);
0 ignored issues
show
Bug introduced by
It seems like $tableAlias defined by \Cake\Utility\Inflector::camelize($tableName) on line 190 can also be of type boolean; however, Cake\ORM\Locator\LocatorInterface::get() does only seem to accept string, maybe add an additional type check?

If a method or function can return multiple different values and unless you are sure that you only can receive a single value in this context, we recommend to add an additional type check:

/**
 * @return array|string
 */
function returnsDifferentValues($x) {
    if ($x) {
        return 'foo';
    }

    return array();
}

$x = returnsDifferentValues($y);
if (is_array($x)) {
    // $x is an array.
}

If this a common case that PHP Analyzer should handle natively, please let us know by opening an issue.

Loading history...
197
            }
198
        }
199
200
        if (is_string($table)) {
201
            $table = $tableLocator->get($table);
202
        }
203
        $target = $this->target();
204
        $source = $this->source();
205
206
        $this->_generateSourceAssociations($table, $source);
207
        $this->_generateTargetAssociations($table, $source, $target);
208
        $this->_generateJunctionAssociations($table, $source, $target);
209
        return $this->_junctionTable = $table;
210
    }
211
212
    /**
213
     * Generate reciprocal associations as necessary.
214
     *
215
     * Generates the following associations:
216
     *
217
     * - target hasMany junction e.g. Articles hasMany ArticlesTags
218
     * - target belongsToMany source e.g Articles belongsToMany Tags.
219
     *
220
     * You can override these generated associations by defining associations
221
     * with the correct aliases.
222
     *
223
     * @param \Cake\ORM\Table $junction The junction table.
224
     * @param \Cake\ORM\Table $source The source table.
225
     * @param \Cake\ORM\Table $target The target table.
226
     * @return void
227
     */
228
    protected function _generateTargetAssociations($junction, $source, $target)
229
    {
230
        $junctionAlias = $junction->alias();
231
        $sAlias = $source->alias();
232
233 View Code Duplication
        if (!$target->association($junctionAlias)) {
234
            $target->hasMany($junctionAlias, [
235
                'targetTable' => $junction,
236
                'foreignKey' => $this->targetForeignKey(),
237
            ]);
238
        }
239
        if (!$target->association($sAlias)) {
240
            $target->belongsToMany($sAlias, [
241
                'sourceTable' => $target,
242
                'targetTable' => $source,
243
                'foreignKey' => $this->targetForeignKey(),
244
                'targetForeignKey' => $this->foreignKey(),
245
                'through' => $junction,
246
                'conditions' => $this->conditions(),
247
            ]);
248
        }
249
    }
250
251
    /**
252
     * Generate additional source table associations as necessary.
253
     *
254
     * Generates the following associations:
255
     *
256
     * - source hasMany junction e.g. Tags hasMany ArticlesTags
257
     *
258
     * You can override these generated associations by defining associations
259
     * with the correct aliases.
260
     *
261
     * @param \Cake\ORM\Table $junction The junction table.
262
     * @param \Cake\ORM\Table $source The source table.
263
     * @return void
264
     */
265
    protected function _generateSourceAssociations($junction, $source)
266
    {
267
        $junctionAlias = $junction->alias();
268
        if (!$source->association($junctionAlias)) {
269
            $source->hasMany($junctionAlias, [
270
                'targetTable' => $junction,
271
                'foreignKey' => $this->foreignKey(),
272
            ]);
273
        }
274
    }
275
276
    /**
277
     * Generate associations on the junction table as necessary
278
     *
279
     * Generates the following associations:
280
     *
281
     * - junction belongsTo source e.g. ArticlesTags belongsTo Tags
282
     * - junction belongsTo target e.g. ArticlesTags belongsTo Articles
283
     *
284
     * You can override these generated associations by defining associations
285
     * with the correct aliases.
286
     *
287
     * @param \Cake\ORM\Table $junction The junction table.
288
     * @param \Cake\ORM\Table $source The source table.
289
     * @param \Cake\ORM\Table $target The target table.
290
     * @return void
291
     */
292
    protected function _generateJunctionAssociations($junction, $source, $target)
293
    {
294
        $tAlias = $target->alias();
295
        $sAlias = $source->alias();
296
297 View Code Duplication
        if (!$junction->association($tAlias)) {
298
            $junction->belongsTo($tAlias, [
299
                'foreignKey' => $this->targetForeignKey(),
300
                'targetTable' => $target
301
            ]);
302
        }
303
        if (!$junction->association($sAlias)) {
304
            $junction->belongsTo($sAlias, [
305
                'foreignKey' => $this->foreignKey(),
306
                'targetTable' => $source
307
            ]);
308
        }
309
    }
310
311
    /**
312
     * Alters a Query object to include the associated target table data in the final
313
     * result
314
     *
315
     * The options array accept the following keys:
316
     *
317
     * - includeFields: Whether to include target model fields in the result or not
318
     * - foreignKey: The name of the field to use as foreign key, if false none
319
     *   will be used
320
     * - conditions: array with a list of conditions to filter the join with
321
     * - fields: a list of fields in the target table to include in the result
322
     * - type: The type of join to be used (e.g. INNER)
323
     *
324
     * @param Query $query the query to be altered to include the target table data
325
     * @param array $options Any extra options or overrides to be taken in account
326
     * @return void
327
     */
328
    public function attachTo(Query $query, array $options = [])
329
    {
330
        parent::attachTo($query, $options);
331
332
        $junction = $this->junction();
333
        $belongsTo = $junction->association($this->source()->alias());
334
        $cond = $belongsTo->_joinCondition(['foreignKey' => $belongsTo->foreignKey()]);
335
        $cond += $this->junctionConditions();
336
337
        if (isset($options['includeFields'])) {
338
            $includeFields = $options['includeFields'];
339
        }
340
341
        // Attach the junction table as well we need it to populate _joinData.
342
        $assoc = $this->_targetTable->association($junction->alias());
343
        $query->removeJoin($assoc->name());
344
        $options = array_intersect_key($options, ['joinType' => 1, 'fields' => 1]);
345
        $options += [
346
            'conditions' => $cond,
347
            'includeFields' => $includeFields,
0 ignored issues
show
Bug introduced by
The variable $includeFields does not seem to be defined for all execution paths leading up to this point.

If you define a variable conditionally, it can happen that it is not defined for all execution paths.

Let’s take a look at an example:

function myFunction($a) {
    switch ($a) {
        case 'foo':
            $x = 1;
            break;

        case 'bar':
            $x = 2;
            break;
    }

    // $x is potentially undefined here.
    echo $x;
}

In the above example, the variable $x is defined if you pass “foo” or “bar” as argument for $a. However, since the switch statement has no default case statement, if you pass any other value, the variable $x would be undefined.

Available Fixes

  1. Check for existence of the variable explicitly:

    function myFunction($a) {
        switch ($a) {
            case 'foo':
                $x = 1;
                break;
    
            case 'bar':
                $x = 2;
                break;
        }
    
        if (isset($x)) { // Make sure it's always set.
            echo $x;
        }
    }
    
  2. Define a default value for the variable:

    function myFunction($a) {
        $x = ''; // Set a default which gets overridden for certain paths.
        switch ($a) {
            case 'foo':
                $x = 1;
                break;
    
            case 'bar':
                $x = 2;
                break;
        }
    
        echo $x;
    }
    
  3. Add a value for the missing path:

    function myFunction($a) {
        switch ($a) {
            case 'foo':
                $x = 1;
                break;
    
            case 'bar':
                $x = 2;
                break;
    
            // We add support for the missing case.
            default:
                $x = '';
                break;
        }
    
        echo $x;
    }
    
Loading history...
348
            'foreignKey' => $this->targetForeignKey(),
349
        ];
350
        $assoc->attachTo($query, $options);
351
        $query->eagerLoader()->addToJoinsMap($junction->alias(), $assoc, true);
0 ignored issues
show
Bug introduced by
The method addToJoinsMap does only exist in Cake\ORM\EagerLoader, but not in Cake\ORM\Query.

It seems like the method you are trying to call exists only in some of the possible types.

Let’s take a look at an example:

class A
{
    public function foo() { }
}

class B extends A
{
    public function bar() { }
}

/**
 * @param A|B $x
 */
function someFunction($x)
{
    $x->foo(); // This call is fine as the method exists in A and B.
    $x->bar(); // This method only exists in B and might cause an error.
}

Available Fixes

  1. Add an additional type-check:

    /**
     * @param A|B $x
     */
    function someFunction($x)
    {
        $x->foo();
    
        if ($x instanceof B) {
            $x->bar();
        }
    }
    
  2. Only allow a single type to be passed if the variable comes from a parameter:

    function someFunction(B $x) { /** ... */ }
    
Loading history...
Bug introduced by
It seems like $assoc defined by $this->_targetTable->ass...ion($junction->alias()) on line 342 can be null; however, Cake\ORM\EagerLoader::addToJoinsMap() does not accept null, maybe add an additional type check?

Unless you are absolutely sure that the expression can never be null because of other conditions, we strongly recommend to add an additional type check to your code:

/** @return stdClass|null */
function mayReturnNull() { }

function doesNotAcceptNull(stdClass $x) { }

// With potential error.
function withoutCheck() {
    $x = mayReturnNull();
    doesNotAcceptNull($x); // Potential error here.
}

// Safe - Alternative 1
function withCheck1() {
    $x = mayReturnNull();
    if ( ! $x instanceof stdClass) {
        throw new \LogicException('$x must be defined.');
    }
    doesNotAcceptNull($x);
}

// Safe - Alternative 2
function withCheck2() {
    $x = mayReturnNull();
    if ($x instanceof stdClass) {
        doesNotAcceptNull($x);
    }
}
Loading history...
352
    }
353
354
    /**
355
     * {@inheritDoc}
356
     */
357
    protected function _appendNotMatching($query, $options)
358
    {
359
        $target = $this->junction();
360 View Code Duplication
        if (!empty($options['negateMatch'])) {
361
            $primaryKey = $query->aliasFields((array)$target->primaryKey(), $target->alias());
362
            $query->andWhere(function ($exp) use ($primaryKey) {
363
                array_map([$exp, 'isNull'], $primaryKey);
364
                return $exp;
365
            });
366
        }
367
    }
368
369
    /**
370
     * {@inheritDoc}
371
     */
372
    public function transformRow($row, $nestKey, $joined)
373
    {
374
        $alias = $this->junction()->alias();
375
        if ($joined) {
376
            $row[$this->target()->alias()][$this->_junctionProperty] = $row[$alias];
377
            unset($row[$alias]);
378
        }
379
380
        return parent::transformRow($row, $nestKey, $joined);
381
    }
382
383
    /**
384
     * Get the relationship type.
385
     *
386
     * @return string
387
     */
388
    public function type()
389
    {
390
        return self::MANY_TO_MANY;
391
    }
392
393
    /**
394
     * Return false as join conditions are defined in the junction table
395
     *
396
     * @param array $options list of options passed to attachTo method
397
     * @return bool false
398
     */
399
    protected function _joinCondition($options)
400
    {
401
        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...
402
    }
403
404
    /**
405
     * Builds an array containing the results from fetchQuery indexed by
406
     * the foreignKey value corresponding to this association.
407
     *
408
     * @param \Cake\ORM\Query $fetchQuery The query to get results from
409
     * @param array $options The options passed to the eager loader
410
     * @return array
411
     * @throws \RuntimeException when the association property is not part of the results set.
412
     */
413
    protected function _buildResultMap($fetchQuery, $options)
414
    {
415
        $resultMap = [];
416
        $key = (array)$options['foreignKey'];
417
        $property = $this->target()->association($this->junction()->alias())->property();
418
        $hydrated = $fetchQuery->hydrate();
419
420
        foreach ($fetchQuery->all() as $result) {
421
            if (!isset($result[$property])) {
422
                throw new RuntimeException(sprintf(
423
                    '"%s" is missing from the belongsToMany results. Results cannot be created.',
424
                    $property
425
                ));
426
            }
427
            $result[$this->_junctionProperty] = $result[$property];
428
            unset($result[$property]);
429
430
            if ($hydrated) {
431
                $result->dirty($this->_junctionProperty, false);
432
            }
433
434
            $values = [];
435
            foreach ($key as $k) {
436
                $values[] = $result[$this->_junctionProperty][$k];
437
            }
438
            $resultMap[implode(';', $values)][] = $result;
439
        }
440
        return $resultMap;
441
    }
442
443
    /**
444
     * Clear out the data in the junction table for a given entity.
445
     *
446
     * @param \Cake\Datasource\EntityInterface $entity The entity that started the cascading delete.
447
     * @param array $options The options for the original delete.
448
     * @return bool Success.
449
     */
450
    public function cascadeDelete(EntityInterface $entity, array $options = [])
451
    {
452
        if (!$this->dependent()) {
453
            return true;
454
        }
455
        $foreignKey = (array)$this->foreignKey();
456
        $bindingKey = (array)$this->bindingKey();
457
        $conditions = [];
458
459
        if (!empty($bindingKey)) {
460
            $conditions = array_combine($foreignKey, $entity->extract($bindingKey));
461
        }
462
463
        $table = $this->junction();
464
        $hasMany = $this->source()->association($table->alias());
465 View Code Duplication
        if ($this->_cascadeCallbacks) {
466
            foreach ($hasMany->find('all')->where($conditions)->toList() as $related) {
467
                $table->delete($related, $options);
468
            }
469
            return true;
470
        }
471
472
        $conditions = array_merge($conditions, $hasMany->conditions());
473
        return $table->deleteAll($conditions);
0 ignored issues
show
Bug Best Practice introduced by
The return type of return $table->deleteAll($conditions); (integer) is incompatible with the return type declared by the abstract method Cake\ORM\Association::cascadeDelete of type boolean.

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...
474
    }
475
476
    /**
477
     * Returns boolean true, as both of the tables 'own' rows in the other side
478
     * of the association via the joint table.
479
     *
480
     * @param \Cake\ORM\Table $side The potential Table with ownership
481
     * @return bool
482
     */
483
    public function isOwningSide(Table $side)
484
    {
485
        return true;
486
    }
487
488
    /**
489
     * Sets the strategy that should be used for saving. If called with no
490
     * arguments, it will return the currently configured strategy
491
     *
492
     * @param string|null $strategy the strategy name to be used
493
     * @throws \InvalidArgumentException if an invalid strategy name is passed
494
     * @return string the strategy to be used for saving
495
     */
496 View Code Duplication
    public function saveStrategy($strategy = null)
497
    {
498
        if ($strategy === null) {
499
            return $this->_saveStrategy;
500
        }
501
        if (!in_array($strategy, [self::SAVE_APPEND, self::SAVE_REPLACE])) {
502
            $msg = sprintf('Invalid save strategy "%s"', $strategy);
503
            throw new InvalidArgumentException($msg);
504
        }
505
        return $this->_saveStrategy = $strategy;
506
    }
507
508
    /**
509
     * Takes an entity from the source table and looks if there is a field
510
     * matching the property name for this association. The found entity will be
511
     * saved on the target table for this association by passing supplied
512
     * `$options`
513
     *
514
     * When using the 'append' strategy, this function will only create new links
515
     * between each side of this association. It will not destroy existing ones even
516
     * though they may not be present in the array of entities to be saved.
517
     *
518
     * When using the 'replace' strategy, existing links will be removed and new links
519
     * will be created in the joint table. If there exists links in the database to some
520
     * of the entities intended to be saved by this method, they will be updated,
521
     * not deleted.
522
     *
523
     * @param \Cake\Datasource\EntityInterface $entity an entity from the source table
524
     * @param array|\ArrayObject $options options to be passed to the save method in
525
     * the target table
526
     * @throws \InvalidArgumentException if the property representing the association
527
     * in the parent entity cannot be traversed
528
     * @return bool|\Cake\Datasource\EntityInterface false if $entity could not be saved, otherwise it returns
529
     * the saved entity
530
     * @see Table::save()
531
     * @see BelongsToMany::replaceLinks()
532
     */
533
    public function saveAssociated(EntityInterface $entity, array $options = [])
534
    {
535
        $targetEntity = $entity->get($this->property());
536
        $strategy = $this->saveStrategy();
537
538
        $isEmpty = in_array($targetEntity, [null, [], '', false], true);
539
        if ($isEmpty && $entity->isNew()) {
540
            return $entity;
541
        }
542
        if ($isEmpty) {
543
            $targetEntity = [];
544
        }
545
546
        if ($strategy === self::SAVE_APPEND) {
547
            return $this->_saveTarget($entity, $targetEntity, $options);
548
        }
549
550
        if ($this->replaceLinks($entity, $targetEntity, $options)) {
551
            return $entity;
552
        }
553
554
        return false;
555
    }
556
557
    /**
558
     * Persists each of the entities into the target table and creates links between
559
     * the parent entity and each one of the saved target entities.
560
     *
561
     * @param \Cake\Datasource\EntityInterface $parentEntity the source entity containing the target
562
     * entities to be saved.
563
     * @param array|\Traversable $entities list of entities to persist in target table and to
564
     * link to the parent entity
565
     * @param array $options list of options accepted by `Table::save()`
566
     * @throws \InvalidArgumentException if the property representing the association
567
     * in the parent entity cannot be traversed
568
     * @return \Cake\Datasource\EntityInterface|bool The parent entity after all links have been
569
     * created if no errors happened, false otherwise
570
     */
571
    protected function _saveTarget(EntityInterface $parentEntity, $entities, $options)
572
    {
573
        $joinAssociations = false;
574
        if (!empty($options['associated'][$this->_junctionProperty]['associated'])) {
575
            $joinAssociations = $options['associated'][$this->_junctionProperty]['associated'];
576
        }
577
        unset($options['associated'][$this->_junctionProperty]);
578
579 View Code Duplication
        if (!(is_array($entities) || $entities instanceof Traversable)) {
580
            $name = $this->property();
581
            $message = sprintf('Could not save %s, it cannot be traversed', $name);
582
            throw new InvalidArgumentException($message);
583
        }
584
585
        $table = $this->target();
586
        $original = $entities;
587
        $persisted = [];
588
589
        foreach ($entities as $k => $entity) {
590
            if (!($entity instanceof EntityInterface)) {
591
                break;
592
            }
593
594
            if (!empty($options['atomic'])) {
595
                $entity = clone $entity;
596
            }
597
598
            if ($table->save($entity, $options)) {
599
                $entities[$k] = $entity;
600
                $persisted[] = $entity;
601
                continue;
602
            }
603
604
            if (!empty($options['atomic'])) {
605
                $original[$k]->errors($entity->errors());
606
                return false;
607
            }
608
        }
609
610
        $options['associated'] = $joinAssociations;
611
        $success = $this->_saveLinks($parentEntity, $persisted, $options);
612
        if (!$success && !empty($options['atomic'])) {
613
            $parentEntity->set($this->property(), $original);
614
            return false;
615
        }
616
617
        $parentEntity->set($this->property(), $entities);
618
        return $parentEntity;
619
    }
620
621
    /**
622
     * Creates links between the source entity and each of the passed target entities
623
     *
624
     * @param \Cake\Datasource\EntityInterface $sourceEntity the entity from source table in this
625
     * association
626
     * @param array $targetEntities list of entities to link to link to the source entity using the
627
     * junction table
628
     * @param array $options list of options accepted by `Table::save()`
629
     * @return bool success
630
     */
631
    protected function _saveLinks(EntityInterface $sourceEntity, $targetEntities, $options)
632
    {
633
        $target = $this->target();
634
        $junction = $this->junction();
635
        $entityClass = $junction->entityClass();
636
        $belongsTo = $junction->association($target->alias());
637
        $foreignKey = (array)$this->foreignKey();
638
        $assocForeignKey = (array)$belongsTo->foreignKey();
639
        $targetPrimaryKey = (array)$target->primaryKey();
640
        $bindingKey = (array)$this->bindingKey();
641
        $jointProperty = $this->_junctionProperty;
642
        $junctionAlias = $junction->alias();
643
644
        foreach ($targetEntities as $e) {
645
            $joint = $e->get($jointProperty);
646
            if (!$joint || !($joint instanceof EntityInterface)) {
647
                $joint = new $entityClass([], ['markNew' => true, 'source' => $junctionAlias]);
0 ignored issues
show
Security Code Execution introduced by
$entityClass can contain request data and is used in code execution context(s) leading to a potential security vulnerability.

General Strategies to prevent injection

In general, it is advisable to prevent any user-data to reach this point. This can be done by white-listing certain values:

if ( ! in_array($value, array('this-is-allowed', 'and-this-too'), true)) {
    throw new \InvalidArgumentException('This input is not allowed.');
}

For numeric data, we recommend to explicitly cast the data:

$sanitized = (integer) $tainted;
Loading history...
648
            }
649
            $sourceKeys = array_combine($foreignKey, $sourceEntity->extract($bindingKey));
650
            $targetKeys = array_combine($assocForeignKey, $e->extract($targetPrimaryKey));
651
652
            if ($sourceKeys !== $joint->extract($foreignKey)) {
653
                $joint->set($sourceKeys, ['guard' => false]);
654
            }
655
656
            if ($targetKeys !== $joint->extract($assocForeignKey)) {
657
                $joint->set($targetKeys, ['guard' => false]);
658
            }
659
660
            $saved = $junction->save($joint, $options);
661
662
            if (!$saved && !empty($options['atomic'])) {
663
                return false;
664
            }
665
666
            $e->set($jointProperty, $joint);
667
            $e->dirty($jointProperty, false);
668
        }
669
670
        return true;
671
    }
672
673
    /**
674
     * Associates the source entity to each of the target entities provided by
675
     * creating links in the junction table. Both the source entity and each of
676
     * the target entities are assumed to be already persisted, if the are marked
677
     * as new or their status is unknown, an exception will be thrown.
678
     *
679
     * When using this method, all entities in `$targetEntities` will be appended to
680
     * the source entity's property corresponding to this association object.
681
     *
682
     * This method does not check link uniqueness.
683
     *
684
     * ### Example:
685
     *
686
     * ```
687
     * $newTags = $tags->find('relevant')->execute();
688
     * $articles->association('tags')->link($article, $newTags);
689
     * ```
690
     *
691
     * `$article->get('tags')` will contain all tags in `$newTags` after liking
692
     *
693
     * @param \Cake\Datasource\EntityInterface $sourceEntity the row belonging to the `source` side
694
     * of this association
695
     * @param array $targetEntities list of entities belonging to the `target` side
696
     * of this association
697
     * @param array $options list of options to be passed to the internal `save` call
698
     * @throws \InvalidArgumentException when any of the values in $targetEntities is
699
     * detected to not be already persisted
700
     * @return bool true on success, false otherwise
701
     */
702
    public function link(EntityInterface $sourceEntity, array $targetEntities, array $options = [])
703
    {
704
        $this->_checkPersistenceStatus($sourceEntity, $targetEntities);
705
        $property = $this->property();
706
        $links = $sourceEntity->get($property) ?: [];
707
        $links = array_merge($links, $targetEntities);
708
        $sourceEntity->set($property, $links);
709
710
        return $this->junction()->connection()->transactional(
711
            function () use ($sourceEntity, $targetEntities, $options) {
712
                return $this->_saveLinks($sourceEntity, $targetEntities, $options);
713
            }
714
        );
715
    }
716
717
    /**
718
     * Removes all links between the passed source entity and each of the provided
719
     * target entities. This method assumes that all passed objects are already persisted
720
     * in the database and that each of them contain a primary key value.
721
     *
722
     * ### Options
723
     *
724
     * Additionally to the default options accepted by `Table::delete()`, the following
725
     * keys are supported:
726
     *
727
     * - cleanProperty: Whether or not to remove all the objects in `$targetEntities` that
728
     * are stored in `$sourceEntity` (default: true)
729
     *
730
     * By default this method will unset each of the entity objects stored inside the
731
     * source entity.
732
     *
733
     * ### Example:
734
     *
735
     * ```
736
     * $article->tags = [$tag1, $tag2, $tag3, $tag4];
737
     * $tags = [$tag1, $tag2, $tag3];
738
     * $articles->association('tags')->unlink($article, $tags);
739
     * ```
740
     *
741
     * `$article->get('tags')` will contain only `[$tag4]` after deleting in the database
742
     *
743
     * @param \Cake\Datasource\EntityInterface $sourceEntity an entity persisted in the source table for
744
     * this association
745
     * @param array $targetEntities list of entities persisted in the target table for
746
     * this association
747
     * @param array|bool $options list of options to be passed to the internal `delete` call,
748
     * or a `boolean`
749
     * @throws \InvalidArgumentException if non persisted entities are passed or if
750
     * any of them is lacking a primary key value
751
     * @return void
752
     */
753
    public function unlink(EntityInterface $sourceEntity, array $targetEntities, $options = [])
754
    {
755 View Code Duplication
        if (is_bool($options)) {
756
            $options = [
757
                'cleanProperty' => $options
758
            ];
759
        } else {
760
            $options += ['cleanProperty' => true];
761
        }
762
763
        $this->_checkPersistenceStatus($sourceEntity, $targetEntities);
764
        $property = $this->property();
765
766
        $this->junction()->connection()->transactional(
767
            function () use ($sourceEntity, $targetEntities, $options) {
768
                $links = $this->_collectJointEntities($sourceEntity, $targetEntities);
769
                foreach ($links as $entity) {
770
                    $this->_junctionTable->delete($entity, $options);
771
                }
772
            }
773
        );
774
775
        $existing = $sourceEntity->get($property) ?: [];
776
        if (!$options['cleanProperty'] || empty($existing)) {
777
            return;
778
        }
779
780
        $storage = new SplObjectStorage;
781
        foreach ($targetEntities as $e) {
782
            $storage->attach($e);
783
        }
784
785
        foreach ($existing as $k => $e) {
786
            if ($storage->contains($e)) {
787
                unset($existing[$k]);
788
            }
789
        }
790
791
        $sourceEntity->set($property, array_values($existing));
792
        $sourceEntity->dirty($property, false);
793
    }
794
795
    /**
796
     * {@inheritDoc}
797
     */
798
    public function conditions($conditions = null)
799
    {
800
        if ($conditions !== null) {
801
            $this->_conditions = $conditions;
802
            $this->_targetConditions = $this->_junctionConditions = [];
803
        }
804
        return $this->_conditions;
805
    }
806
807
    /**
808
     * Returns filtered conditions that reference the target table.
809
     *
810
     * Any string expressions, or expression objects will
811
     * also be returned in this list.
812
     *
813
     * @return mixed Generally an array. If the conditions
814
     *   are not an array, the association conditions will be
815
     *   returned unmodified.
816
     */
817
    protected function targetConditions()
818
    {
819
        if ($this->_targetConditions !== null) {
820
            return $this->_targetConditions;
821
        }
822
        $conditions = $this->conditions();
823
        if (!is_array($conditions)) {
824
            return $conditions;
825
        }
826
        $matching = [];
827
        $alias = $this->alias() . '.';
0 ignored issues
show
Documentation Bug introduced by
The method alias 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...
828
        foreach ($conditions as $field => $value) {
829
            if (is_string($field) && strpos($field, $alias) === 0) {
830
                $matching[$field] = $value;
831
            } elseif (is_int($field) || $value instanceof ExpressionInterface) {
832
                $matching[$field] = $value;
833
            }
834
        }
835
        return $this->_targetConditions = $matching;
836
    }
837
838
    /**
839
     * Returns filtered conditions that specifically reference
840
     * the junction table.
841
     *
842
     * @return array
843
     */
844
    protected function junctionConditions()
845
    {
846
        if ($this->_junctionConditions !== null) {
847
            return $this->_junctionConditions;
848
        }
849
        $matching = [];
850
        $conditions = $this->conditions();
851
        if (!is_array($conditions)) {
852
            return $matching;
853
        }
854
        $alias = $this->_junctionAssociationName() . '.';
855
        foreach ($conditions as $field => $value) {
856
            if (is_string($field) && strpos($field, $alias) === 0) {
857
                $matching[$field] = $value;
858
            }
859
        }
860
        return $this->_junctionConditions = $matching;
861
    }
862
863
    /**
864
     * Proxies the finding operation to the target table's find method
865
     * and modifies the query accordingly based of this association
866
     * configuration.
867
     *
868
     * If your association includes conditions, the junction table will be
869
     * included in the query's contained associations.
870
     *
871
     * @param string|array $type the type of query to perform, if an array is passed,
872
     *   it will be interpreted as the `$options` parameter
873
     * @param array $options The options to for the find
874
     * @see \Cake\ORM\Table::find()
875
     * @return \Cake\ORM\Query
876
     */
877
    public function find($type = null, array $options = [])
878
    {
879
        $type = $type ?: $this->finder();
880
        list($type, $opts) = $this->_extractFinder($type);
881
        $query = $this->target()
882
            ->find($type, $options + $opts)
883
            ->where($this->targetConditions());
884
885
        if (!$this->junctionConditions()) {
886
            return $query;
887
        }
888
889
        $belongsTo = $this->junction()->association($this->target()->alias());
890
        $conditions = $belongsTo->_joinCondition([
891
            'foreignKey' => $this->foreignKey()
892
        ]);
893
        $conditions += $this->junctionConditions();
894
        return $this->_appendJunctionJoin($query, $conditions);
895
    }
896
897
    /**
898
     * Append a join to the junction table.
899
     *
900
     * @param \Cake\ORM\Query $query The query to append.
901
     * @param string|array $conditions The query conditions to use.
902
     * @return \Cake\ORM\Query The modified query.
903
     */
904
    protected function _appendJunctionJoin($query, $conditions)
905
    {
906
        $name = $this->_junctionAssociationName();
907
        $joins = $query->join();
908
        $matching = [
909
            $name => [
910
                'table' => $this->junction()->table(),
911
                'conditions' => $conditions,
912
                'type' => 'INNER'
913
            ]
914
        ];
915
916
        $assoc = $this->target()->association($name);
917
        $query
918
            ->addDefaultTypes($assoc->target())
919
            ->join($matching + $joins, [], true);
920
        $query->eagerLoader()->addToJoinsMap($name, $assoc);
0 ignored issues
show
Bug introduced by
The method addToJoinsMap does only exist in Cake\ORM\EagerLoader, but not in Cake\ORM\Query.

It seems like the method you are trying to call exists only in some of the possible types.

Let’s take a look at an example:

class A
{
    public function foo() { }
}

class B extends A
{
    public function bar() { }
}

/**
 * @param A|B $x
 */
function someFunction($x)
{
    $x->foo(); // This call is fine as the method exists in A and B.
    $x->bar(); // This method only exists in B and might cause an error.
}

Available Fixes

  1. Add an additional type-check:

    /**
     * @param A|B $x
     */
    function someFunction($x)
    {
        $x->foo();
    
        if ($x instanceof B) {
            $x->bar();
        }
    }
    
  2. Only allow a single type to be passed if the variable comes from a parameter:

    function someFunction(B $x) { /** ... */ }
    
Loading history...
Bug introduced by
It seems like $assoc defined by $this->target()->association($name) on line 916 can be null; however, Cake\ORM\EagerLoader::addToJoinsMap() does not accept null, maybe add an additional type check?

Unless you are absolutely sure that the expression can never be null because of other conditions, we strongly recommend to add an additional type check to your code:

/** @return stdClass|null */
function mayReturnNull() { }

function doesNotAcceptNull(stdClass $x) { }

// With potential error.
function withoutCheck() {
    $x = mayReturnNull();
    doesNotAcceptNull($x); // Potential error here.
}

// Safe - Alternative 1
function withCheck1() {
    $x = mayReturnNull();
    if ( ! $x instanceof stdClass) {
        throw new \LogicException('$x must be defined.');
    }
    doesNotAcceptNull($x);
}

// Safe - Alternative 2
function withCheck2() {
    $x = mayReturnNull();
    if ($x instanceof stdClass) {
        doesNotAcceptNull($x);
    }
}
Loading history...
921
        return $query;
922
    }
923
924
    /**
925
     * Replaces existing association links between the source entity and the target
926
     * with the ones passed. This method does a smart cleanup, links that are already
927
     * persisted and present in `$targetEntities` will not be deleted, new links will
928
     * be created for the passed target entities that are not already in the database
929
     * and the rest will be removed.
930
     *
931
     * For example, if an article is linked to tags 'cake' and 'framework' and you pass
932
     * to this method an array containing the entities for tags 'cake', 'php' and 'awesome',
933
     * only the link for cake will be kept in database, the link for 'framework' will be
934
     * deleted and the links for 'php' and 'awesome' will be created.
935
     *
936
     * Existing links are not deleted and created again, they are either left untouched
937
     * or updated so that potential extra information stored in the joint row is not
938
     * lost. Updating the link row can be done by making sure the corresponding passed
939
     * target entity contains the joint property with its primary key and any extra
940
     * information to be stored.
941
     *
942
     * On success, the passed `$sourceEntity` will contain `$targetEntities` as  value
943
     * in the corresponding property for this association.
944
     *
945
     * This method assumes that links between both the source entity and each of the
946
     * target entities are unique. That is, for any given row in the source table there
947
     * can only be one link in the junction table pointing to any other given row in
948
     * the target table.
949
     *
950
     * Additional options for new links to be saved can be passed in the third argument,
951
     * check `Table::save()` for information on the accepted options.
952
     *
953
     * ### Example:
954
     *
955
     * ```
956
     * $article->tags = [$tag1, $tag2, $tag3, $tag4];
957
     * $articles->save($article);
958
     * $tags = [$tag1, $tag3];
959
     * $articles->association('tags')->replaceLinks($article, $tags);
960
     * ```
961
     *
962
     * `$article->get('tags')` will contain only `[$tag1, $tag3]` at the end
963
     *
964
     * @param \Cake\Datasource\EntityInterface $sourceEntity an entity persisted in the source table for
965
     * this association
966
     * @param array $targetEntities list of entities from the target table to be linked
967
     * @param array $options list of options to be passed to the internal `save`/`delete` calls
968
     * when persisting/updating new links, or deleting existing ones
969
     * @throws \InvalidArgumentException if non persisted entities are passed or if
970
     * any of them is lacking a primary key value
971
     * @return bool success
972
     */
973
    public function replaceLinks(EntityInterface $sourceEntity, array $targetEntities, array $options = [])
974
    {
975
        $bindingKey = (array)$this->bindingKey();
976
        $primaryValue = $sourceEntity->extract($bindingKey);
977
978
        if (count(array_filter($primaryValue, 'strlen')) !== count($bindingKey)) {
979
            $message = 'Could not find primary key value for source entity';
980
            throw new InvalidArgumentException($message);
981
        }
982
983
        return $this->junction()->connection()->transactional(
984
            function () use ($sourceEntity, $targetEntities, $primaryValue, $options) {
985
                $foreignKey = (array)$this->foreignKey();
986
                $hasMany = $this->source()->association($this->_junctionTable->alias());
987
                $existing = $hasMany->find('all')
988
                    ->where(array_combine($foreignKey, $primaryValue));
989
990
                $associationConditions = $this->conditions();
991
                if ($associationConditions) {
992
                    $existing->contain($this->target()->alias());
993
                    $existing->andWhere($associationConditions);
994
                }
995
996
                $jointEntities = $this->_collectJointEntities($sourceEntity, $targetEntities);
997
                $inserts = $this->_diffLinks($existing, $jointEntities, $targetEntities, $options);
998
999
                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...
1000
                    return false;
1001
                }
1002
1003
                $property = $this->property();
1004
1005
                if (count($inserts)) {
1006
                    $inserted = array_combine(
1007
                        array_keys($inserts),
1008
                        (array)$sourceEntity->get($property)
1009
                    );
1010
                    $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...
1011
                }
1012
1013
                ksort($targetEntities);
1014
                $sourceEntity->set($property, array_values($targetEntities));
1015
                $sourceEntity->dirty($property, false);
1016
                return true;
1017
            }
1018
        );
1019
    }
1020
1021
    /**
1022
     * Helper method used to delete the difference between the links passed in
1023
     * `$existing` and `$jointEntities`. This method will return the values from
1024
     * `$targetEntities` that were not deleted from calculating the difference.
1025
     *
1026
     * @param \Cake\ORM\Query $existing a query for getting existing links
1027
     * @param array $jointEntities link entities that should be persisted
1028
     * @param array $targetEntities entities in target table that are related to
1029
     * the `$jointEntities`
1030
     * @param array $options list of options accepted by `Table::delete()`
1031
     * @return array
1032
     */
1033
    protected function _diffLinks($existing, $jointEntities, $targetEntities, $options = [])
1034
    {
1035
        $junction = $this->junction();
1036
        $target = $this->target();
1037
        $belongsTo = $junction->association($target->alias());
1038
        $foreignKey = (array)$this->foreignKey();
1039
        $assocForeignKey = (array)$belongsTo->foreignKey();
1040
1041
        $keys = array_merge($foreignKey, $assocForeignKey);
1042
        $deletes = $indexed = $present = [];
1043
1044
        foreach ($jointEntities as $i => $entity) {
1045
            $indexed[$i] = $entity->extract($keys);
1046
            $present[$i] = array_values($entity->extract($assocForeignKey));
1047
        }
1048
1049
        foreach ($existing as $result) {
1050
            $fields = $result->extract($keys);
1051
            $found = false;
1052
            foreach ($indexed as $i => $data) {
1053
                if ($fields === $data) {
1054
                    unset($indexed[$i]);
1055
                    $found = true;
1056
                    break;
1057
                }
1058
            }
1059
1060
            if (!$found) {
1061
                $deletes[] = $result;
1062
            }
1063
        }
1064
1065
        $primary = (array)$target->primaryKey();
1066
        $jointProperty = $this->_junctionProperty;
1067
        foreach ($targetEntities as $k => $entity) {
1068
            if (!($entity instanceof EntityInterface)) {
1069
                continue;
1070
            }
1071
            $key = array_values($entity->extract($primary));
1072
            foreach ($present as $i => $data) {
1073
                if ($key === $data && !$entity->get($jointProperty)) {
1074
                    unset($targetEntities[$k], $present[$i]);
1075
                    break;
1076
                }
1077
            }
1078
        }
1079
1080
        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...
1081
            foreach ($deletes as $entity) {
1082
                $junction->delete($entity, $options);
1083
            }
1084
        }
1085
1086
        return $targetEntities;
1087
    }
1088
1089
    /**
1090
     * Throws an exception should any of the passed entities is not persisted.
1091
     *
1092
     * @param \Cake\Datasource\EntityInterface $sourceEntity the row belonging to the `source` side
1093
     *   of this association
1094
     * @param array $targetEntities list of entities belonging to the `target` side
1095
     *   of this association
1096
     * @return bool
1097
     * @throws \InvalidArgumentException
1098
     */
1099
    protected function _checkPersistenceStatus($sourceEntity, array $targetEntities)
1100
    {
1101
        if ($sourceEntity->isNew()) {
1102
            $error = 'Source entity needs to be persisted before proceeding';
1103
            throw new InvalidArgumentException($error);
1104
        }
1105
1106
        foreach ($targetEntities as $entity) {
1107
            if ($entity->isNew()) {
1108
                $error = 'Cannot link not persisted entities';
1109
                throw new InvalidArgumentException($error);
1110
            }
1111
        }
1112
1113
        return true;
1114
    }
1115
1116
    /**
1117
     * Returns the list of joint entities that exist between the source entity
1118
     * and each of the passed target entities
1119
     *
1120
     * @param \Cake\Datasource\EntityInterface $sourceEntity The row belonging to the source side
1121
     *   of this association.
1122
     * @param array $targetEntities The rows belonging to the target side of this
1123
     *   association.
1124
     * @throws \InvalidArgumentException if any of the entities is lacking a primary
1125
     *   key value
1126
     * @return array
1127
     */
1128
    protected function _collectJointEntities($sourceEntity, $targetEntities)
1129
    {
1130
        $target = $this->target();
1131
        $source = $this->source();
1132
        $junction = $this->junction();
1133
        $jointProperty = $this->_junctionProperty;
1134
        $primary = (array)$target->primaryKey();
1135
1136
        $result = [];
1137
        $missing = [];
1138
1139
        foreach ($targetEntities as $entity) {
1140
            if (!($entity instanceof EntityInterface)) {
1141
                continue;
1142
            }
1143
            $joint = $entity->get($jointProperty);
1144
1145
            if (!$joint || !($joint instanceof EntityInterface)) {
1146
                $missing[] = $entity->extract($primary);
1147
                continue;
1148
            }
1149
1150
            $result[] = $joint;
1151
        }
1152
1153
        if (empty($missing)) {
1154
            return $result;
1155
        }
1156
1157
        $belongsTo = $junction->association($target->alias());
1158
        $hasMany = $source->association($junction->alias());
1159
        $foreignKey = (array)$this->foreignKey();
1160
        $assocForeignKey = (array)$belongsTo->foreignKey();
1161
        $sourceKey = $sourceEntity->extract((array)$source->primaryKey());
1162
1163
        foreach ($missing as $key) {
1164
            $unions[] = $hasMany->find('all')
0 ignored issues
show
Coding Style Comprehensibility introduced by
$unions was never initialized. Although not strictly required by PHP, it is generally a good practice to add $unions = array(); before regardless.

Adding an explicit array definition is generally preferable to implicit array definition as it guarantees a stable state of the code.

Let’s take a look at an example:

foreach ($collection as $item) {
    $myArray['foo'] = $item->getFoo();

    if ($item->hasBar()) {
        $myArray['bar'] = $item->getBar();
    }

    // do something with $myArray
}

As you can see in this example, the array $myArray is initialized the first time when the foreach loop is entered. You can also see that the value of the bar key is only written conditionally; thus, its value might result from a previous iteration.

This might or might not be intended. To make your intention clear, your code more readible and to avoid accidental bugs, we recommend to add an explicit initialization $myArray = array() either outside or inside the foreach loop.

Loading history...
1165
                ->where(array_combine($foreignKey, $sourceKey))
1166
                ->andWhere(array_combine($assocForeignKey, $key));
1167
        }
1168
1169
        $query = array_shift($unions);
1170
        foreach ($unions as $q) {
1171
            $query->union($q);
1172
        }
1173
1174
        return array_merge($result, $query->toArray());
1175
    }
1176
1177
    /**
1178
     * Auxiliary function to construct a new Query object to return all the records
1179
     * in the target table that are associated to those specified in $options from
1180
     * the source table.
1181
     *
1182
     * This is used for eager loading records on the target table based on conditions.
1183
     *
1184
     * @param array $options options accepted by eagerLoader()
1185
     * @return \Cake\ORM\Query
1186
     * @throws \InvalidArgumentException When a key is required for associations but not selected.
1187
     */
1188
    protected function _buildQuery($options)
1189
    {
1190
        $name = $this->_junctionAssociationName();
1191
        $assoc = $this->target()->association($name);
1192
        $queryBuilder = false;
1193
1194
        if (!empty($options['queryBuilder'])) {
1195
            $queryBuilder = $options['queryBuilder'];
1196
            unset($options['queryBuilder']);
1197
        }
1198
1199
        $query = $this->_buildBaseQuery($options);
1200
        $query->addDefaultTypes($assoc->target());
1201
1202
        if ($queryBuilder) {
1203
            $query = $queryBuilder($query);
1204
        }
1205
1206
        $query = $this->_appendJunctionJoin($query, []);
1207
        // Ensure that association conditions are applied
1208
        // and that the required keys are in the selected columns.
1209
        $query
1210
            ->where($this->junctionConditions())
1211
            ->autoFields($query->clause('select') === [])
1212
            ->select($query->aliasFields((array)$assoc->foreignKey(), $name));
1213
1214
        $assoc->attachTo($query);
1215
        return $query;
1216
    }
1217
1218
    /**
1219
     * Generates a string used as a table field that contains the values upon
1220
     * which the filter should be applied
1221
     *
1222
     * @param array $options the options to use for getting the link field.
1223
     * @return string
1224
     */
1225 View Code Duplication
    protected function _linkField($options)
1226
    {
1227
        $links = [];
1228
        $name = $this->_junctionAssociationName();
1229
1230
        foreach ((array)$options['foreignKey'] as $key) {
1231
            $links[] = sprintf('%s.%s', $name, $key);
1232
        }
1233
1234
        if (count($links) === 1) {
1235
            return $links[0];
1236
        }
1237
1238
        return $links;
0 ignored issues
show
Bug Best Practice introduced by
The return type of return $links; (array) is incompatible with the return type documented by Cake\ORM\Association\BelongsToMany::_linkField of type string.

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...
1239
    }
1240
1241
    /**
1242
     * Returns the name of the association from the target table to the junction table,
1243
     * this name is used to generate alias in the query and to later on retrieve the
1244
     * results.
1245
     *
1246
     * @return string
1247
     */
1248
    protected function _junctionAssociationName()
1249
    {
1250
        if (!$this->_junctionAssociationName) {
1251
            $this->_junctionAssociationName = $this->target()
1252
                ->association($this->junction()->alias())
1253
                ->name();
1254
        }
1255
        return $this->_junctionAssociationName;
1256
    }
1257
1258
    /**
1259
     * Sets the name of the junction table.
1260
     * If no arguments are passed the current configured name is returned. A default
1261
     * name based of the associated tables will be generated if none found.
1262
     *
1263
     * @param string|null $name The name of the junction table.
1264
     * @return string
1265
     */
1266
    protected function _junctionTableName($name = null)
1267
    {
1268
        if ($name === null) {
1269
            if (empty($this->_junctionTableName)) {
1270
                $tablesNames = array_map('\Cake\Utility\Inflector::underscore', [
1271
                    $this->source()->table(),
1272
                    $this->target()->table()
1273
                ]);
1274
                sort($tablesNames);
1275
                $this->_junctionTableName = implode('_', $tablesNames);
1276
            }
1277
            return $this->_junctionTableName;
1278
        }
1279
        return $this->_junctionTableName = $name;
1280
    }
1281
1282
    /**
1283
     * Parse extra options passed in the constructor.
1284
     *
1285
     * @param array $opts original list of options passed in constructor
1286
     * @return void
1287
     */
1288
    protected function _options(array $opts)
1289
    {
1290
        $this->_externalOptions($opts);
1291
        if (!empty($opts['targetForeignKey'])) {
1292
            $this->targetForeignKey($opts['targetForeignKey']);
1293
        }
1294
        if (!empty($opts['joinTable'])) {
1295
            $this->_junctionTableName($opts['joinTable']);
1296
        }
1297
        if (!empty($opts['through'])) {
1298
            $this->_through = $opts['through'];
1299
        }
1300
        if (!empty($opts['saveStrategy'])) {
1301
            $this->saveStrategy($opts['saveStrategy']);
1302
        }
1303
    }
1304
}
1305