Test Failed
Pull Request — master (#54)
by
unknown
07:00
created

SaveRelationsBehavior::_getRelatedFks()   C

Complexity

Conditions 14
Paths 13

Size

Total Lines 39
Code Lines 26

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 17
CRAP Score 14.0336

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 17
cts 18
cp 0.9444
crap 14.0336
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\Model;
13
use yii\base\ModelEvent;
14
use yii\base\UnknownPropertyException;
15
use yii\db\ActiveQuery;
16
use yii\db\BaseActiveRecord;
17
use yii\db\Exception as DbException;
18
use yii\helpers\ArrayHelper;
19
use yii\helpers\Inflector;
20
use yii\helpers\VarDumper;
21
22
/**
23
 * This Active Record Behavior allows to validate and save the Model relations when the save() method is invoked.
24
 * List of handled relations should be declared using the $relations parameter via an array of relation names.
25
 * @author albanjubert
26
 */
27
class SaveRelationsBehavior extends Behavior
28
{
29
30
    const RELATION_KEY_FORM_NAME = 'formName';
31
    const RELATION_KEY_RELATION_NAME = 'relationName';
32
33
    public $relations = [];
34
    public $relationKeyName = self::RELATION_KEY_FORM_NAME;
35
36
    private $_relations = [];
37
    private $_oldRelationValue = []; // Store initial relations value
38
    private $_newRelationValue = []; // Store update relations value
39
    private $_relationsToDelete = [];
40
    private $_relationsSaveStarted = false;
41
42
    /** @var BaseActiveRecord[] $_savedHasOneModels */
43
    private $_savedHasOneModels = [];
44
45
    private $_relationsScenario = [];
46
    private $_relationsExtraColumns = [];
47
    private $_relationsCascadeDelete = [];
48
49
    /**
50
     * @inheritdoc
51 49
     */
52
    public function init()
53 49
    {
54 49
        parent::init();
55 49
        $allowedProperties = ['scenario', 'extraColumns', 'cascadeDelete'];
56 48
        foreach ($this->relations as $key => $value) {
57 48
            if (is_int($key)) {
58
                $this->_relations[] = $value;
59 44
            } else {
60 44
                $this->_relations[] = $key;
61 44
                if (is_array($value)) {
62 44
                    foreach ($value as $propertyKey => $propertyValue) {
63 44
                        if (in_array($propertyKey, $allowedProperties)) {
64
                            $this->{'_relations' . ucfirst($propertyKey)}[$key] = $propertyValue;
65 1
                        } else {
66
                            throw new UnknownPropertyException('The relation property named ' . $propertyKey . ' is not supported');
67
                        }
68
                    }
69
                }
70
            }
71 49
        }
72
    }
73
74
    /**
75
     * @inheritdoc
76 48
     */
77
    public function events()
78
    {
79 48
        return [
80 48
            BaseActiveRecord::EVENT_BEFORE_VALIDATE => 'beforeValidate',
81 48
            BaseActiveRecord::EVENT_AFTER_VALIDATE  => 'afterValidate',
82 48
            BaseActiveRecord::EVENT_AFTER_INSERT    => 'afterSave',
83 48
            BaseActiveRecord::EVENT_AFTER_UPDATE    => 'afterSave',
84 48
            BaseActiveRecord::EVENT_BEFORE_DELETE   => 'beforeDelete',
85
            BaseActiveRecord::EVENT_AFTER_DELETE    => 'afterDelete'
86
        ];
87
    }
88
89
    /**
90
     * Check if the behavior is attached to an Active Record
91
     * @param Component $owner
92
     * @throws RuntimeException
93 49
     */
94
    public function attach($owner)
95 49
    {
96 1
        if (!($owner instanceof BaseActiveRecord)) {
97
            throw new RuntimeException('Owner must be instance of yii\db\BaseActiveRecord');
98 48
        }
99 48
        parent::attach($owner);
100
    }
101
102
    /**
103
     * Override canSetProperty method to be able to detect if a relation setter is allowed.
104
     * Setter is allowed if the relation is declared in the `relations` parameter
105
     * @param string $name
106
     * @param boolean $checkVars
107
     * @return boolean
108 42
     */
109
    public function canSetProperty($name, $checkVars = true)
110
    {
111 42
        /** @var BaseActiveRecord $owner */
112 42
        $owner = $this->owner;
113 42
        $relation = $owner->getRelation($name, false);
114 41
        if (in_array($name, $this->_relations) && !is_null($relation)) {
115
            return true;
116 1
        }
117
        return parent::canSetProperty($name, $checkVars);
118
    }
119
120
    /**
121
     * Override __set method to be able to set relations values either by providing a model instance,
122
     * a primary key value or an associative array
123
     * @param string $name
124
     * @param mixed $value
125
     * @throws \yii\base\InvalidArgumentException
126 41
     */
127
    public function __set($name, $value)
128
    {
129 41
        /** @var BaseActiveRecord $owner */
130 41
        $owner = $this->owner;
131 41
        if (in_array($name, $this->_relations)) {
132
            Yii::debug("Setting {$name} relation value", __METHOD__);
133 41
            /** @var ActiveQuery $relation */
134 41
            $relation = $owner->getRelation($name);
135 41
            if (!isset($this->_oldRelationValue[$name])) {
136 19
                if ($owner->isNewRecord) {
137 10
                    if ($relation->multiple === true) {
138
                        $this->_oldRelationValue[$name] = [];
139 19
                    } else {
140
                        $this->_oldRelationValue[$name] = null;
141
                    }
142 24
                } else {
143
                    $this->_oldRelationValue[$name] = $owner->{$name};
144
                }
145 41
            }
146 28
            if ($relation->multiple === true) {
147
                $this->setMultipleRelation($name, $value);
148 25
            } else {
149
                $this->setSingleRelation($name, $value);
150
            }
151 41
        }
152
    }
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 25
     */
160
    protected function setSingleRelation($relationName, $value)
161
    {
162 25
        /** @var BaseActiveRecord $owner */
163
        $owner = $this->owner;
164 25
        /** @var ActiveQuery $relation */
165 25
        $relation = $owner->getRelation($relationName);
166 10
167
        if (!($value instanceof $relation->modelClass)) {
168 25
            //we have an existing hasone relation model
169 25
            if(is_array($value) && $this->_getRelatedFks($value, $relation, $relation->modelClass) && $owner->{$relationName} instanceof $relation->modelClass && !$owner->{$relationName}->getIsNewRecord()) {
0 ignored issues
show
Bug introduced by
$relation->modelClass of type string is incompatible with the type yii\db\BaseActiveRecord expected by parameter $modelClass of lhs\Yii2SaveRelationsBeh...avior::_getRelatedFks(). ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

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