Passed
Push — master ( 1fbe8d...79deb9 )
by Alban
01:56
created

src/SaveRelationsBehavior.php (1 issue)

Labels
Severity
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 44
                            throw new UnknownPropertyException('The relation property named ' . $propertyKey . ' is not supported');
66
                        }
67
                    }
68
                }
69
            }
70
        }
71 49
    }
72
73
    /**
74
     * @inheritdoc
75
     */
76 48
    public function events()
77
    {
78
        return [
79 48
            BaseActiveRecord::EVENT_BEFORE_VALIDATE => 'beforeValidate',
80 48
            BaseActiveRecord::EVENT_AFTER_VALIDATE  => 'afterValidate',
81 48
            BaseActiveRecord::EVENT_AFTER_INSERT    => 'afterSave',
82 48
            BaseActiveRecord::EVENT_AFTER_UPDATE    => 'afterSave',
83 48
            BaseActiveRecord::EVENT_BEFORE_DELETE   => 'beforeDelete',
84 48
            BaseActiveRecord::EVENT_AFTER_DELETE    => 'afterDelete'
85
        ];
86
    }
87
88
    /**
89
     * Check if the behavior is attached to an Active Record
90
     * @param Component $owner
91
     * @throws RuntimeException
92
     */
93 49
    public function attach($owner)
94
    {
95 49
        if (!($owner instanceof BaseActiveRecord)) {
96 1
            throw new RuntimeException('Owner must be instance of yii\db\BaseActiveRecord');
97
        }
98 48
        parent::attach($owner);
99 48
    }
100
101
    /**
102
     * Override canSetProperty method to be able to detect if a relation setter is allowed.
103
     * Setter is allowed if the relation is declared in the `relations` parameter
104
     * @param string $name
105
     * @param boolean $checkVars
106
     * @return boolean
107
     */
108 42
    public function canSetProperty($name, $checkVars = true)
109
    {
110
        /** @var BaseActiveRecord $owner */
111 42
        $owner = $this->owner;
112 42
        $relation = $owner->getRelation($name, false);
113 42
        if (in_array($name, $this->_relations) && !is_null($relation)) {
114 41
            return true;
115
        }
116 1
        return parent::canSetProperty($name, $checkVars);
117
    }
118
119
    /**
120
     * Override __set method to be able to set relations values either by providing a model instance,
121
     * a primary key value or an associative array
122
     * @param string $name
123
     * @param mixed $value
124
     * @throws \yii\base\InvalidArgumentException
125
     */
126 41
    public function __set($name, $value)
127
    {
128
        /** @var BaseActiveRecord $owner */
129 41
        $owner = $this->owner;
130 41
        if (in_array($name, $this->_relations)) {
131 41
            Yii::debug("Setting {$name} relation value", __METHOD__);
132
            /** @var ActiveQuery $relation */
133 41
            $relation = $owner->getRelation($name);
134 41
            if (!isset($this->_oldRelationValue[$name])) {
135 41
                if ($owner->isNewRecord) {
136 19
                    if ($relation->multiple === true) {
137 10
                        $this->_oldRelationValue[$name] = [];
138
                    } else {
139 16
                        $this->_oldRelationValue[$name] = null;
140
                    }
141
                } else {
142 24
                    $this->_oldRelationValue[$name] = $owner->{$name};
143
                }
144
            }
145 41
            if ($relation->multiple === true) {
146 28
                $this->setMultipleRelation($name, $value);
147
            } else {
148 25
                $this->setSingleRelation($name, $value);
149
            }
150
        }
151 41
    }
152
153
    /**
154
     * Set the named single relation with the given value
155
     * @param string $relationName
156
     * @param $value
157
     * @throws \yii\base\InvalidArgumentException
158
     */
159 25
    protected function setSingleRelation($relationName, $value)
160
    {
161
        /** @var BaseActiveRecord $owner */
162 25
        $owner = $this->owner;
163
        /** @var ActiveQuery $relation */
164 25
        $relation = $owner->getRelation($relationName);
165 25
        if (!($value instanceof $relation->modelClass)) {
166 10
            $value = $this->processModelAsArray($value, $relation, $relationName);
167
        }
168 25
        $this->_newRelationValue[$relationName] = $value;
169 25
        $owner->populateRelation($relationName, $value);
170 25
    }
