Passed
Pull Request — master (#67)
by
unknown
08:46
created

SaveRelationsBehavior::_afterSaveHasManyRelation()   B

Complexity

Conditions 11
Paths 32

Size

Total Lines 62
Code Lines 38

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 32
CRAP Score 11.0245

Importance

Changes 7
Bugs 0 Features 0
Metric Value
cc 11
eloc 38
c 7
b 0
f 0
nc 32
nop 1
dl 0
loc 62
ccs 32
cts 34
cp 0.9412
crap 11.0245
rs 7.3166

How to fix   Long Method    Complexity   

Long Method

Small methods make your code easier to understand, in particular if combined with a good name. Besides, if your method is small, finding a good name is usually much easier.

For example, if you find yourself adding comments to a method's body, this is usually a good sign to extract the commented part to a new method, and use the comment as a starting point when coming up with a good name for this new method.

Commonly applied refactorings include:

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