Browse Source

* Small refactoring.

* Changed the way exceptions are thrown and handled during afterSave event (add error to relation attribute of the owner model, forced rollback if relevant)
tags/1.3.0
Alban Jubert 7 years ago
parent
commit
96c153f110
  1. 2
      CHANGELOG.md
  2. 5
      README.md
  3. 95
      src/SaveRelationsBehavior.php
  4. 25
      tests/SaveRelationsBehaviorTest.php
  5. 4
      tests/models/Project.php

2
CHANGELOG.md

@ -11,7 +11,7 @@
- False positive testLoadRelationsShouldSucceed test case - False positive testLoadRelationsShouldSucceed test case
### Changed ### Changed
- afterSave throw exception if a related record fail to be saved - afterSave throw exception if a related record fail to be saved. In that case, a database rollback is triggered (when relevant) and an error is attached to the according relation attribute
- related record are now correctly updated based on there primary key (Thanks to @DD174) - related record are now correctly updated based on there primary key (Thanks to @DD174)
## [1.2.0] ## [1.2.0]

5
README.md

@ -153,6 +153,11 @@ For instance, in the following configuration, the `links ` related records will
> **Tips:** > **Tips:**
> For relations not involving a junction table by using the `via()` or `viaTable()` methods, you should remove the attributes pointing to the owner model from the 'required' validation rules to be able to pass the validations. > For relations not involving a junction table by using the `via()` or `viaTable()` methods, you should remove the attributes pointing to the owner model from the 'required' validation rules to be able to pass the validations.
> **Note:**
> If an error occurs for any reason during the saving process of related records in the afterSave event, a `yii\db\Exception` will be thrown on the first occurring error.
> An error message will be attached to the relation attribute of the owner model.
> In order to be able to handle these cases in a user-friendly way, one will have to catch `yii\db\Exception` exceptions.
Populate the model and its relations with input data Populate the model and its relations with input data
---------------------------------------------------- ----------------------------------------------------
This behavior adds a convenient method to load relations models attributes in the same way that the load() method does. This behavior adds a convenient method to load relations models attributes in the same way that the load() method does.

95
src/SaveRelationsBehavior.php

