Test Failed
Pull Request — master (#54)
by
unknown
02:54
created

SaveRelationsBehavior::_afterSaveHasManyRelation()   B

Complexity

Conditions 10
Paths 32

Size

Total Lines 62
Code Lines 38

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 34
CRAP Score 10.017

Importance

Changes 7
Bugs 0 Features 0
Metric Value
cc 10
eloc 38
c 7
b 0
f 0
nc 32
nop 1
dl 0
loc 62
ccs 34
cts 36
cp 0.9444
crap 10.017
rs 7.6666

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

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

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