Passed
Pull Request — master (#53)
by
unknown
01:47
created

SaveRelationsBehavior::_loadRelationModel()   A

Complexity

Conditions 5
Paths 6

Size

Total Lines 10
Code Lines 6

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 7
CRAP Score 5

Importance

Changes 1
Bugs 1 Features 0
Metric Value
cc 5
eloc 6
c 1
b 1
f 0
nc 6
nop 3
dl 0
loc 10
ccs 7
cts 7
cp 1
crap 5
rs 9.6111
1
<?php
2
3
namespace lhs\Yii2SaveRelationsBehavior;
4
5
use RuntimeException;
6
use Yii;
7
use yii\base\Behavior;
8
use yii\base\Component;
9
use yii\base\Exception;
10
use yii\base\InvalidArgumentException;
11
use yii\base\InvalidConfigException;
12
use yii\base\Model;
13
use yii\base\ModelEvent;
14
use yii\base\UnknownPropertyException;
15
use yii\db\ActiveQuery;
16
use yii\db\BaseActiveRecord;
17
use yii\db\Exception as DbException;
18
use yii\helpers\ArrayHelper;
19
use yii\helpers\Inflector;
20
use yii\helpers\VarDumper;
21
22
/**
23
 * This Active Record Behavior allows to validate and save the Model relations when the save() method is invoked.
24
 * List of handled relations should be declared using the $relations parameter via an array of relation names.
25
 * @author albanjubert
26
 */
27
class SaveRelationsBehavior extends Behavior
28
{
29
30
    const RELATION_KEY_FORM_NAME = 'formName';
31
    const RELATION_KEY_RELATION_NAME = 'relationName';
32
33
    public $relations = [];
34
    public $relationKeyName = self::RELATION_KEY_FORM_NAME;
35
36
    private $_relations = [];
37
    private $_oldRelationValue = []; // Store initial relations value
38
    private $_newRelationValue = []; // Store update relations value
39
    private $_relationsToDelete = [];
40
    private $_relationsSaveStarted = false;
41
42
    /** @var BaseActiveRecord[] $_savedHasOneModels */
43
    private $_savedHasOneModels = [];
44
45
    private $_relationsScenario = [];
46
    private $_relationsExtraColumns = [];
47
    private $_relationsCascadeDelete = [];
48
49
    /**
50
     * @inheritdoc
51
     */
52 49
    public function init()
53
    {
54 49
        parent::init();
55 49
        $allowedProperties = ['scenario', 'extraColumns', 'cascadeDelete'];
56 49
        foreach ($this->relations as $key => $value) {
57 48
            if (is_int($key)) {
58 48
                $this->_relations[] = $value;
59
            } else {
60 44
                $this->_relations[] = $key;
61 44
                if (is_array($value)) {
62 44
                    foreach ($value as $propertyKey => $propertyValue) {
63 44
                        if (in_array($propertyKey, $allowedProperties)) {
64 44
                            $this->{'_relations' . ucfirst($propertyKey)}[$key] = $propertyValue;
65
                        } else {
66 44
                            throw new UnknownPropertyException('The relation property named ' . $propertyKey . ' is not supported');
67
                        }
68
                    }
69
                }
70
            }
71
        }
72 49
    }
73
74
    /**
75
     * @inheritdoc
76
     */
77 48
    public function events()
78
    {
79
        return [
80 48
            BaseActiveRecord::EVENT_BEFORE_VALIDATE => 'beforeValidate',
81 48
            BaseActiveRecord::EVENT_AFTER_VALIDATE  => 'afterValidate',
82 48
            BaseActiveRecord::EVENT_AFTER_INSERT    => 'afterSave',
83 48
            BaseActiveRecord::EVENT_AFTER_UPDATE    => 'afterSave',
84 48
            BaseActiveRecord::EVENT_BEFORE_DELETE   => 'beforeDelete',
85 48
            BaseActiveRecord::EVENT_AFTER_DELETE    => 'afterDelete'
86
        ];
87
    }
88
89
    /**
90
     * Check if the behavior is attached to an Active Record
91
     * @param Component $owner
92
     * @throws RuntimeException
93
     */
94 49
    public function attach($owner)
95
    {
96 49
        if (!($owner instanceof BaseActiveRecord)) {
97 1
            throw new RuntimeException('Owner must be instance of yii\db\BaseActiveRecord');
98
        }
99 48
        parent::attach($owner);
100 48
    }
101
102
    /**
103
     * Override canSetProperty method to be able to detect if a relation setter is allowed.
104
     * Setter is allowed if the relation is declared in the `relations` parameter
105
     * @param string $name
106
     * @param boolean $checkVars
107
     * @return boolean
108
     */
109 42
    public function canSetProperty($name, $checkVars = true)
110
    {
111
        /** @var BaseActiveRecord $owner */
112 42
        $owner = $this->owner;
113 42
        $relation = $owner->getRelation($name, false);
114 42
        if (in_array($name, $this->_relations) && !is_null($relation)) {
115 41
            return true;
116
        }
117 1
        return parent::canSetProperty($name, $checkVars);
118
    }
119
120
    /**
121
     * Override __set method to be able to set relations values either by providing a model instance,
122
     * a primary key value or an associative array
123
     * @param string $name
124
     * @param mixed $value
125
     * @throws \yii\base\InvalidArgumentException
126
     */
127 41
    public function __set($name, $value)
128
    {
129
        /** @var BaseActiveRecord $owner */
130 41
        $owner = $this->owner;
131 41
        if (in_array($name, $this->_relations)) {
132 41
            Yii::debug("Setting {$name} relation value", __METHOD__);
133
            /** @var ActiveQuery $relation */
134 41
            $relation = $owner->getRelation($name);
135 41
            if (!isset($this->_oldRelationValue[$name])) {
136 41
                if ($owner->isNewRecord) {
137 19
                    if ($relation->multiple === true) {
138 10
                        $this->_oldRelationValue[$name] = [];
139
                    } else {
140 16
                        $this->_oldRelationValue[$name] = null;
141
                    }
142
                } else {
143 24
                    $this->_oldRelationValue[$name] = $owner->{$name};
144
                }
145
            }
146 41
            if ($relation->multiple === true) {
147 28
                $this->setMultipleRelation($name, $value);
148
            } else {
149 25
                $this->setSingleRelation($name, $value);
150
            }
151
        }
152 41
    }
153
154
    /**
155
     * Set the named single relation with the given value
156
     * @param string $relationName
157
     * @param $value
158
     * @throws \yii\base\InvalidArgumentException
159
     */
160 25
    protected function setSingleRelation($relationName, $value)
161
    {
162
        /** @var BaseActiveRecord $owner */
163 25
        $owner = $this->owner;
164
        /** @var ActiveQuery $relation */
165 25
        $relation = $owner->getRelation($relationName);
166
167 25
        if (!($value instanceof $relation->modelClass)) {
168
            //we have an existing hasone relation model
169 10
            if(is_array($value) && $this->_getRelatedFks($value, $relation, $relation->modelClass) && $owner->{$relationName} instanceof $relation->modelClass && !$owner->{$relationName}->getIsNewRecord()) {
0 ignored issues
show
Bug introduced by
$relation->modelClass of type string is incompatible with the type yii\db\BaseActiveRecord expected by parameter $modelClass of lhs\Yii2SaveRelationsBeh...avior::_getRelatedFks(). ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

169
            if(is_array($value) && $this->_getRelatedFks($value, $relation, /** @scrutinizer ignore-type */ $relation->modelClass) && $owner->{$relationName} instanceof $relation->modelClass && !$owner->{$relationName}->getIsNewRecord()) {
Loading history...
170
                $this->_loadRelationModel($value, $relationName, $owner->{$relationName});
171
                $value = $owner->{$relationName};
172
            } else {
173 10
                $value = $this->processModelAsArray($value, $relation, $relationName);
174
            }
175
        }
176 25
        $this->_newRelationValue[$relationName] = $value;
177 25
        $owner->populateRelation($relationName, $value);
178 25
    }
179
180
    /**
181
     * Set the named multiple relation with the given value
182
     * @param string $relationName
183
     * @param $value
184
     * @throws \yii\base\InvalidArgumentException
185
     */
186 28
    protected function setMultipleRelation($relationName, $value)
187
    {
188
        /** @var BaseActiveRecord $owner */
189 28
        $owner = $this->owner;
190
        /** @var ActiveQuery $relation */
191 28
        $relation = $owner->getRelation($relationName);
192 28
        $newRelations = [];
193 28
        if (!is_array($value)) {
194 4
            if (!empty($value)) {
195 3
                $value = [$value];
196
            } else {
197 1
                $value = [];
198
            }
199
        }
200 28
        foreach ($value as $entry) {
201 27
            if ($entry instanceof $relation->modelClass) {
202 16
                $newRelations[] = $entry;
203
            } else {
204
                // TODO handle this with one DB request to retrieve all models
205 15
                $newRelations[] = $this->processModelAsArray($entry, $relation, $relationName);
206
            }
207
        }
208 28
        $this->_newRelationValue[$relationName] = $newRelations;
209 28
        $owner->populateRelation($relationName, $newRelations);
210 28
    }
211
212
    /**
213
     * Get a BaseActiveRecord model using the given $data parameter.
214
     * $data could either be a model ID or an associative array representing model attributes => values
215
     * @param mixed $data
216
     * @param \yii\db\ActiveQuery $relation
217
     * @return BaseActiveRecord
218
     */
219 20
    protected function processModelAsArray($data, $relation, $name)
220
    {
221
        /** @var BaseActiveRecord $modelClass */
222 20
        $modelClass = $relation->modelClass;
223 20
        $fks = $this->_getRelatedFks($data, $relation, $modelClass);
224 20
        return $this->_loadOrCreateRelationModel($data, $fks, $modelClass, $name);
225
    }
226
227
    /**
228
     * Get the related model foreign keys
229
     * @param $data
230
     * @param $relation
231
     * @param BaseActiveRecord $modelClass
232
     * @return array
233
     */
234 20
    private function _getRelatedFks($data, $relation, $modelClass)
235
    {
236 20
        $fks = [];
237 20
        if (is_array($data)) {
238
            // Get the right link definition
239 16
            if ($relation->via instanceof BaseActiveRecord) {
240
                $link = $relation->via->link;
241 16
            } elseif (is_array($relation->via)) {
242 11
                list($viaName, $viaQuery) = $relation->via;
243 11
                $link = $viaQuery->link;
244
            } else {
245 10
                $link = $relation->link;
246
            }
247
            // search PK
248 16
            foreach ($modelClass::primaryKey() as $modelAttribute) {
249 16
                if (isset($data[$modelAttribute])) {
250 11
                    $fks[$modelAttribute] = $data[$modelAttribute];
251 12
                } elseif ($relation->multiple && !$relation->via) {
252 4
                    foreach ($link as $relatedAttribute => $relatedModelAttribute) {
253 4
                        if (!isset($data[$relatedAttribute]) && in_array($relatedAttribute, $modelClass::primaryKey())) {
254 4
                            $fks[$relatedAttribute] = $this->owner->{$relatedModelAttribute};
255
                        }
256
                    }
257
                } else {
258 9
                    $fks = [];
259 9
                    break;
260
                }
261
            }
262 16
            if (empty($fks)) {
263 11
                foreach ($link as $relatedAttribute => $modelAttribute) {
264 11
                    if (isset($data[$modelAttribute])) {
265 11
                        $fks[$modelAttribute] = $data[$modelAttribute];
266
                    }
267
                }
268
            }
269
        } else {
270 5
            $fks = $data;
271
        }
272 20
        return $fks;
273
    }
274
275
    /**
276
     * Load existing model or create one if no key was provided and data is not empty
277
     * @param $data
278
     * @param $fks
279
     * @param $modelClass
280
     * @param $relationName
281
     * @return BaseActiveRecord
282
     */
283 20
    private function _loadOrCreateRelationModel($data, $fks, $modelClass, $relationName)
284
    {
285
286
        /** @var BaseActiveRecord $relationModel */
287 20
        $relationModel = null;
288 20
        if (!empty($fks)) {
289 13
            $relationModel = $modelClass::findOne($fks);
290
        }
291 20
        if (!($relationModel instanceof BaseActiveRecord) && !empty($data)) {
292 15
            $relationModel = new $modelClass;
293
        }
294 20
        $this->_loadRelationModel($data, $relationName, $relationModel);
295 20
        return $relationModel;
296
    }
297
298
    /**
299
     * Before the owner model validation, save related models.
300
     * For `hasOne()` relations, set the according foreign keys of the owner model to be able to validate it
301
     * @param ModelEvent $event
302
     * @throws DbException
303
     * @throws \yii\base\InvalidConfigException
304
     */
305 37
    public function beforeValidate(ModelEvent $event)
306
    {
307 37
        if ($this->_relationsSaveStarted === false && !empty($this->_oldRelationValue)) {
308
            /* @var $model BaseActiveRecord */
309 37
            $model = $this->owner;
310 37
            if ($this->saveRelatedRecords($model, $event)) {
311
                // If relation is has_one, try to set related model attributes
312 37
                foreach ($this->_relations as $relationName) {
313 37
                    if (array_key_exists($relationName, $this->_oldRelationValue)) { // Relation was not set, do nothing...
314 37
                        $this->_setRelationForeignKeys($relationName);
315
                    }
316
                }
317
            }
318
        }
319 37
    }
320
321
    /**
322
     * After the owner model validation, rollback newly saved hasOne relations if it fails
323
     * @throws DbException
324
     */
325 37
    public function afterValidate()
326
    {
327
        /* @var $model BaseActiveRecord */
328 37
        $model = $this->owner;
329 37
        if (!empty($this->_savedHasOneModels) && $model->hasErrors()) {
330 2
            $this->_rollbackSavedHasOneModels();
331
        }
332 37
    }
333
334
    /**
335
     * Prepare each related model (validate or save if needed).
336
     * This is done during the before validation process to be able
337
     * to set the related foreign keys for newly created has one records.
338
     * @param BaseActiveRecord $model
339
     * @param ModelEvent $event
340
     * @return bool
341
     * @throws DbException
342
     * @throws \yii\base\InvalidConfigException
343
     */
344 37
    protected function saveRelatedRecords(BaseActiveRecord $model, ModelEvent $event)
345
    {
346
        try {
347 37
            foreach ($this->_relations as $relationName) {
348 37
                if (array_key_exists($relationName, $this->_oldRelationValue)) { // Relation was not set, do nothing...
349
                    /** @var ActiveQuery $relation */
350 37
                    $relation = $model->getRelation($relationName);
351 37
                    if (!empty($model->{$relationName})) {
352 35
                        if ($relation->multiple === false) {
353 22
                            $this->_prepareHasOneRelation($model, $relationName, $event);
354
                        } else {
355 24
                            $this->_prepareHasManyRelation($model, $relationName);
356
                        }
357
                    }
358
                }
359
            }
360 37
            if (!$event->isValid) {
361
                throw new Exception('One of the related model could not be validated');
362
            }
363
        } catch (Exception $e) {
364
            Yii::warning(get_class($e) . ' was thrown while saving related records during beforeValidate event: ' . $e->getMessage(), __METHOD__);
365
            $this->_rollbackSavedHasOneModels(); // Rollback saved records during validation process, if any
366
            $model->addError($model->formName(), $e->getMessage());
367
            $event->isValid = false; // Stop saving, something went wrong
368
            return false;
369
        }
370 37
        return true;
371
    }
372
373
    /**
374
     * @param BaseActiveRecord $model
375
     * @param ModelEvent $event
376
     * @param $relationName
377
     */
378 22
    private function _prepareHasOneRelation(BaseActiveRecord $model, $relationName, ModelEvent $event)
379
    {
380 22
        Yii::debug("_prepareHasOneRelation for {$relationName}", __METHOD__);
381 22
        $relationModel = $model->{$relationName};
382 22
        $this->validateRelationModel(self::prettyRelationName($relationName), $relationName, $model->{$relationName});
383 22
        $relation = $model->getRelation($relationName);
384 22
        $p1 = $model->isPrimaryKey(array_keys($relation->link));
0 ignored issues
show
Bug introduced by
Accessing link on the interface yii\db\ActiveQueryInterface suggest that you code against a concrete implementation. How about adding an instanceof check?
Loading history...
385 22
        $p2 = $relationModel::isPrimaryKey(array_values($relation->link));
386 22
        if ($relationModel->getIsNewRecord() && $p1 && !$p2) {
387
            // Save Has one relation new record
388 13
            if ($event->isValid && (count($model->dirtyAttributes) || $model->{$relationName}->isNewRecord)) {
389 13
                Yii::debug('Saving ' . self::prettyRelationName($relationName) . ' relation model', __METHOD__);
390 13
                if ($model->{$relationName}->save()) {
391 11
                    $this->_savedHasOneModels[] = $model->{$relationName};
392
                }
393
            }
394
        }
395 22
    }
396
397
    /**
398
     * Validate a relation model and add an error message to owner model attribute if needed
399
     * @param string $prettyRelationName
400
     * @param string $relationName
401
     * @param BaseActiveRecord $relationModel
402
     */
403 35
    protected function validateRelationModel($prettyRelationName, $relationName, BaseActiveRecord $relationModel)
404
    {
405
        /** @var BaseActiveRecord $model */
406 35
        $model = $this->owner;
407 35
        if (!is_null($relationModel) && ($relationModel->isNewRecord || count($relationModel->getDirtyAttributes()))) {
408 27
            Yii::debug("Validating {$prettyRelationName} relation model using " . $relationModel->scenario . ' scenario', __METHOD__);
409 27
            if (!$relationModel->validate()) {
410 4
                $this->_addError($relationModel, $model, $relationName, $prettyRelationName);
411
            }
412
413
        }
414 35
    }
415
416
    /**
417
     * Attach errors to owner relational attributes
418
     * @param BaseActiveRecord $relationModel
419
     * @param BaseActiveRecord $owner
420
     * @param string $relationName
421
     * @param string $prettyRelationName
422
     */
423 5
    private function _addError($relationModel, $owner, $relationName, $prettyRelationName)
424
    {
425 5
        foreach ($relationModel->errors as $attribute => $attributeErrors) {
426 5
            foreach ($attributeErrors as $error) {
427 5
                $owner->addError($relationName, "{$prettyRelationName}: {$error}");
428
            }
429
        }
430 5
    }
431
432
    /**
433
     * @param $relationName
434
     * @param int|null $i
435
     * @return string
436
     */
437 35
    protected static function prettyRelationName($relationName, $i = null)
438
    {
439 35
        return Inflector::camel2words($relationName, true) . (is_null($i) ? '' : " #{$i}");
440
    }
441
442
    /**
443
     * @param BaseActiveRecord $model
444
     * @param $relationName
445
     */
446 24
    private function _prepareHasManyRelation(BaseActiveRecord $model, $relationName)
447
    {
448
        /** @var BaseActiveRecord $relationModel */
449 24
        foreach ($model->{$relationName} as $i => $relationModel) {
450 24
            $this->validateRelationModel(self::prettyRelationName($relationName, $i), $relationName, $relationModel);
451
        }
452 24
    }
453
454
    /**
455
     * Delete newly created Has one models if any
456
     * @throws DbException
457
     */
458 4
    private function _rollbackSavedHasOneModels()
459
    {
460 4
        foreach ($this->_savedHasOneModels as $savedHasOneModel) {
461 3
            $savedHasOneModel->delete();
462
        }
463 4
        $this->_savedHasOneModels = [];
464 4
    }
465
466
    /**
467
     * Set relation foreign keys that point to owner primary key
468
     * @param $relationName
469
     */
470 37
    protected function _setRelationForeignKeys($relationName)
471
    {
472
        /** @var BaseActiveRecord $owner */
473 37
        $owner = $this->owner;
474
        /** @var ActiveQuery $relation */
475 37
        $relation = $owner->getRelation($relationName);
476 37
        if ($relation->multiple === false && !empty($owner->{$relationName})) {
477 22
            Yii::debug("Setting foreign keys for {$relationName}", __METHOD__);
478 22
            foreach ($relation->link as $relatedAttribute => $modelAttribute) {
479 22
                if ($owner->{$modelAttribute} !== $owner->{$relationName}->{$relatedAttribute}) {
480 17
                    if ($owner->{$relationName}->isNewRecord) {
481 1
                        $owner->{$relationName}->save();
482
                    }
483 17
                    $owner->{$modelAttribute} = $owner->{$relationName}->{$relatedAttribute};
484
                }
485
            }
486
        }
487 37
    }
488
489
    /**
490
     * Link the related models.
491
     * If the models have not been changed, nothing will be done.
492
     * Related records will be linked to the owner model using the BaseActiveRecord `link()` method.
493
     * @throws Exception
494
     */
495 35
    public function afterSave()
496
    {
497 35
        if ($this->_relationsSaveStarted === false) {
498
            /** @var BaseActiveRecord $owner */
499 35
            $owner = $this->owner;
500 35
            $this->_relationsSaveStarted = true;
501
            // Populate relations with updated values
502 35
            foreach ($this->_newRelationValue as $name => $value) {
503 34
                $owner->populateRelation($name, $value);
504
            }
505
            try {
506 35
                foreach ($this->_relations as $relationName) {
507 35
                    if (array_key_exists($relationName, $this->_oldRelationValue)) { // Relation was not set, do nothing...
508 34
                        Yii::debug("Linking {$relationName} relation", __METHOD__);
509
                        /** @var ActiveQuery $relation */
510 34
                        $relation = $owner->getRelation($relationName);
511 34
                        if ($relation->multiple === true) { // Has many relation
512 24
                            $this->_afterSaveHasManyRelation($relationName);
513
                        } else { // Has one relation
514 21
                            $this->_afterSaveHasOneRelation($relationName);
515
                        }
516 35
                        unset($this->_oldRelationValue[$relationName]);
517
                    }
518
                }
519 1
            } catch (Exception $e) {
520 1
                Yii::warning(get_class($e) . ' was thrown while saving related records during afterSave event: ' . $e->getMessage(), __METHOD__);
521 1
                $this->_rollbackSavedHasOneModels();
522
                /***
523
                 * Sadly mandatory because the error occurred during afterSave event
524
                 * and we don't want the user/developper not to be aware of the issue.
525
                 ***/
526 1
                throw $e;
527
            }
528 35
            $owner->refresh();
529 35
            $this->_relationsSaveStarted = false;
530
        }
531 35
    }
532
533
    /**
534
     * @param $relationName
535
     * @throws DbException
536
     */
537 24
    public function _afterSaveHasManyRelation($relationName)
538
    {
539
        /** @var BaseActiveRecord $owner */
540 24
        $owner = $this->owner;
541
        /** @var ActiveQuery $relation */
542 24
        $relation = $owner->getRelation($relationName);
543
544
        // Process new relations
545 24
        $existingRecords = [];
546
        /** @var ActiveQuery $relationModel */
547 24
        foreach ($owner->{$relationName} as $i => $relationModel) {
548 24
            if ($relationModel->isNewRecord) {
549 16
                if (!empty($relation->via)) {
550 11
                    if ($relationModel->validate()) {
551 11
                        $relationModel->save();
552
                    } else {
553 1
                        $this->_addError($relationModel, $owner, $relationName, self::prettyRelationName($relationName, $i));
554 1
                        throw new DbException('Related record ' . self::prettyRelationName($relationName, $i) . ' could not be saved.');
555
                    }
556
                }
557 16
                $junctionTableColumns = $this->_getJunctionTableColumns($relationName, $relationModel);
558 16
                $owner->link($relationName, $relationModel, $junctionTableColumns);
559
            } else {
560 15
                $existingRecords[] = $relationModel;
561
            }
562 24
            if (count($relationModel->dirtyAttributes)) {
563 6
                if ($relationModel->validate()) {
564 6
                    $relationModel->save();
565
                } else {
566
                    $this->_addError($relationModel, $owner, $relationName, self::prettyRelationName($relationName));
567
                    throw new DbException('Related record ' . self::prettyRelationName($relationName) . ' could not be saved.');
568
                }
569
            }
570
        }
571 23
        $junctionTablePropertiesUsed = array_key_exists($relationName, $this->_relationsExtraColumns);
572
573
        // Process existing added and deleted relations
574 23
        list($addedPks, $deletedPks) = $this->_computePkDiff(
575 23
            $this->_oldRelationValue[$relationName],
576 23
            $existingRecords,
577 23
            $junctionTablePropertiesUsed
578
        );
579
580
        // Deleted relations
581
        $initialModels = ArrayHelper::index($this->_oldRelationValue[$relationName], function (BaseActiveRecord $model) {
582 17
            return implode('-', $model->getPrimaryKey(true));
583 23
        });
584 23
        $initialRelations = $owner->{$relationName};
585 23
        foreach ($deletedPks as $key) {
586 5
            $owner->unlink($relationName, $initialModels[$key], true);
587
        }
588
589
        // Added relations
590 23
        $actualModels = ArrayHelper::index(
591 23
            $junctionTablePropertiesUsed ? $initialRelations : $owner->{$relationName},
592
            function (BaseActiveRecord $model) {
593 23
                return implode('-', $model->getPrimaryKey(true));
594 23
            }
595
        );
596 23
        foreach ($addedPks as $key) {
597 4
            $junctionTableColumns = $this->_getJunctionTableColumns($relationName, $actualModels[$key]);
598 4
            $owner->link($relationName, $actualModels[$key], $junctionTableColumns);
599
        }
600 23
    }
601
602
    /**
603
     * Return array of columns to save to the junction table for a related model having a many-to-many relation.
604
     * @param string $relationName
605
     * @param BaseActiveRecord $model
606
     * @return array
607
     * @throws \RuntimeException
608
     */
609 20
    private function _getJunctionTableColumns($relationName, $model)
610
    {
611 20
        $junctionTableColumns = [];
612 20
        if (array_key_exists($relationName, $this->_relationsExtraColumns)) {
613 1
            if (is_callable($this->_relationsExtraColumns[$relationName])) {
614 1
                $junctionTableColumns = $this->_relationsExtraColumns[$relationName]($model);
615
            } elseif (is_array($this->_relationsExtraColumns[$relationName])) {
616
                $junctionTableColumns = $this->_relationsExtraColumns[$relationName];
617
            }
618 1
            if (!is_array($junctionTableColumns)) {
619
                throw new RuntimeException(
620
                    'Junction table columns definition must return an array, got ' . gettype($junctionTableColumns)
621
                );
622
            }
623
        }
624 20
        return $junctionTableColumns;
625
    }
626
627
    /**
628
     * Compute the difference between two set of records using primary keys "tokens"
629
     * If third parameter is set to true all initial related records will be marked for removal even if their
630
     * properties did not change. This can be handy in a many-to-many relation_ involving a junction table.
631
     * @param BaseActiveRecord[] $initialRelations
632
     * @param BaseActiveRecord[] $updatedRelations
633
     * @param bool $forceSave
634
     * @return array
635
     */
636 23
    private function _computePkDiff($initialRelations, $updatedRelations, $forceSave = false)
637
    {
638
        // Compute differences between initial relations and the current ones
639
        $oldPks = ArrayHelper::getColumn($initialRelations, function (BaseActiveRecord $model) {
640 17
            return implode('-', $model->getPrimaryKey(true));
641 23
        });
642 23
        $newPks = ArrayHelper::getColumn($updatedRelations, function (BaseActiveRecord $model) {
643 15
            return implode('-', $model->getPrimaryKey(true));
644 23
        });
645 23
        if ($forceSave) {
646 1
            $addedPks = $newPks;
647 1
            $deletedPks = $oldPks;
648
        } else {
649 22
            $identicalPks = array_intersect($oldPks, $newPks);
650 22
            $addedPks = array_values(array_diff($newPks, $identicalPks));
651 22
            $deletedPks = array_values(array_diff($oldPks, $identicalPks));
652
        }
653 23
        return [$addedPks, $deletedPks];
654
    }
655
656
    /**
657
     * @param $relationName
658
     * @throws \yii\base\InvalidCallException
659
     */
660 21
    private function _afterSaveHasOneRelation($relationName)
661
    {
662
        /** @var BaseActiveRecord $owner */
663 21
        $owner = $this->owner;
664 21
        if ($this->_oldRelationValue[$relationName] !== $owner->{$relationName}) {
665 18
            if ($owner->{$relationName} instanceof BaseActiveRecord) {
666 17
                $owner->link($relationName, $owner->{$relationName});
667
            } else {
668 1
                if ($this->_oldRelationValue[$relationName] instanceof BaseActiveRecord) {
669 1
                    $owner->unlink($relationName, $this->_oldRelationValue[$relationName]);
670
                }
671
            }
672
        }
673 21
        if ($owner->{$relationName} instanceof BaseActiveRecord) {
674 19
            $owner->{$relationName}->save();
675
        }
676 21
    }
677
678
    /**
679
     * Get the list of owner model relations in order to be able to delete them after its deletion
680
     */
681 6
    public function beforeDelete()
682
    {
683
        /** @var BaseActiveRecord $owner */
684 6
        $owner = $this->owner;
685 6
        foreach ($this->_relationsCascadeDelete as $relationName => $params) {
686 3
            if ($params === true) {
687
                /** @var ActiveQuery $relation */
688 3
                $relation = $owner->getRelation($relationName);
689 3
                if (!empty($owner->{$relationName})) {
690 3
                    if ($relation->multiple === true) { // Has many relation
691 2
                        $this->_relationsToDelete = ArrayHelper::merge($this->_relationsToDelete, $owner->{$relationName});
692
                    } else {
693 1
                        $this->_relationsToDelete[] = $owner->{$relationName};
694
                    }
695
                }
696
            }
697
        }
698 6
    }
699
700
    /**
701
     * Delete related models marked as to be deleted
702
     * @throws Exception
703
     */
704 6
    public function afterDelete()
705
    {
706
        /** @var BaseActiveRecord $modelToDelete */
707 6
        foreach ($this->_relationsToDelete as $modelToDelete) {
708
            try {
709 3
                if (!$modelToDelete->delete()) {
710 1
                    throw new DbException('Could not delete the related record: ' . $modelToDelete::className() . '(' . VarDumper::dumpAsString($modelToDelete->primaryKey) . ')');
711
                }
712 1
            } catch (Exception $e) {
713 1
                Yii::warning(get_class($e) . ' was thrown while deleting related records during afterDelete event: ' . $e->getMessage(), __METHOD__);
714 1
                $this->_rollbackSavedHasOneModels();
715 1
                throw $e;
716
            }
717
        }
718 5
    }
719
720
    /**
721
     * Populates relations with input data
722
     * @param array $data
723
     * @throws InvalidConfigException
724
     */
725 16
    public function loadRelations($data)
726
    {
727
        /** @var BaseActiveRecord $owner */
728 16
        $owner = $this->owner;
729 16
        foreach ($this->_relations as $relationName) {
730 16
            $keyName = $this->_getRelationKeyName($relationName);
731 16
            if (array_key_exists($keyName, $data)) {
732 8
                $owner->{$relationName} = $data[$keyName];
733
            }
734
        }
735 16
    }
736
737
    /**
738
     * Set the scenario for a given relation
739
     * @param $relationName
740
     * @param $scenario
741
     * @throws InvalidArgumentException
742
     */
743 2
    public function setRelationScenario($relationName, $scenario)
744
    {
745
        /** @var BaseActiveRecord $owner */
746 2
        $owner = $this->owner;
747 2
        $relation = $owner->getRelation($relationName, false);
748 2
        if (in_array($relationName, $this->_relations) && !is_null($relation)) {
749 1
            $this->_relationsScenario[$relationName] = $scenario;
750
        } else {
751 1
            throw new InvalidArgumentException('Unknown ' . $relationName . ' relation');
752
        }
753
754 1
    }
755
756
    /**
757
     * @param $relationName string
758
     * @return mixed
759
     * @throws InvalidConfigException
760
     */
761 16
    private function _getRelationKeyName($relationName)
762
    {
763 16
        switch ($this->relationKeyName) {
764 16
            case self::RELATION_KEY_RELATION_NAME:
765 1
                $keyName = $relationName;
766 1
                break;
767 16
            case self::RELATION_KEY_FORM_NAME:
768
                /** @var BaseActiveRecord $owner */
769 16
                $owner = $this->owner;
770
                /** @var ActiveQuery $relation */
771 16
                $relation = $owner->getRelation($relationName);
772 16
                $modelClass = $relation->modelClass;
773
                /** @var ActiveQuery $relationalModel */
774 16
                $relationalModel = new $modelClass;
775 16
                $keyName = $relationalModel->formName();
776 16
                break;
777
            default:
778
                throw new InvalidConfigException('Unknown relation key name');
779
        }
780 16
        return $keyName;
781
    }
782
783
    /**
784
     * Return the old relations values.
785
     * @return array The old relations (name-value pairs)
786
     */
787 1
    public function getOldRelations()
788
    {
789 1
        $oldRelations = [];
790 1
        foreach ($this->_relations as $relationName) {
791 1
            $oldRelations[$relationName] = $this->getOldRelation($relationName);
792
        }
793 1
        return $oldRelations;
794
    }
795
796
    /**
797
     * Returns the old value of the named relation.
798
     * @param $relationName string The relations name as defined in the behavior `relations` parameter
799
     * @return mixed
800
     */
801 1
    public function getOldRelation($relationName)
802
    {
803 1
        return array_key_exists($relationName, $this->_oldRelationValue) ? $this->_oldRelationValue[$relationName] : $this->owner->{$relationName};
804
    }
805
806
    /**
807
     * Returns the relations that have been modified since they are loaded.
808
     * @return array The changed relations (name-value pairs)
809
     */
810 2
    public function getDirtyRelations()
811
    {
812 2
        $dirtyRelations = [];
813 2
        foreach ($this->_relations as $relationName) {
814 2
            if (array_key_exists($relationName, $this->_oldRelationValue)) {
815 2
                $dirtyRelations[$relationName] = $this->owner->{$relationName};
816
            }
817
        }
818 2
        return $dirtyRelations;
819
    }
820
821
    /**
822
     * Mark a relation as dirty
823
     * @param $relationName string
824
     * @return bool Whether the operation succeeded.
825
     */
826 1
    public function markRelationDirty($relationName)
827
    {
828 1
        if (in_array($relationName, $this->_relations) && !array_key_exists($relationName, $this->_oldRelationValue)) {
829 1
            $this->_oldRelationValue[$relationName] = $this->owner->{$relationName};
830 1
            return true;
831
        }
832 1
        return false;
833
    }
834
835
    /**
836
     * @param $data
837
     * @param $relationName
838
     * @param $relationModel
839
     */
840 20
    private function _loadRelationModel($data, $relationName, ?Model $relationModel): void
841
    {
842
        // If a custom scenario is set, apply it here to correctly be able to set the model attributes
843 20
        if (array_key_exists($relationName, $this->_relationsScenario)) {
844 9
            $relationModel->setScenario($this->_relationsScenario[$relationName]);
0 ignored issues
show
Bug introduced by
The method setScenario() does not exist on null. ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-call  annotation

844
            $relationModel->/** @scrutinizer ignore-call */ 
845
                            setScenario($this->_relationsScenario[$relationName]);

This check looks for calls to methods that do not seem to exist on a given type. It looks for the method on the type itself as well as in inherited classes or implemented interfaces.

This is most likely a typographical error or the method has been renamed.

Loading history...
845
        }
846 20
        if (($relationModel instanceof BaseActiveRecord) && is_array($data)) {
847 16
            $relationModel->setAttributes($data);
848 16
            if ($relationModel->hasMethod('loadRelations')) {
849 14
                $relationModel->loadRelations($data);
850
            }
851
852
        }
853 20
    }
854
}
855