Passed
Pull Request — master (#34)
by
unknown
02:37
created

SaveRelationsBehavior::validateRelationModel()   A

Complexity

Conditions 6
Paths 5

Size

Total Lines 11
Code Lines 8

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 9
CRAP Score 6

Importance

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