Passed
Push — master ( 1fb220...9f657b )
by Alban
03:06
created

SaveRelationsBehavior::_setRelationForeignKeys()   A

Complexity

Conditions 6
Paths 5

Size

Total Lines 14
Code Lines 9

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 10
CRAP Score 6

Importance

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