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

_loadOrCreateRelationModel()   A

Complexity

Conditions 4
Paths 4

Size

Total Lines 13
Code Lines 7

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 8
CRAP Score 4

Importance

Changes 5
Bugs 1 Features 0
Metric Value
cc 4
eloc 7
c 5
b 1
f 0
nc 4
nop 4
dl 0
loc 13
ccs 8
cts 8
cp 1
crap 4
rs 10
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