Passed
Pull Request — master (#38)
by Leandro
02:04
created

SaveRelationsBehavior::canSetProperty()   A

Complexity

Conditions 3
Paths 2

Size

Total Lines 9
Code Lines 5

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 6
CRAP Score 3

Importance

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