Test Failed
Pull Request — master (#63)
by
unknown
05:30 queued 03:29
created

SaveRelationsBehavior::_getRelatedFks()   C

Complexity

Conditions 14
Paths 13

Size

Total Lines 39
Code Lines 26

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 21
CRAP Score 14.3828

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