Test Failed
Pull Request — master (#53)
by
unknown
02:43
created

SaveRelationsBehavior::_loadRelationModel()   A

Complexity

Conditions 5
Paths 6

Size

Total Lines 10
Code Lines 6

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 7
CRAP Score 5

Importance

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