Test Failed
Pull Request — master (#62)
by
unknown
08:10
created

SaveRelationsBehavior::_getRelatedFks()   C

Complexity

Conditions 14
Paths 13

Size

Total Lines 39
Code Lines 26

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 22
CRAP Score 14.1132

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