Passed
Push — master ( ae1f94...178402 )
by Alban
03:19
created

SaveRelationsBehavior::markRelationDirty()   A

Complexity

Conditions 3
Paths 2

Size

Total Lines 7
Code Lines 4

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 5
CRAP Score 3

Importance

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

The issue could also be caused by a filter entry in the build configuration. If the path has been excluded in your configuration, e.g. excluded_paths: ["lib/*"], you can move it to the dependency path list as follows:

filter:
    dependency_paths: ["lib/*"]

For further information see https://scrutinizer-ci.com/docs/tools/php/php-scrutinizer/#list-dependency-paths

Loading history...
783
     * @return mixed
784
     */
785 1
    public function getOldRelation($relationName)
786
    {
787 1
        return array_key_exists($relationName, $this->_oldRelationValue) ? $this->_oldRelationValue[$relationName] : $this->owner->{$relationName};
788
    }
789
790
    /**
791
     * Returns the relations that have been modified since they are loaded.
792
     * @return array The changed relations (name-value pairs)
793
     */
794 2
    public function getDirtyRelations()
795
    {
796 2
        $dirtyRelations = [];
797 2
        foreach ($this->_relations as $relationName) {
798 2
            if (array_key_exists($relationName, $this->_oldRelationValue)) {
799 2
                $dirtyRelations[$relationName] = $this->owner->{$relationName};
800
            }
801
        }
802 2
        return $dirtyRelations;
803
    }
804
805
    /**
806
     * Mark a relation as dirty
807
     * @param $relationName
808
     * @return bool Whether the operation succeeded.
809
     */
810 1
    public function markRelationDirty($relationName)
811
    {
812 1
        if (in_array($relationName, $this->_relations) && !array_key_exists($relationName, $this->_oldRelationValue)) {
813 1
            $this->_oldRelationValue[$relationName] = $this->owner->{$relationName};
814 1
            return true;
815
        }
816 1
        return false;
817
    }
818
}
819