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

SaveRelationsBehavior::__set()   B

Complexity

Conditions 7
Paths 9

Size

Total Lines 24
Code Lines 17

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 15
CRAP Score 7

Importance

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