171
172
    /**
173
     * Set the named multiple relation with the given value
174
     * @param string $relationName
175
     * @param $value
176
     * @throws \yii\base\InvalidArgumentException
177
     */
178 28
    protected function setMultipleRelation($relationName, $value)
179
    {
180
        /** @var BaseActiveRecord $owner */
181 28
        $owner = $this->owner;
182
        /** @var ActiveQuery $relation */
183 28
        $relation = $owner->getRelation($relationName);
184 28
        $newRelations = [];
185 28
        if (!is_array($value)) {
186 4
            if (!empty($value)) {
187 3
                $value = [$value];
188
            } else {
189 1
                $value = [];
190
            }
191
        }
192 28
        foreach ($value as $entry) {
193 27
            if ($entry instanceof $relation->modelClass) {
194 16
                $newRelations[] = $entry;
195
            } else {
196
                // TODO handle this with one DB request to retrieve all models
197 15
                $newRelations[] = $this->processModelAsArray($entry, $relation, $relationName);
198
            }
199
        }
200 28
        $this->_newRelationValue[$relationName] = $newRelations;
201 28
        $owner->populateRelation($relationName, $newRelations);
202 28
    }
203
204
    /**
205
     * Get a BaseActiveRecord model using the given $data parameter.
206
     * $data could either be a model ID or an associative array representing model attributes => values
207
     * @param mixed $data
208
     * @param \yii\db\ActiveQuery $relation
209
     * @return BaseActiveRecord
210
     */
211 20
    protected function processModelAsArray($data, $relation, $name)
212
    {
213
        /** @var BaseActiveRecord $modelClass */
214 20
        $modelClass = $relation->modelClass;
215 20
        $fks = $this->_getRelatedFks($data, $relation, $modelClass);
216 20
        return $this->_loadOrCreateRelationModel($data, $fks, $modelClass, $name);
217
    }
218
219
    /**
220
     * Get the related model foreign keys
221
     * @param $data
222
     * @param $relation
223
     * @param BaseActiveRecord $modelClass
224
     * @return array
225
     */
226 20
    private function _getRelatedFks($data, $relation, $modelClass)
227
    {
228 20
        $fks = [];
229 20
        if (is_array($data)) {
230
            // Get the right link definition
231 16
            if ($relation->via instanceof BaseActiveRecord) {
232
                $link = $relation->via->link;
233 16
            } elseif (is_array($relation->via)) {
234 11
                list($viaName, $viaQuery) = $relation->via;
235 11
                $link = $viaQuery->link;
236
            } else {
237 10
                $link = $relation->link;
238
            }
239
            // search PK
240 16
            foreach ($modelClass::primaryKey() as $modelAttribute) {
241 16
                if (isset($data[$modelAttribute])) {
242 11
                    $fks[$modelAttribute] = $data[$modelAttribute];
243 12
                } elseif ($relation->multiple && !$relation->via) {
244 4
                    foreach ($link as $relatedAttribute => $relatedModelAttribute) {
245 4
                        if (!isset($data[$relatedAttribute]) && in_array($relatedAttribute, $modelClass::primaryKey())) {
246 4
                            $fks[$relatedAttribute] = $this->owner->{$relatedModelAttribute};
247
                        }
248
                    }
249
                } else {
250 9
                    $fks = [];
251 9
                    break;
252
                }
253
            }
254 16
            if (empty($fks)) {
255 11
                foreach ($link as $relatedAttribute => $modelAttribute) {
256 11
                    if (isset($data[$modelAttribute])) {
257 11
                        $fks[$modelAttribute] = $data[$modelAttribute];
258
                    }
259
                }
260
            }
261
        } else {
262 5
            $fks = $data;
263
        }
264 20
        return $fks;
265
    }
266
267
    /**
268
     * Load existing model or create one if no key was provided and data is not empty
269
     * @param $data
270
     * @param $fks
271
     * @param $modelClass
272
     * @param $relationName
273
     * @return BaseActiveRecord
274
     */
