SaveRelationsBehavior   F
last analyzed

Complexity

Total Complexity 149

Size/Duplication

Total Lines 809
Duplicated Lines 0 %

Test Coverage

Coverage 95.64%

Importance

Changes 37
Bugs 2 Features 0
Metric Value
eloc 327
c 37
b 2
f 0
dl 0
loc 809
ccs 329
cts 344
cp 0.9564
rs 2
wmc 149

34 Methods

Rating   Name   Duplication   Size   Complexity  
A init() 0 15 6
A canSetProperty() 0 9 3
A beforeValidate() 0 10 6
A processModelAsArray() 0 6 1
A events() 0 9 1
A setSingleRelation() 0 11 2
C _getRelatedFks() 0 39 14
B _loadOrCreateRelationModel() 0 23 8
A __set() 0 23 6
A attach() 0 6 2
A setMultipleRelation() 0 24 5
A _addError() 0 5 3
A getOldRelations() 0 7 2
A _computePkDiff() 0 18 2
B saveRelatedRecords() 0 27 7
A beforeDelete() 0 13 5
A _rollbackSavedHasOneModels() 0 6 2
A afterDelete() 0 12 4
A _getJunctionTableColumns() 0 16 5
A afterValidate() 0 6 3
A _setRelationForeignKeys() 0 14 6
A getDirtyRelations() 0 9 3
A markRelationDirty() 0 7 3
A loadRelations() 0 8 3
A validateRelationModel() 0 8 5
B _afterSaveHasManyRelation() 0 62 10
A _prepareHasManyRelation() 0 5 2
A setRelationScenario() 0 9 3
A getOldRelation() 0 3 2
B _prepareHasOneRelation() 0 14 8
B afterSave() 0 35 7
A _getRelationKeyName() 0 20 3
A _afterSaveHasOneRelation() 0 15 5
A prettyRelationName() 0 3 2

How to fix   Complexity   

Complex Class

Complex classes like SaveRelationsBehavior often do a lot of different things. To break such a class down, we need to identify a cohesive component within that class. A common approach to find such a component is to look for fields/methods that share the same prefixes, or suffixes.

Once you have determined the fields that belong together, you can apply the Extract Class refactoring. If the component makes sense as a sub-class, Extract Subclass is also a candidate, and is often faster.

While breaking up the class, it is a good idea to analyze how other classes use SaveRelationsBehavior, and based on these observations, apply Extract Interface, too.

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