@ -10,10 +10,10 @@ use yii\base\ModelEvent;
use yii\base\UnknownPropertyException; use yii\base\UnknownPropertyException;
use yii\db\ActiveQueryInterface; use yii\db\ActiveQueryInterface;
use yii\db\BaseActiveRecord; use yii\db\BaseActiveRecord;
use Yii\db\Exception as DbException;
use yii\db\Transaction; use yii\db\Transaction;
use yii\helpers\ArrayHelper; use yii\helpers\ArrayHelper;
use yii\helpers\Inflector; use yii\helpers\Inflector;
use yii\helpers\VarDumper;
/** /**
* This Active Record Behavior allows to validate and save the Model relations when the save() method is invoked. * This Active Record Behavior allows to validate and save the Model relations when the save() method is invoked.
@ -287,10 +287,7 @@ class SaveRelationsBehavior extends Behavior
} }
} catch (Exception $e) { } catch (Exception $e) {
Yii::warning(get_class($e) . " was thrown during the saving of related records : " . $e->getMessage(), __METHOD__); Yii::warning(get_class($e) . " was thrown during the saving of related records : " . $e->getMessage(), __METHOD__);
if (($this->_transaction instanceof Transaction) && $this->_transaction->isActive) { $this->_rollback();
$this->_transaction->rollBack(); // If anything goes wrong, transaction will be rolled back
Yii::info("Rolling back", __METHOD__);
}
$event->isValid = false; // Stop saving, something went wrong $event->isValid = false; // Stop saving, something went wrong
return false; return false;
} }
@ -330,12 +327,7 @@ class SaveRelationsBehavior extends Behavior
} }
Yii::trace("Validating {$pettyRelationName} relation model using " . $relationModel->scenario . " scenario", __METHOD__); Yii::trace("Validating {$pettyRelationName} relation model using " . $relationModel->scenario . " scenario", __METHOD__);
if (!$relationModel->validate()) { if (!$relationModel->validate()) {
foreach ($relationModel->errors as $attributeErrors) { $this->_addError($relationModel, $model, $relationName, $pettyRelationName);
foreach ($attributeErrors as $error) {
$model->addError($relationName, "{$pettyRelationName}: {$error}");
}
$event->isValid = false;
}
} }
} }
} }
@ -352,6 +344,8 @@ class SaveRelationsBehavior extends Behavior
$model = $this->owner; $model = $this->owner;
$this->_relationsSaveStarted = true; $this->_relationsSaveStarted = true;
try { try {
foreach ($this->_relations as $relationName) { foreach ($this->_relations as $relationName) {
if (array_key_exists($relationName, $this->_oldRelationValue)) { // Relation was not set, do nothing... if (array_key_exists($relationName, $this->_oldRelationValue)) { // Relation was not set, do nothing...
Yii::trace("Linking {$relationName} relation", __METHOD__); Yii::trace("Linking {$relationName} relation", __METHOD__);
@ -361,11 +355,15 @@ class SaveRelationsBehavior extends Behavior
$existingRecords = []; $existingRecords = [];
/** @var BaseActiveRecord $relationModel */ /** @var BaseActiveRecord $relationModel */
foreach ($model->{$relationName} as $relationModel) { foreach ($model->{$relationName} as $i => $relationModel) {
if ($relationModel->isNewRecord) { if ($relationModel->isNewRecord) {
if ($relation->via !== null) { if ($relation->via !== null) {
if (!$relationModel->save()) { if ($relationModel->validate()) {
throw new Exception('Related model ' . $relationName . ' could not be saved (' . VarDumper::dumpAsString($relationModel->getErrors()) . ')'); $relationModel->save();
} else {
$pettyRelationName = Inflector::camel2words($relationName, true) . " #{$i}";
$this->_addError($relationModel, $model, $relationName, $pettyRelationName);
throw new DbException("Related record {$pettyRelationName} could not be saved.");
} }
} }
$model->link($relationName, $relationModel); $model->link($relationName, $relationModel);
@ -373,8 +371,12 @@ class SaveRelationsBehavior extends Behavior
$existingRecords[] = $relationModel; $existingRecords[] = $relationModel;
} }
if (count($relationModel->dirtyAttributes)) { if (count($relationModel->dirtyAttributes)) {
if (!$relationModel->save()) { if ($relationModel->validate()) {
throw new Exception('Related model ' . $relationName . ' could not be saved (' . VarDumper::dumpAsString($relationModel->getErrors()) . ')'); $relationModel->save();
} else {
$pettyRelationName = Inflector::camel2words($relationName, true);
$this->_addError($relationModel, $model, $relationName, $pettyRelationName);
throw new DbException("Related record {$pettyRelationName} could not be saved.");
} }
} }
} }
@ -409,11 +411,11 @@ class SaveRelationsBehavior extends Behavior
} }
} }
} catch (Exception $e) { } catch (Exception $e) {
Yii::warning(get_class($e) . " was thrown during the saving of related records : " . $e->getMessage(), __METHOD__); $this->_rollback();
if (($this->_transaction instanceof Transaction) && $this->_transaction->isActive) { /***
$this->_transaction->rollBack(); // If anything goes wrong, transaction will be rolled back * Sadly mandatory because the error occurred during afterSave event
Yii::info("Rolling back", __METHOD__); * and we don't want the user/developper not to be aware of the issue.
} ***/
throw $e; throw $e;
} }
$model->refresh(); $model->refresh();
@ -425,6 +427,26 @@ class SaveRelationsBehavior extends Behavior
} }
/** /**
* Populates relations with input data
* @param array $data
*/
public function loadRelations($data)
{
/** @var BaseActiveRecord $model */
$model = $this->owner;
foreach ($this->_relations as $relationName) {
$relation = $model->getRelation($relationName);
$modelClass = $relation->modelClass;
/** @var BaseActiveRecord $relationalModel */
$relationalModel = new $modelClass;
$formName = $relationalModel->formName();
if (array_key_exists($formName, $data)) {
$model->{$relationName} = $data[$formName];
}
}
}
/**
* Compute the difference between two set of records using primary keys "tokens" * Compute the difference between two set of records using primary keys "tokens"
* @param BaseActiveRecord[] $initialRelations * @param BaseActiveRecord[] $initialRelations
* @param BaseActiveRecord[] $updatedRelations * @param BaseActiveRecord[] $updatedRelations
@ -446,22 +468,27 @@ class SaveRelationsBehavior extends Behavior
} }
/** /**
* Populates relations with input data * Attach errors to owner relational attributes
* @param array $data * @param $relationModel
* @param $owner
* @param $relationName
* @param $pettyRelationName
* @return array
*/ */
public function loadRelations($data) private function _addError($relationModel, $owner, $relationName, $pettyRelationName)
{ {
/** @var BaseActiveRecord $model */ foreach ($relationModel->errors as $attributeErrors) {
$model = $this->owner; foreach ($attributeErrors as $error) {
foreach ($this->_relations as $relationName) { $owner->addError($relationName, "{$pettyRelationName}: {$error}");
$relation = $model->getRelation($relationName); }
$modelClass = $relation->modelClass;
/** @var BaseActiveRecord $relationalModel */
$relationalModel = new $modelClass;
$formName = $relationalModel->formName();
if (array_key_exists($formName, $data)) {
$model->{$relationName} = $data[$formName];
} }
} }
private function _rollback()
{
if (($this->_transaction instanceof Transaction) && $this->_transaction->isActive) {
$this->_transaction->rollBack(); // If anything goes wrong, transaction will be rolled back
Yii::info("Rolling back", __METHOD__);
}
} }
} }