275 20
    private function _loadOrCreateRelationModel($data, $fks, $modelClass, $relationName)
276
    {
277
278
        /** @var BaseActiveRecord $relationModel */
279 20
        $relationModel = null;
280 20
        if (!empty($fks)) {
281 13
            $relationModel = $modelClass::findOne($fks);
282
        }
283 20
        if (!($relationModel instanceof BaseActiveRecord) && !empty($data)) {
284 15
            $relationModel = new $modelClass;
285
        }
286
        // If a custom scenario is set, apply it here to correctly be able to set the model attributes
287 20
        if (array_key_exists($relationName, $this->_relationsScenario)) {
288 9
            $relationModel->setScenario($this->_relationsScenario[$relationName]);
289
        }
290 20
        if (($relationModel instanceof BaseActiveRecord) && is_array($data)) {
291 16
            $relationModel->setAttributes($data);
292 16
            if ($relationModel->hasMethod('loadRelations')) {
293 14
                $relationModel->loadRelations($data);
294
            }
295
296
        }
297 20
        return $relationModel;
298
    }
299
300
    /**
301
     * Before the owner model validation, save related models.
302
     * For `hasOne()` relations, set the according foreign keys of the owner model to be able to validate it
303
     * @param ModelEvent $event
304
     * @throws DbException
305
     * @throws \yii\base\InvalidConfigException
306
     */
307 37
    public function beforeValidate(ModelEvent $event)
308
    {
309 37
        if ($this->_relationsSaveStarted === false && !empty($this->_oldRelationValue)) {
310
            /* @var $model BaseActiveRecord */
311 37
            $model = $this->owner;
312 37
            if ($this->saveRelatedRecords($model, $event)) {
313
                // If relation is has_one, try to set related model attributes
314 37
                foreach ($this->_relations as $relationName) {
315 37
                    if (array_key_exists($relationName, $this->_oldRelationValue)) { // Relation was not set, do nothing...
316 37
                        $this->_setRelationForeignKeys($relationName);
317
                    }
318
                }
319
            }
320
        }
321 37
    }
322
323
    /**
324
     * After the owner model validation, rollback newly saved hasOne relations if it fails
325
     * @throws DbException
326
     */
327 37
    public function afterValidate()
328
    {
329 37
        if ($this->owner->hasErrors() && !empty($this->_savedHasOneModels)) {
0 ignored issues
show
The method hasErrors() does not exist on null. ( Ignorable by Annotation )

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

329
        if ($this->owner->/** @scrutinizer ignore-call */ hasErrors() && !empty($this->_savedHasOneModels)) {

This check looks for calls to methods that do not seem to exist on a given type. It looks for the method on the type itself as well as in inherited classes or implemented interfaces.

This is most likely a typographical error or the method has been renamed.

Loading history...
330 2
            $this->_rollbackSavedHasOneModels();
331
        }
332 37
    }
333
334
    /**
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 37
    protected function saveRelatedRecords(BaseActiveRecord $model, ModelEvent $event)
345
    {
346
        try {
347 37
            foreach ($this->_relations as $relationName) {
348 37
                if (array_key_exists($relationName, $this->_oldRelationValue)) { // Relation was not set, do nothing...
349
                    /** @var ActiveQuery $relation */
350 37
                    $relation = $model->getRelation($relationName);
351 37
                    if (!empty($model->{$relationName})) {
352 35
                        if ($relation->multiple === false) {
353 22
                            $this->_prepareHasOneRelation($model, $relationName, $event);
354
                        } else {
355 24
                            $this->_prepareHasManyRelation($model, $relationName);
356
                        }
357
                    }
358
                }
359
            }
360 37
            if (!$event->isValid) {
361
                throw new Exception('One of the related model could not be validated');
362
            }
363
        } 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 37
        return true;
371
    }
372
373
    /**
374
     * @param BaseActiveRecord $model
375
     * @param ModelEvent $event
376
     * @param $relationName
377
     */
378 22
    private function _prepareHasOneRelation(BaseActiveRecord $model, $relationName, ModelEvent $event)
379
    {
380 22
        Yii::debug("_prepareHasOneRelation for {$relationName}", __METHOD__);
381 22
        $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));
