SaveRelationsBehavior::_afterSaveHasManyRelation()   B
last analyzed

Complexity

Conditions 11
Paths 32

Size

Total Lines 58
Code Lines 34

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 30
CRAP Score 11.0295

Importance

Changes 7
Bugs 0 Features 0
Metric Value
cc 11
eloc 34
c 7
b 0
f 0
nc 32
nop 1
dl 0
loc 58
ccs 30
cts 32
cp 0.9375
crap 11.0295
rs 7.3166

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\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 50
    public function init()
52
    {
53 50
        parent::init();
54 50
        $allowedProperties = ['scenario', 'extraColumns', 'cascadeDelete'];
55 50
        foreach ($this->relations as $key => $value) {
56 49
            if (is_int($key)) {
57 49
                $this->_relations[] = $value;
58
            } else {
59 45
                $this->_relations[] = $key;
60 45
                if (is_array($value)) {
61 45
                    foreach ($value as $propertyKey => $propertyValue) {
62 45
                        if (in_array($propertyKey, $allowedProperties)) {
63 45
                            $this->{'_relations' . ucfirst($propertyKey)}[$key] = $propertyValue;
64
                        } else {
65 1
                            throw new UnknownPropertyException('The relation property named ' . $propertyKey . ' is not supported');
66
                        }
67
                    }
68
                }
69
            }
70
        }
71 50
    }
72
73
    /**
74
     * @inheritdoc
75
     */
76 49
    public function events()
