Passed
Push — master ( 178402...1fe482 )
by Alban
02:44
created

SaveRelationsBehavior   F

Complexity

Total Complexity 146

Size/Duplication

Total Lines 795
Duplicated Lines 0 %

Test Coverage

Coverage 95.56%

Importance

Changes 0
Metric Value
eloc 323
dl 0
loc 795
ccs 323
cts 338
cp 0.9556
rs 2
c 0
b 0
f 0
wmc 146

33 Methods

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