Passed
Push — master ( 5a531a...cd14cb )
by Alban
04:16
created

SaveRelationsBehavior::_getRelatedFks()   C

Complexity

Conditions 13
Paths 13

Size

Total Lines 39
Code Lines 26

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 23
CRAP Score 13.0122

Importance

Changes 0
Metric Value
cc 13
eloc 26
nc 13
nop 3
dl 0
loc 39
ccs 23
cts 24
cp 0.9583
crap 13.0122
rs 6.6166
c 0
b 0
f 0

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