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