385 22
        $p2 = $relationModel::isPrimaryKey(array_values($relation->link));
386 22
        if ($relationModel->getIsNewRecord() && $p1 && !$p2) {
387
            // Save Has one relation new record
388 13
            if ($event->isValid && (count($model->dirtyAttributes) || $model->{$relationName}->isNewRecord)) {
389 13
                Yii::debug('Saving ' . self::prettyRelationName($relationName) . ' relation model', __METHOD__);
390 13
                if ($model->{$relationName}->save()) {
391 11
                    $this->_savedHasOneModels[] = $model->{$relationName};
392
                }
393
            }
394
        }
395 22
    }
396
397
    /**
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 35
    protected function validateRelationModel($prettyRelationName, $relationName, BaseActiveRecord $relationModel)
404
    {
405
        /** @var BaseActiveRecord $model */
406 35
        $model = $this->owner;
407 35
        if (!is_null($relationModel) && ($relationModel->isNewRecord || count($relationModel->getDirtyAttributes()))) {
408 27
            Yii::debug("Validating {$prettyRelationName} relation model using " . $relationModel->scenario . ' scenario', __METHOD__);
409 27
            if (!$relationModel->validate()) {
410 4
                $this->_addError($relationModel, $model, $relationName, $prettyRelationName);
411
            }
412
413
        }
414 35
    }
415
416
    /**
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 5
    private function _addError($relationModel, $owner, $relationName, $prettyRelationName)
424
    {
425 5
        foreach ($relationModel->errors as $attribute => $attributeErrors) {
426 5
            foreach ($attributeErrors as $error) {
427 5
                $owner->addError($relationName, "{$prettyRelationName}: {$error}");
428
            }
429
        }
430 5
    }
431
432
    /**
433
     * @param $relationName
434
     * @param int|null $i
435
     * @return string
436
     */
437 35
    protected static function prettyRelationName($relationName, $i = null)
438
    {
439 35
        return Inflector::camel2words($relationName, true) . (is_null($i) ? '' : " #{$i}");
440
    }
441
442
    /**
443
     * @param BaseActiveRecord $model
444
     * @param $relationName
445
     */
446 24
    private function _prepareHasManyRelation(BaseActiveRecord $model, $relationName)
447
    {
448
        /** @var BaseActiveRecord $relationModel */
449 24
        foreach ($model->{$relationName} as $i => $relationModel) {
450 24
            $this->validateRelationModel(self::prettyRelationName($relationName, $i), $relationName, $relationModel);
451
        }
452 24
    }
453
454
    /**
455
     * Delete newly created Has one models if any
456
     * @throws DbException
457
     */
458 4
    private function _rollbackSavedHasOneModels()
459
    {
460 4
        foreach ($this->_savedHasOneModels as $savedHasOneModel) {
461 3
            $savedHasOneModel->delete();
462
        }
463 4
        $this->_savedHasOneModels = [];
464 4
    }
465
466
    /**
467
     * Set relation foreign keys that point to owner primary key
468
     * @param $relationName
469
     */
470 37
    protected function _setRelationForeignKeys($relationName)
471
    {
472
        /** @var BaseActiveRecord $owner */
473 37
        $owner = $this->owner;
474
        /** @var ActiveQuery $relation */
475 37
        $relation = $owner->getRelation($relationName);
476 37
        if ($relation->multiple === false && !empty($owner->{$relationName})) {
477 22
            Yii::debug("Setting foreign keys for {$relationName}", __METHOD__);
478 22
            foreach ($relation->link as $relatedAttribute => $modelAttribute) {
479 22
                if ($owner->{$modelAttribute} !== $owner->{$relationName}->{$relatedAttribute}) {
480 17
                    if ($owner->{$relationName}->isNewRecord) {
481 1
                        $owner->{$relationName}->save();
482
                    }
483 17
                    $owner->{$modelAttribute} = $owner->{$relationName}->{$relatedAttribute};
484
                }
485
            }
486
        }
487 37
    }
488
489
    /**
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 35
    public function afterSave()
496
    {
497 35
        if ($this->_relationsSaveStarted === false) {
498
            /** @var BaseActiveRecord $owner */