77
    {
78
        return [
79 49
            BaseActiveRecord::EVENT_BEFORE_VALIDATE => 'beforeValidate',
80 49
            BaseActiveRecord::EVENT_AFTER_VALIDATE  => 'afterValidate',
81 49
            BaseActiveRecord::EVENT_AFTER_INSERT    => 'afterSave',
82 49
            BaseActiveRecord::EVENT_AFTER_UPDATE    => 'afterSave',
83 49
            BaseActiveRecord::EVENT_BEFORE_DELETE   => 'beforeDelete',
84 49
            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 50
    public function attach($owner)
94
    {
95 50
        if (!($owner instanceof BaseActiveRecord)) {
96 1
            throw new RuntimeException('Owner must be instance of yii\db\BaseActiveRecord');
97
        }
98 49
        parent::attach($owner);
99 49
    }
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 43
    public function canSetProperty($name, $checkVars = true)
109
    {
110
        /** @var BaseActiveRecord $owner */
111 43
        $owner = $this->owner;
112 43
        $relation = $owner->getRelation($name, false);
113 43
        if (in_array($name, $this->_relations) && !is_null($relation)) {
114 42
            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 42
    public function __set($name, $value)
127
    {
128
        /** @var BaseActiveRecord $owner */
129 42
        $owner = $this->owner;
130 42
        if (in_array($name, $this->_relations) && in_array($name, $owner->safeAttributes())) {
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 19
                        $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 42
    }
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 11
            $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 21
    protected function processModelAsArray($data, $relation, $name)
212
    {
213
        /** @var BaseActiveRecord $modelClass */
214 21
        $modelClass = $relation->modelClass;
215 21
        $fks = $this->_getRelatedFks($data, $relation, $modelClass);
216 21
        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 21
    private function _getRelatedFks($data, $relation, $modelClass)
227
    {
228 21
        $fks = [];
229 21
        if (is_array($data)) {
230
            // Get the right link definition
231 17
            if ($relation->via instanceof BaseActiveRecord) {
232
                $link = $relation->via->link;
233 17
            } elseif (is_array($relation->via)) {
234 11
                list($viaName, $viaQuery) = $relation->via;
235 11
                $link = $viaQuery->link;
236
            } else {
237 11
                $link = $relation->link;
238
            }
239
            // search PK
240 17
            foreach ($modelClass::primaryKey() as $modelAttribute) {
241 17
                if (isset($data[$modelAttribute])) {
242 12
                    $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 1
                            $fks[$relatedAttribute] = $this->owner->{$relatedModelAttribute};
247
                        }
248
                    }
249
                } else {
250 9
                    $fks = [];
251 9
                    break;
252
                }
253
            }
254 17
            if (empty($fks)) {
255 17
                foreach ($link as $relatedAttribute => $modelAttribute) {
256 11
                    if (isset($data[$modelAttribute])) {
257
                        $fks[$modelAttribute] = $data[$modelAttribute];
258
                    }
259
                }
260
            }
261
        } else {
262 5
            $fks = $data;
263
        }
264 21
        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 21
    private function _loadOrCreateRelationModel($data, $fks, $modelClass, $relationName)
276
    {
277
278
        /** @var BaseActiveRecord $relationModel */
279 21
        $relationModel = null;
280 21
        if (!empty($fks)) {
281 14
            $relationModel = $modelClass::findOne($fks);
282
        }
283 21
        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 21
        if (array_key_exists($relationName, $this->_relationsScenario)) {
288 9
            $relationModel->setScenario($this->_relationsScenario[$relationName]);
289
        }
290 21
        if (($relationModel instanceof BaseActiveRecord) && is_array($data)) {
291 17
            $relationModel->setAttributes($data);
292 17
            if ($relationModel->hasMethod('loadRelations')) {
293 15
                $relationModel->loadRelations($data);
294
            }
295
296
        }
297 21
        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 38
    public function beforeValidate(ModelEvent $event)
308
    {
309 38
        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 38
    }
322
323
    /**
324
     * After the owner model validation, rollback newly saved hasOne relations if it fails
325
     * @throws DbException
326
     */
327 38
    public function afterValidate()
328
    {
329
        /* @var $model BaseActiveRecord */
330 38
        $model = $this->owner;
331 38
        if (!empty($this->_savedHasOneModels) && $model->hasErrors()) {
332 2
            $this->_rollbackSavedHasOneModels();
333
        }
334 38
    }
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 37
                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
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 12
            if ($event->isValid && (count($model->dirtyAttributes) || $model->{$relationName}->isNewRecord)) {
391 12
                Yii::debug('Saving ' . self::prettyRelationName($relationName) . ' relation model', __METHOD__);
392 12
                if ($model->{$relationName}->save()) {
393 10
                    $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 16
                    if ($owner->{$relationName}->isNewRecord) {
483 1
                        $owner->{$relationName}->save();
484
                    }
485 16
                    $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 36
    public function afterSave()
498
    {
499 36
        if ($this->_relationsSaveStarted === false) {
500
            /** @var BaseActiveRecord $owner */
501 36
            $owner = $this->owner;
502 36
            $this->_relationsSaveStarted = true;
503
            // Populate relations with updated values
504 36
            foreach ($this->_newRelationValue as $name => $value) {
505 34
                $owner->populateRelation($name, $value);
506
            }
507
            try {
508 36
                foreach ($this->_relations as $relationName) {
509 36
                    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 34
                        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 36
            $owner->refresh();
531 36
            $this->_relationsSaveStarted = false;
532
        }
533 36
    }
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->save()) {
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 16
                $junctionTableColumns = $this->_getJunctionTableColumns($relationName, $relationModel);
558 16
                $owner->link($relationName, $relationModel, $junctionTableColumns);
559
            } else {
560 15
                $existingRecords[] = $relationModel;
561
            }
562 24
            if (count($relationModel->dirtyAttributes) || count($this->_newRelationValue)) {
563 24
                if (!$relationModel->save()) {
564
                    $this->_addError($relationModel, $owner, $relationName, self::prettyRelationName($relationName));
565
                    throw new DbException('Related record ' . self::prettyRelationName($relationName) . ' could not be saved.');
566
                }
567
            }
568
        }
569 23
        $junctionTablePropertiesUsed = array_key_exists($relationName, $this->_relationsExtraColumns);
570
571
        // Process existing added and deleted relations
572 23
        list($addedPks, $deletedPks) = $this->_computePkDiff(
573 23
            $this->_oldRelationValue[$relationName],
574
            $existingRecords,
575
            $junctionTablePropertiesUsed
576
        );
577
578
        // Deleted relations
579
        $initialModels = ArrayHelper::index($this->_oldRelationValue[$relationName], function (BaseActiveRecord $model) {
580 17
            return implode('-', $model->getPrimaryKey(true));
581 23
        });
582 23
        $initialRelations = $owner->{$relationName};
583 23
        foreach ($deletedPks as $key) {
584 5
            $owner->unlink($relationName, $initialModels[$key], true);
585
        }
586
587
        // Added relations
588 23
        $actualModels = ArrayHelper::index(
589 23
            $junctionTablePropertiesUsed ? $initialRelations : $owner->{$relationName},
590
            function (BaseActiveRecord $model) {
591 23
                return implode('-', $model->getPrimaryKey(true));
592 23
            }
593
        );
594 23
        foreach ($addedPks as $key) {
595 4
            $junctionTableColumns = $this->_getJunctionTableColumns($relationName, $actualModels[$key]);
596 4
            $owner->link($relationName, $actualModels[$key], $junctionTableColumns);
597
        }
598 23
    }
599
600
    /**
601
     * Return array of columns to save to the junction table for a related model having a many-to-many relation.
602
     * @param string $relationName
603
     * @param BaseActiveRecord $model
604
     * @return array
605
     * @throws \RuntimeException
606
     */
607 20
    private function _getJunctionTableColumns($relationName, $model)
608
    {
609 20
        $junctionTableColumns = [];
610 20
        if (array_key_exists($relationName, $this->_relationsExtraColumns)) {
611 1
            if (is_callable($this->_relationsExtraColumns[$relationName])) {
612 1
                $junctionTableColumns = $this->_relationsExtraColumns[$relationName]($model);
613
            } elseif (is_array($this->_relationsExtraColumns[$relationName])) {
614
                $junctionTableColumns = $this->_relationsExtraColumns[$relationName];
615
            }
616 1
            if (!is_array($junctionTableColumns)) {
617
                throw new RuntimeException(
618
                    'Junction table columns definition must return an array, got ' . gettype($junctionTableColumns)
619
                );
620
            }
621
        }
622 20
        return $junctionTableColumns;
623
    }
624
625
    /**
626
     * Compute the difference between two set of records using primary keys "tokens"
627
     * If third parameter is set to true all initial related records will be marked for removal even if their
628
     * properties did not change. This can be handy in a many-to-many relation_ involving a junction table.
629
     * @param BaseActiveRecord[] $initialRelations
630
     * @param BaseActiveRecord[] $updatedRelations
631
     * @param bool $forceSave
632
     * @return array
633
     */
634 23
    private function _computePkDiff($initialRelations, $updatedRelations, $forceSave = false)
635
    {
636
        // Compute differences between initial relations and the current ones
637
        $oldPks = ArrayHelper::getColumn($initialRelations, function (BaseActiveRecord $model) {
638 17
            return implode('-', $model->getPrimaryKey(true));
639 23
        });
640 23
        $newPks = ArrayHelper::getColumn($updatedRelations, function (BaseActiveRecord $model) {
641 15
            return implode('-', $model->getPrimaryKey(true));
642 23
        });
643 23
        if ($forceSave) {
644 1
            $addedPks = $newPks;
645 1
            $deletedPks = $oldPks;
646
        } else {
647 22
            $identicalPks = array_intersect($oldPks, $newPks);
648 22
            $addedPks = array_values(array_diff($newPks, $identicalPks));
649 22
            $deletedPks = array_values(array_diff($oldPks, $identicalPks));
650
        }
651 23
        return [$addedPks, $deletedPks];
652
    }
653
654
    /**
655
     * @param $relationName
656
     * @throws \yii\base\InvalidCallException
657
     */
658 21
    private function _afterSaveHasOneRelation($relationName)
659
    {
660
        /** @var BaseActiveRecord $owner */
661 21
        $owner = $this->owner;
662 21
        if ($this->_oldRelationValue[$relationName] !== $owner->{$relationName}) {
663 18
            if ($owner->{$relationName} instanceof BaseActiveRecord) {
664 17
                $owner->link($relationName, $owner->{$relationName});
665
            } else {
666 1
                if ($this->_oldRelationValue[$relationName] instanceof BaseActiveRecord) {
667 1
                    $owner->unlink($relationName, $this->_oldRelationValue[$relationName]);
668
                }
669
            }
670
        }
671 21
        if ($owner->{$relationName} instanceof BaseActiveRecord) {
672 19
            $owner->{$relationName}->save();
673
        }
674 21
    }
675
676
    /**
677
     * Get the list of owner model relations in order to be able to delete them after its deletion
678
     */
679 6
    public function beforeDelete()
680
    {
681
        /** @var BaseActiveRecord $owner */
682 6
        $owner = $this->owner;
683 6
        foreach ($this->_relationsCascadeDelete as $relationName => $params) {
684 3
            if ($params === true) {
685
                /** @var ActiveQuery $relation */
686 3
                $relation = $owner->getRelation($relationName);
687 3
                if (!empty($owner->{$relationName})) {
688 3
                    if ($relation->multiple === true) { // Has many relation
689 2
                        $this->_relationsToDelete = ArrayHelper::merge($this->_relationsToDelete, $owner->{$relationName});
690
                    } else {
691 1
                        $this->_relationsToDelete[] = $owner->{$relationName};
692
                    }
693
                }
694
            }
695
        }
696 6
    }
697
698
    /**
699
     * Delete related models marked as to be deleted
700
     * @throws Exception
701
     */
702 6
    public function afterDelete()
703
    {
704
        /** @var BaseActiveRecord $modelToDelete */
705 6
        foreach ($this->_relationsToDelete as $modelToDelete) {
706
            try {
707 3
                if (!$modelToDelete->delete()) {
708 3
                    throw new DbException('Could not delete the related record: ' . $modelToDelete::className() . '(' . VarDumper::dumpAsString($modelToDelete->primaryKey) . ')');
709
                }
710 1
            } catch (Exception $e) {
711 1
                Yii::warning(get_class($e) . ' was thrown while deleting related records during afterDelete event: ' . $e->getMessage(), __METHOD__);
712 1
                $this->_rollbackSavedHasOneModels();
713 1
                throw $e;
714
            }
715
        }
716 5
    }
717
718
    /**
719
     * Populates relations with input data
720
     * @param array $data
721
     * @throws InvalidConfigException
722
     */
723 17
    public function loadRelations($data)
724
    {
725
        /** @var BaseActiveRecord $owner */
726 17
        $owner = $this->owner;
727 17
        foreach ($this->_relations as $relationName) {
728 17
            $keyName = $this->_getRelationKeyName($relationName);
729 17
            if (array_key_exists($keyName, $data)) {
730 8
                $owner->{$relationName} = $data[$keyName];
731
            }
732
        }
733 17
    }
734
735
    /**
736
     * Set the scenario for a given relation
737
     * @param $relationName
738
     * @param $scenario
739
     * @throws InvalidArgumentException
740
     */
741 2
    public function setRelationScenario($relationName, $scenario)
742
    {
743
        /** @var BaseActiveRecord $owner */
744 2
        $owner = $this->owner;
745 2
        $relation = $owner->getRelation($relationName, false);
746 2
        if (in_array($relationName, $this->_relations) && !is_null($relation)) {
747 1
            $this->_relationsScenario[$relationName] = $scenario;
748
        } else {
749 1
            throw new InvalidArgumentException('Unknown ' . $relationName . ' relation');
750
        }
751
752 1
    }
753
754
    /**
755
     * @param $relationName string
756
     * @return mixed
757
     * @throws InvalidConfigException
758
     */
759 17
    private function _getRelationKeyName($relationName)
760
    {
761 17
        switch ($this->relationKeyName) {
762 17
            case self::RELATION_KEY_RELATION_NAME:
763 1
                $keyName = $relationName;
764 1
                break;
765 17
            case self::RELATION_KEY_FORM_NAME:
766
                /** @var BaseActiveRecord $owner */
767 17
                $owner = $this->owner;
768
                /** @var ActiveQuery $relation */
769 17
                $relation = $owner->getRelation($relationName);
770 17
                $modelClass = $relation->modelClass;
771
                /** @var ActiveQuery $relationalModel */
772 17
                $relationalModel = new $modelClass;
773 17
                $keyName = $relationalModel->formName();
774 17
                break;
775
            default:
776
                throw new InvalidConfigException('Unknown relation key name');
777
        }
778 17
        return $keyName;
779
    }
780
781
    /**
782
     * Return the old relations values.
783
     * @return array The old relations (name-value pairs)
784
     */
785 1
    public function getOldRelations()
786
    {
787 1
        $oldRelations = [];
788 1
        foreach ($this->_relations as $relationName) {
789 1
            $oldRelations[$relationName] = $this->getOldRelation($relationName);
790
        }
791 1
        return $oldRelations;
792
    }
793
794
    /**
795
     * Returns the old value of the named relation.
796
     * @param $relationName string The relations name as defined in the behavior `relations` parameter
797
     * @return mixed
798
     */
799 1
    public function getOldRelation($relationName)
800
    {
801 1
        return array_key_exists($relationName, $this->_oldRelationValue) ? $this->_oldRelationValue[$relationName] : $this->owner->{$relationName};
802
    }
803
804
    /**
805
     * Returns the relations that have been modified since they are loaded.
806
     * @return array The changed relations (name-value pairs)
807
     */
808 2
    public function getDirtyRelations()
809
    {
810 2
        $dirtyRelations = [];
811 2
        foreach ($this->_relations as $relationName) {
812 2
            if (array_key_exists($relationName, $this->_oldRelationValue)) {
813 2
                $dirtyRelations[$relationName] = $this->owner->{$relationName};
814
            }
815
        }
816 2
        return $dirtyRelations;
817
    }
818
819
    /**
820
     * Mark a relation as dirty
821
     * @param $relationName string
822
     * @return bool Whether the operation succeeded.
823
     */
824 1
    public function markRelationDirty($relationName)
825
    {
826 1
        if (in_array($relationName, $this->_relations) && !array_key_exists($relationName, $this->_oldRelationValue)) {
827 1
            $this->_oldRelationValue[$relationName] = $this->owner->{$relationName};
828 1
            return true;
829
        }
830 1
        return false;
831
    }
832
}
833