25
tests/SaveRelationsBehaviorTest.php

@ -462,7 +462,7 @@ class SaveRelationsBehaviorTest extends \PHPUnit_Framework_TestCase
} }
/** /**
* @expectedException \Exception * @expectedException yii\db\Exception
*/ */
public function testSavingRelationWithSameUniqueKeyShouldFail() public function testSavingRelationWithSameUniqueKeyShouldFail()
{ {
@ -486,7 +486,25 @@ class SaveRelationsBehaviorTest extends \PHPUnit_Framework_TestCase
] ]
]; ];
$project->loadRelations($data); $project->loadRelations($data);
$this->assertFalse($project->save(), 'Project should not be saved'); /***
* This test throw an yii\base\Exception due to key conflict for related records.
* That kind of issue is hard to address because no validation process could prevent that.
* The exception is raised during the afterSave event of the owner model.
* In that case, the behavior takes care to rollback any database modifications
* and add an error to the related relational record.
* Anyway, the exception should be catched to address the correct workflow.
***/
try {
$project->save();
} catch (\Exception $e) {
$this->assertArrayHasKey(
'links',
$project->getErrors(),
'Links #1: The combination \"en\"-\"newsoft\" of Language and Name has already been taken.'
);
throw $e;
}
} }
public function testUpdatingAnExistingRelationShouldSucceed() public function testUpdatingAnExistingRelationShouldSucceed()
@ -549,9 +567,6 @@ class SaveRelationsBehaviorTest extends \PHPUnit_Framework_TestCase
$project->loadRelations($data); $project->loadRelations($data);
$this->assertFalse($project->save(), 'Project could be saved'); $this->assertFalse($project->save(), 'Project could be saved');
$data = [ $data = [
'Company' => [
'name' => 'YiiSoft'
],
'Link' => [ 'Link' => [
[ [
'language' => 'en', 'language' => 'en',

4
tests/models/Project.php

@ -69,7 +69,9 @@ class Project extends \yii\db\ActiveRecord
*/ */
public function getUsers() public function getUsers()
{ {
return $this->hasMany(User::className(), ['id' => 'user_id'])->via('projectUsers'); return $this->hasMany(User::className(), ['id' => 'user_id'])->via('projectUsers', function ($query) {
return $query;
});
} }
/** /**

Loading…
Cancel
Save