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

SaveRelationsBehavior::_getRelatedFks()   C

Complexity

Conditions 14
Paths 13

Size

Total Lines 39
Code Lines 26

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 23
CRAP Score 14.0142

Importance

Changes 2
Bugs 0 Features 0
Metric Value
cc 14
eloc 26
c 2
b 0
f 0
nc 13
nop 3
dl 0
loc 39
ccs 23
cts 24
cp 0.9583
crap 14.0142
rs 6.2666

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