Passed
Pull Request — master (#67)
by
unknown
08:46
created

SaveRelationsBehavior::markRelationDirty()   A

Complexity

Conditions 3
Paths 2

Size

Total Lines 7
Code Lines 4

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 5
CRAP Score 3

Importance

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