499 35
            $owner = $this->owner;
500 35
            $this->_relationsSaveStarted = true;
501
            // Populate relations with updated values
502 35
            foreach ($this->_newRelationValue as $name => $value) {
503 34
                $owner->populateRelation($name, $value);
504
            }
505
            try {
506 35
                foreach ($this->_relations as $relationName) {
507 35
                    if (array_key_exists($relationName, $this->_oldRelationValue)) { // Relation was not set, do nothing...
508 34
                        Yii::debug("Linking {$relationName} relation", __METHOD__);
509
                        /** @var ActiveQuery $relation */
510 34
                        $relation = $owner->getRelation($relationName);
511 34
                        if ($relation->multiple === true) { // Has many relation
512 24
                            $this->_afterSaveHasManyRelation($relationName);
513
                        } else { // Has one relation
514 21
                            $this->_afterSaveHasOneRelation($relationName);
515
                        }
516 35
                        unset($this->_oldRelationValue[$relationName]);
517
                    }
518
                }
519 1
            } catch (Exception $e) {
520 1
                Yii::warning(get_class($e) . ' was thrown while saving related records during afterSave event: ' . $e->getMessage(), __METHOD__);
521 1
                $this->_rollbackSavedHasOneModels();
522
                /***
523
                 * 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 1
                throw $e;
527
            }
528 35
            $owner->refresh();
529 35
            $this->_relationsSaveStarted = false;
530
        }
531 35
    }
532
533
    /**
534
     * @param $relationName
535
     * @throws DbException
536
     */
537 24
    public function _afterSaveHasManyRelation($relationName)
538
    {
539
        /** @var BaseActiveRecord $owner */
540 24
        $owner = $this->owner;
541
        /** @var ActiveQuery $relation */
542 24
        $relation = $owner->getRelation($relationName);
543
544
        // Process new relations
545 24
        $existingRecords = [];
546
        /** @var ActiveQuery $relationModel */
547 24
        foreach ($owner->{$relationName} as $i => $relationModel) {
548 24
            if ($relationModel->isNewRecord) {
549 16
                if (!empty($relation->via)) {
550 11
                    if ($relationModel->validate()) {
551 11
                        $relationModel->save();
552
                    } else {
553 1
                        $this->_addError($relationModel, $owner, $relationName, self::prettyRelationName($relationName, $i));
554 1
                        throw new DbException('Related record ' . self::prettyRelationName($relationName, $i) . ' could not be saved.');
555
                    }
556
                }
557 16
                $junctionTableColumns = $this->_getJunctionTableColumns($relationName, $relationModel);
558 16
                $owner->link($relationName, $relationModel, $junctionTableColumns);
559
            } else {
560 15
                $existingRecords[] = $relationModel;
561
            }
562 24
            if (count($relationModel->dirtyAttributes)) {
563 6
                if ($relationModel->validate()) {
564 6
                    $relationModel->save();
565
                } else {
566
                    $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 23
        $junctionTablePropertiesUsed = array_key_exists($relationName, $this->_relationsExtraColumns);
572
573
        // Process existing added and deleted relations
574 23
        list($addedPks, $deletedPks) = $this->_computePkDiff(
575 23
            $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 17
            return implode('-', $model->getPrimaryKey(true));
583 23
        });
584 23
        $initialRelations = $owner->{$relationName};
585 23
        foreach ($deletedPks as $key) {
586 5
            $owner->unlink($relationName, $initialModels[$key], true);
587
        }
588
589
        // Added relations
590 23
        $actualModels = ArrayHelper::index(
591 23
            $junctionTablePropertiesUsed ? $initialRelations : $owner->{$relationName},
592
            function (BaseActiveRecord $model) {
593 23
                return implode('-', $model->getPrimaryKey(true));
594 23
            }
595
        );
596 23
        foreach ($addedPks as $key) {
597 4
            $junctionTableColumns = $this->_getJunctionTableColumns($relationName, $actualModels[$key]);
598 4
            $owner->link($relationName, $actualModels[$key], $junctionTableColumns);
599
        }
600 23
    }
601
602
    /**
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 20
    private function _getJunctionTableColumns($relationName, $model)
610
    {
611 20
        $junctionTableColumns = [];
612 20
        if (array_key_exists($relationName, $this->_relationsExtraColumns)) {
613 1
            if (is_callable($this->_relationsExtraColumns[$relationName])) {
614 1
                $junctionTableColumns = $this->_relationsExtraColumns[$relationName]($model);
615
            } elseif (is_array($this->_relationsExtraColumns[$relationName])) {
616
                $junctionTableColumns = $this->_relationsExtraColumns[$relationName];
617
            }
618 1
            if (!is_array($junctionTableColumns)) {
619
                throw new RuntimeException(
620
                    'Junction table columns definition must return an array, got ' . gettype($junctionTableColumns)
621
                );
622
            }
623
        }
624 20
        return $junctionTableColumns;
625
    }
626
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 23
    private function _computePkDiff($initialRelations, $updatedRelations, $forceSave = false)
637
    {
638
        // Compute differences between initial relations and the current ones
639
        $oldPks = ArrayHelper::getColumn($initialRelations, function (BaseActiveRecord $model) {
640 17
            return implode('-', $model->getPrimaryKey(true));
641 23
        });
642 23
        $newPks = ArrayHelper::getColumn($updatedRelations, function (BaseActiveRecord $model) {
643 15
            return implode('-', $model->getPrimaryKey(true));
644 23
        });
645 23
        if ($forceSave) {
646 1
            $addedPks = $newPks;
647 1
            $deletedPks = $oldPks;
648
        } else {
649 22
            $identicalPks = array_intersect($oldPks, $newPks);
650 22
            $addedPks = array_values(array_diff($newPks, $identicalPks));
651 22
            $deletedPks = array_values(array_diff($oldPks, $identicalPks));
652
        }
653 23
        return [$addedPks, $deletedPks];
654
    }
655
656
    /**
657
     * @param $relationName
658
     * @throws \yii\base\InvalidCallException
659
     */
660 21
    private function _afterSaveHasOneRelation($relationName)
661
    {
662
        /** @var BaseActiveRecord $owner */
663 21
        $owner = $this->owner;
664 21
        if ($this->_oldRelationValue[$relationName] !== $owner->{$relationName}) {
665 18
            if ($owner->{$relationName} instanceof BaseActiveRecord) {
666 17
                $owner->link($relationName, $owner->{$relationName});
667
            } else {
668 1
                if ($this->_oldRelationValue[$relationName] instanceof BaseActiveRecord) {
669 1
                    $owner->unlink($relationName, $this->_oldRelationValue[$relationName]);
670
                }
671
            }
672
        }
673 21
        if ($owner->{$relationName} instanceof BaseActiveRecord) {
674 19
            $owner->{$relationName}->save();
675
        }
676 21
    }
677
678
    /**
679
     * Get the list of owner model relations in order to be able to delete them after its deletion
680
     */
681 6
    public function beforeDelete()
682
    {
683
        /** @var BaseActiveRecord $owner */
684 6
        $owner = $this->owner;
685 6
        foreach ($this->_relationsCascadeDelete as $relationName => $params) {
686 3
            if ($params === true) {
687
                /** @var ActiveQuery $relation */
688 3
                $relation = $owner->getRelation($relationName);
689 3
                if (!empty($owner->{$relationName})) {
690 3
                    if ($relation->multiple === true) { // Has many relation
691 2
                        $this->_relationsToDelete = ArrayHelper::merge($this->_relationsToDelete, $owner->{$relationName});
692
                    } else {
693 1
                        $this->_relationsToDelete[] = $owner->{$relationName};
694
                    }
695
                }
696
            }
697
        }
698 6
    }
699
700
    /**
701
     * Delete related models marked as to be deleted
702
     * @throws Exception
703
     */
704 6
    public function afterDelete()
705
    {
706
        /** @var BaseActiveRecord $modelToDelete */
707 6
        foreach ($this->_relationsToDelete as $modelToDelete) {
708
            try {
709 3
                if (!$modelToDelete->delete()) {
710 1
                    throw new DbException('Could not delete the related record: ' . $modelToDelete::className() . '(' . VarDumper::dumpAsString($modelToDelete->primaryKey) . ')');
711
                }
712 1
            } catch (Exception $e) {
713 1
                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
            }
717
        }
718 5
    }
719
720
    /**
721
     * Populates relations with input data
722
     * @param array $data
723
     * @throws InvalidConfigException
724
     */
725 16
    public function loadRelations($data)
726
    {
727
        /** @var BaseActiveRecord $owner */
728 16
        $owner = $this->owner;
729 16
        foreach ($this->_relations as $relationName) {
730 16
            $keyName = $this->_getRelationKeyName($relationName);
731 16
            if (array_key_exists($keyName, $data)) {
732 8
                $owner->{$relationName} = $data[$keyName];
733
            }
734
        }
735 16
    }
736
737
    /**
738
     * Set the scenario for a given relation
739
     * @param $relationName
740
     * @param $scenario
741
     * @throws InvalidArgumentException
742
     */
743 2
    public function setRelationScenario($relationName, $scenario)
744
    {
745
        /** @var BaseActiveRecord $owner */
746 2
        $owner = $this->owner;
747 2
        $relation = $owner->getRelation($relationName, false);
748 2
        if (in_array($relationName, $this->_relations) && !is_null($relation)) {
749 1
            $this->_relationsScenario[$relationName] = $scenario;
750
        } else {
751 1
            throw new InvalidArgumentException('Unknown ' . $relationName . ' relation');
752
        }
753
754 1
    }
755
756
    /**
757
     * @param $relationName string
758
     * @return mixed
759
     * @throws InvalidConfigException
760
     */
761 16
    private function _getRelationKeyName($relationName)
762
    {
763 16
        switch ($this->relationKeyName) {
764 16
            case self::RELATION_KEY_RELATION_NAME:
765 1
                $keyName = $relationName;
766 1
                break;
767 16
            case self::RELATION_KEY_FORM_NAME:
768
                /** @var BaseActiveRecord $owner */
769 16
                $owner = $this->owner;
770
                /** @var ActiveQuery $relation */
771 16
                $relation = $owner->getRelation($relationName);
772 16
                $modelClass = $relation->modelClass;
773
                /** @var ActiveQuery $relationalModel */
774 16
                $relationalModel = new $modelClass;
775 16
                $keyName = $relationalModel->formName();
776 16
                break;
777
            default:
778
                throw new InvalidConfigException('Unknown relation key name');
779
        }
780 16
        return $keyName;
781
    }
782
783
    /**
784
     * Return the old relations values.
785
     * @return array The old relations (name-value pairs)
786
     */
787 1
    public function getOldRelations()
788
    {
789 1
        $oldRelations = [];
790 1
        foreach ($this->_relations as $relationName) {
791 1
            $oldRelations[$relationName] = $this->getOldRelation($relationName);
792
        }
793 1
        return $oldRelations;
794
    }
795
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 1
    public function getOldRelation($relationName)
802
    {
803 1
        return array_key_exists($relationName, $this->_oldRelationValue) ? $this->_oldRelationValue[$relationName] : $this->owner->{$relationName};
804
    }
805
806
    /**
807
     * Returns the relations that have been modified since they are loaded.
808
     * @return array The changed relations (name-value pairs)
809
     */
810 2
    public function getDirtyRelations()
811
    {
812 2
        $dirtyRelations = [];
813 2
        foreach ($this->_relations as $relationName) {
814 2
            if (array_key_exists($relationName, $this->_oldRelationValue)) {
815 2
                $dirtyRelations[$relationName] = $this->owner->{$relationName};
816
            }
817
        }
818 2
        return $dirtyRelations;
819
    }
820
821
    /**
822
     * Mark a relation as dirty
823
     * @param $relationName string
824
     * @return bool Whether the operation succeeded.
825
     */
826 1
    public function markRelationDirty($relationName)
827
    {
828 1
        if (in_array($relationName, $this->_relations) && !array_key_exists($relationName, $this->_oldRelationValue)) {
829 1
            $this->_oldRelationValue[$relationName] = $this->owner->{$relationName};
830 1
            return true;
831
        }
832 1
        return false;
833
    }
834
}
835