Browse Source

- Bug fix for has One relation

- Bug fix for SaveRelationTrait
tags/1.5.0^2
Alban Jubert 6 years ago
parent
commit
c76e4cf367
  1. 9
      README.md
  2. 500
      src/SaveRelationsBehavior.php
  3. 4
      src/SaveRelationsTrait.php
  4. 18
      tests/SaveRelationsBehaviorTest.php
  5. 24
      tests/models/UserProfile.php

9
README.md

@ -59,6 +59,7 @@ class Project extends \yii\db\ActiveRecord
'relations' => [ 'relations' => [
'company', 'company',
'users', 'users',
'projectLinks' => ['cascadeDelete' => true],
'tags' => [ 'tags' => [
'extraColumns' => function ($model) { 'extraColumns' => function ($model) {
/** @var $model Tag */ /** @var $model Tag */
@ -105,6 +106,14 @@ class Project extends \yii\db\ActiveRecord
{ {
return $this->hasMany(User::className(), ['id' => 'user_id'])->via('ProjectUsers'); return $this->hasMany(User::className(), ['id' => 'user_id'])->via('ProjectUsers');
} }
/**
* @return ActiveQuery
*/
public function getProjectLinks()
{
return $this->hasMany(ProjectLink::className(), ['project_id' => 'id']);
}
/** /**
* @return ActiveQuery * @return ActiveQuery

500
src/SaveRelationsBehavior.php

@ -39,18 +39,6 @@ class SaveRelationsBehavior extends Behavior
private $_relationsCascadeDelete = []; private $_relationsCascadeDelete = [];
/** /**
* @param $relationName
* @param int|null $i
* @return string
*/
protected static function prettyRelationName($relationName, $i = null)
{
return Inflector::camel2words($relationName, true) . (is_null($i) ? '' : " #{$i}");
}
//private $_relationsCascadeDelete = []; //TODO
/**
* @inheritdoc * @inheritdoc
*/ */
public function init() public function init()
@ -75,6 +63,8 @@ class SaveRelationsBehavior extends Behavior
} }
} }
//private $_relationsCascadeDelete = []; //TODO
/** /**
* @inheritdoc * @inheritdoc
*/ */
@ -155,25 +145,6 @@ class SaveRelationsBehavior extends Behavior
} }
/** /**
* Set the named single relation with the given value
* @param $name
* @param $value
* @throws \yii\base\InvalidArgumentException
*/
protected function setSingleRelation($name, $value)
{
/** @var BaseActiveRecord $owner */
$owner = $this->owner;
/** @var ActiveQuery $relation */
$relation = $owner->getRelation($name);
if (!($value instanceof $relation->modelClass)) {
$value = $this->processModelAsArray($value, $relation);
}
$this->_newRelationValue[$name] = $value;
$owner->populateRelation($name, $value);
}
/**
* Set the named multiple relation with the given value * Set the named multiple relation with the given value
* @param $name * @param $name
* @param $value * @param $value
@ -221,6 +192,91 @@ class SaveRelationsBehavior extends Behavior
} }
/** /**
* Get the related model foreign keys
* @param $data
* @param $relation
* @param BaseActiveRecord $modelClass
* @return array
*/
private function _getRelatedFks($data, $relation, $modelClass)
{
$fks = [];
if (is_array($data)) {
// search PK
foreach ($modelClass::primaryKey() as $modelAttribute) {
if (array_key_exists($modelAttribute, $data) && !empty($data[$modelAttribute])) {
$fks[$modelAttribute] = $data[$modelAttribute];
} else {
$fks = [];
break;
}
}
if (empty($fks)) {
// Get the right link definition
if ($relation->via instanceof BaseActiveRecord) {
$link = $relation->via->link;
} elseif (is_array($relation->via)) {
list($viaName, $viaQuery) = $relation->via;
$link = $viaQuery->link;
} else {
$link = $relation->link;
}
foreach ($link as $relatedAttribute => $modelAttribute) {
if (array_key_exists($modelAttribute, $data) && !empty($data[$modelAttribute])) {
$fks[$modelAttribute] = $data[$modelAttribute];
}
}
}
} else {
$fks = $data;
}
return $fks;
}
/**
* Load existing model or create one if no key was provided and data is not empty
* @param $data
* @param $fks
* @param $modelClass
* @return BaseActiveRecord
*/
private function _loadOrCreateRelationModel($data, $fks, $modelClass)
{
/** @var BaseActiveRecord $relationModel */
$relationModel = null;
if (!empty($fks)) {
$relationModel = $modelClass::findOne($fks);
}
if (!($relationModel instanceof BaseActiveRecord) && !empty($data)) {
$relationModel = new $modelClass;
}
if (($relationModel instanceof BaseActiveRecord) && is_array($data)) {
$relationModel->setAttributes($data);
}
return $relationModel;
}
/**
* Set the named single relation with the given value
* @param $name
* @param $value
* @throws \yii\base\InvalidArgumentException
*/
protected function setSingleRelation($name, $value)
{
/** @var BaseActiveRecord $owner */
$owner = $this->owner;
/** @var ActiveQuery $relation */
$relation = $owner->getRelation($name);
if (!($value instanceof $relation->modelClass)) {
$value = $this->processModelAsArray($value, $relation);
}
$this->_newRelationValue[$name] = $value;
$owner->populateRelation($name, $value);
}
/**
* Before the owner model validation, save related models. * Before the owner model validation, save related models.
* For `hasOne()` relations, set the according foreign keys of the owner model to be able to validate it * For `hasOne()` relations, set the according foreign keys of the owner model to be able to validate it
* @param ModelEvent $event * @param ModelEvent $event
@ -294,6 +350,50 @@ class SaveRelationsBehavior extends Behavior
} }
/** /**
* @param BaseActiveRecord $model
*/
protected function startTransactionForModel(BaseActiveRecord $model)
{
if ($this->isModelTransactional($model) && is_null($model->getDb()->transaction)) {
$this->_transaction = $model->getDb()->beginTransaction();
}
}
/**
* @param BaseActiveRecord $model
* @return bool
*/
protected function isModelTransactional(BaseActiveRecord $model)
{
if (method_exists($model, 'isTransactional')) {
return ($model->isNewRecord && $model->isTransactional($model::OP_INSERT))
|| (!$model->isNewRecord && $model->isTransactional($model::OP_UPDATE))
|| $model->isTransactional($model::OP_ALL);
}
return false;
}
/**
* @param BaseActiveRecord $model
* @param ModelEvent $event
* @param $relationName
*/
private function _prepareHasOneRelation(BaseActiveRecord $model, $relationName, ModelEvent $event)
{
/** @var ActiveQuery $relation */
$relation = $model->getRelation($relationName);
$relationModel = $model->{$relationName};
$this->validateRelationModel(self::prettyRelationName($relationName), $relationName, $model->{$relationName});
if ($relationModel->getIsNewRecord()) {
// Save Has one relation new record
if ($event->isValid && (count($model->dirtyAttributes) || $model->{$relationName}->isNewRecord)) {
Yii::debug('Saving ' . self::prettyRelationName($relationName) . ' relation model', __METHOD__);
$model->{$relationName}->save();
}
}
}
/**
* Validate a relation model and add an error message to owner model attribute if needed * Validate a relation model and add an error message to owner model attribute if needed
* @param string $prettyRelationName * @param string $prettyRelationName
* @param string $relationName * @param string $relationName
@ -332,6 +432,28 @@ class SaveRelationsBehavior extends Behavior
} }
/** /**
* @param $relationName
* @param int|null $i
* @return string
*/
protected static function prettyRelationName($relationName, $i = null)
{
return Inflector::camel2words($relationName, true) . (is_null($i) ? '' : " #{$i}");
}
/**
* @param BaseActiveRecord $model
* @param $relationName
*/
private function _prepareHasManyRelation(BaseActiveRecord $model, $relationName)
{
/** @var BaseActiveRecord $relationModel */
foreach ($model->{$relationName} as $i => $relationModel) {
$this->validateRelationModel(self::prettyRelationName($relationName, $i), $relationName, $relationModel);
}
}
/**
* Rollback transaction if any * Rollback transaction if any
* @throws DbException * @throws DbException
*/ */
@ -391,121 +513,6 @@ class SaveRelationsBehavior extends Behavior
} }
/** /**
* Get the list of owner model relations in order to be able to delete them after its deletion
*/
public function beforeDelete()
{
$model = $this->owner;
foreach ($this->_relationsCascadeDelete as $relationName => $params) {
if ($params === true) {
$relation = $model->getRelation($relationName);
if (!empty($model->{$relationName})) {
if ($relation->multiple === true) { // Has many relation
$this->_relationsToDelete = ArrayHelper::merge($this->_relationsToDelete, $model->{$relationName});
} else {
$this->_relationsToDelete[] = $model->{$relationName};
}
}
}
}
}
/**
* Delete related models marked as to be deleted
* @throws Exception
*/
public function afterDelete()
{
/** @var BaseActiveRecord $modelToDelete */
foreach ($this->_relationsToDelete as $modelToDelete) {
try {
if (!$modelToDelete->delete()) {
throw new DbException('Could not delete the related record: ' . $modelToDelete::className() . '(' . VarDumper::dumpAsString($modelToDelete->primaryKey) . ')');
}
} catch (Exception $e) {
Yii::warning(get_class($e) . ' was thrown while deleting related records during afterDelete event: ' . $e->getMessage(), __METHOD__);
$this->_rollback();
throw $e;
}
}
}
/**
* Return array of columns to save to the junction table for a related model having a many-to-many relation.
* @param string $relationName
* @param BaseActiveRecord $model
* @return array
* @throws \RuntimeException
*/
private function _getJunctionTableColumns($relationName, $model)
{
$junctionTableColumns = [];
if (array_key_exists($relationName, $this->_relationsExtraColumns)) {
if (is_callable($this->_relationsExtraColumns[$relationName])) {
$junctionTableColumns = $this->_relationsExtraColumns[$relationName]($model);
} elseif (is_array($this->_relationsExtraColumns[$relationName])) {
$junctionTableColumns = $this->_relationsExtraColumns[$relationName];
}
if (!is_array($junctionTableColumns)) {
throw new RuntimeException(
'Junction table columns definition must return an array, got ' . gettype($junctionTableColumns)
);
}
}
return $junctionTableColumns;
}
/**
* Compute the difference between two set of records using primary keys "tokens"
* If third parameter is set to true all initial related records will be marked for removal even if their
* properties did not change. This can be handy in a many-to-many relation involving a junction table.
* @param BaseActiveRecord[] $initialRelations
* @param BaseActiveRecord[] $updatedRelations
* @param bool $forceSave
* @return array
*/
private function _computePkDiff($initialRelations, $updatedRelations, $forceSave = false)
{
// Compute differences between initial relations and the current ones
$oldPks = ArrayHelper::getColumn($initialRelations, function (BaseActiveRecord $model) {
return implode('-', $model->getPrimaryKey(true));
});
$newPks = ArrayHelper::getColumn($updatedRelations, function (BaseActiveRecord $model) {
return implode('-', $model->getPrimaryKey(true));
});
if ($forceSave) {
$addedPks = $newPks;
$deletedPks = $oldPks;
} else {
$identicalPks = array_intersect($oldPks, $newPks);
$addedPks = array_values(array_diff($newPks, $identicalPks));
$deletedPks = array_values(array_diff($oldPks, $identicalPks));
}
return [$addedPks, $deletedPks];
}
/**
* Populates relations with input data
* @param array $data
*/
public function loadRelations($data)
{
/** @var BaseActiveRecord $model */
$model = $this->owner;
foreach ($this->_relations as $relationName) {
/** @var ActiveQuery $relation */
$relation = $model->getRelation($relationName);
$modelClass = $relation->modelClass;
/** @var ActiveQuery $relationalModel */
$relationalModel = new $modelClass;
$formName = $relationalModel->formName();
if (array_key_exists($formName, $data)) {
$model->{$relationName} = $data[$formName];
}
}
}
/**
* @param $relationName * @param $relationName
* @throws DbException * @throws DbException
*/ */
@ -575,6 +582,60 @@ class SaveRelationsBehavior extends Behavior
} }
/** /**
* Return array of columns to save to the junction table for a related model having a many-to-many relation.
* @param string $relationName
* @param BaseActiveRecord $model
* @return array
* @throws \RuntimeException
*/
private function _getJunctionTableColumns($relationName, $model)
{
$junctionTableColumns = [];
if (array_key_exists($relationName, $this->_relationsExtraColumns)) {
if (is_callable($this->_relationsExtraColumns[$relationName])) {
$junctionTableColumns = $this->_relationsExtraColumns[$relationName]($model);
} elseif (is_array($this->_relationsExtraColumns[$relationName])) {
$junctionTableColumns = $this->_relationsExtraColumns[$relationName];
}
if (!is_array($junctionTableColumns)) {
throw new RuntimeException(
'Junction table columns definition must return an array, got ' . gettype($junctionTableColumns)
);
}
}
return $junctionTableColumns;
}
/**
* Compute the difference between two set of records using primary keys "tokens"
* If third parameter is set to true all initial related records will be marked for removal even if their
* properties did not change. This can be handy in a many-to-many relation involving a junction table.
* @param BaseActiveRecord[] $initialRelations
* @param BaseActiveRecord[] $updatedRelations
* @param bool $forceSave
* @return array
*/
private function _computePkDiff($initialRelations, $updatedRelations, $forceSave = false)
{
// Compute differences between initial relations and the current ones
$oldPks = ArrayHelper::getColumn($initialRelations, function (BaseActiveRecord $model) {
return implode('-', $model->getPrimaryKey(true));
});
$newPks = ArrayHelper::getColumn($updatedRelations, function (BaseActiveRecord $model) {
return implode('-', $model->getPrimaryKey(true));
});
if ($forceSave) {
$addedPks = $newPks;
$deletedPks = $oldPks;
} else {
$identicalPks = array_intersect($oldPks, $newPks);
$addedPks = array_values(array_diff($newPks, $identicalPks));
$deletedPks = array_values(array_diff($oldPks, $identicalPks));
}
return [$addedPks, $deletedPks];
}
/**
* @param $relationName * @param $relationName
* @throws \yii\base\InvalidCallException * @throws \yii\base\InvalidCallException
*/ */
@ -598,128 +659,63 @@ class SaveRelationsBehavior extends Behavior
} }
/** /**
* @param BaseActiveRecord $model * Get the list of owner model relations in order to be able to delete them after its deletion
* @param $relationName
*/
private function _prepareHasManyRelation(BaseActiveRecord $model, $relationName)
{
/** @var BaseActiveRecord $relationModel */
foreach ($model->{$relationName} as $i => $relationModel) {
$this->validateRelationModel(self::prettyRelationName($relationName, $i), $relationName, $relationModel);
}
}
/**
* @param BaseActiveRecord $model
* @param ModelEvent $event
* @param $relationName
*/ */
private function _prepareHasOneRelation(BaseActiveRecord $model, $relationName, ModelEvent $event) public function beforeDelete()
{ {
/** @var ActiveQuery $relation */ $model = $this->owner;
$relation = $model->getRelation($relationName); foreach ($this->_relationsCascadeDelete as $relationName => $params) {
$relationModel = $model->{$relationName}; if ($params === true) {
$p1 = $model->isPrimaryKey(array_keys($relation->link)); $relation = $model->getRelation($relationName);
$p2 = $relationModel::isPrimaryKey(array_values($relation->link)); if (!empty($model->{$relationName})) {
if ($relationModel->getIsNewRecord() && $p1 && !$p2) { if ($relation->multiple === true) { // Has many relation
// Save Has one relation new record $this->_relationsToDelete = ArrayHelper::merge($this->_relationsToDelete, $model->{$relationName});
$this->validateRelationModel(self::prettyRelationName($relationName), $relationName, $model->{$relationName}); } else {
if ($event->isValid && (count($model->dirtyAttributes) || $model->{$relationName}->isNewRecord)) { $this->_relationsToDelete[] = $model->{$relationName};
Yii::debug('Saving ' . self::prettyRelationName($relationName) . ' relation model', __METHOD__); }
$model->{$relationName}->save(false); }
} }
} else {
$this->validateRelationModel(self::prettyRelationName($relationName), $relationName, $relationModel);
} }
} }
/** /**
* @param BaseActiveRecord $model * Delete related models marked as to be deleted
*/ * @throws Exception
protected function startTransactionForModel(BaseActiveRecord $model)
{
if ($this->isModelTransactional($model) && is_null($model->getDb()->transaction)) {
$this->_transaction = $model->getDb()->beginTransaction();
}
}
/**
* @param BaseActiveRecord $model
* @return bool
*/
protected function isModelTransactional(BaseActiveRecord $model)
{
if (method_exists($model, 'isTransactional')) {
return ($model->isNewRecord && $model->isTransactional($model::OP_INSERT))
|| (!$model->isNewRecord && $model->isTransactional($model::OP_UPDATE))
|| $model->isTransactional($model::OP_ALL);
}
return false;
}
/**
* Load existing model or create one if no key was provided and data is not empty
* @param $data
* @param $fks
* @param $modelClass
* @return BaseActiveRecord
*/ */
private function _loadOrCreateRelationModel($data, $fks, $modelClass) public function afterDelete()
{ {
/** @var BaseActiveRecord $relationModel */ /** @var BaseActiveRecord $modelToDelete */
$relationModel = null; foreach ($this->_relationsToDelete as $modelToDelete) {
if (!empty($fks)) { try {
$relationModel = $modelClass::findOne($fks); if (!$modelToDelete->delete()) {
} throw new DbException('Could not delete the related record: ' . $modelToDelete::className() . '(' . VarDumper::dumpAsString($modelToDelete->primaryKey) . ')');
if (!($relationModel instanceof BaseActiveRecord) && !empty($data)) { }
$relationModel = new $modelClass; } catch (Exception $e) {
} Yii::warning(get_class($e) . ' was thrown while deleting related records during afterDelete event: ' . $e->getMessage(), __METHOD__);
if (($relationModel instanceof BaseActiveRecord) && is_array($data)) { $this->_rollback();
$relationModel->setAttributes($data); throw $e;
}
} }
return $relationModel;
} }
/** /**
* Get the related model foreign keys * Populates relations with input data
* @param $data * @param array $data
* @param $relation
* @param BaseActiveRecord $modelClass
* @return array
*/ */
private function _getRelatedFks($data, $relation, $modelClass) public function loadRelations($data)
{ {
$fks = []; /** @var BaseActiveRecord $model */
if (is_array($data)) { $model = $this->owner;
// search PK foreach ($this->_relations as $relationName) {
foreach ($modelClass::primaryKey() as $modelAttribute) { /** @var ActiveQuery $relation */
if (array_key_exists($modelAttribute, $data) && !empty($data[$modelAttribute])) { $relation = $model->getRelation($relationName);
$fks[$modelAttribute] = $data[$modelAttribute]; $modelClass = $relation->modelClass;
} else { /** @var ActiveQuery $relationalModel */
$fks = []; $relationalModel = new $modelClass;
break; $formName = $relationalModel->formName();
} if (array_key_exists($formName, $data)) {
} $model->{$relationName} = $data[$formName];
if (empty($fks)) {
// Get the right link definition
if ($relation->via instanceof BaseActiveRecord) {
$link = $relation->via->link;
} elseif (is_array($relation->via)) {
list($viaName, $viaQuery) = $relation->via;
$link = $viaQuery->link;
} else {
$link = $relation->link;
}
foreach ($link as $relatedAttribute => $modelAttribute) {
if (array_key_exists($modelAttribute, $data) && !empty($data[$modelAttribute])) {
$fks[$modelAttribute] = $data[$modelAttribute];
}
}
} }
} else {
$fks = $data;
} }
return $fks;
} }
} }

4
src/SaveRelationsTrait.php

@ -8,8 +8,8 @@ trait SaveRelationsTrait
public function load($data, $formName = null) public function load($data, $formName = null)
{ {
$loaded = parent::load($data, $formName); $loaded = parent::load($data, $formName);
if ($loaded && method_exists($this, 'loadRelations')) { if ($loaded && $this->hasMethod('loadRelations')) {
$this->loadRelations($data, $formName = null); $this->loadRelations($data);
} }
return $loaded; return $loaded;
} }

18
tests/SaveRelationsBehaviorTest.php

@ -665,6 +665,24 @@ class SaveRelationsBehaviorTest extends \PHPUnit_Framework_TestCase
$this->assertEquals($user->id, $user->userProfile->user_id); $this->assertEquals($user->id, $user->userProfile->user_id);
} }
public function testSaveHasOneReplaceRelatedWithNewRecord()
{
$profile = UserProfile::findOne(1);
$this->assertEquals('Steven Paul Jobs (February 24, 1955 – October 5, 2011) was an American entrepreneur, business magnate, inventor, and industrial designer. He was the chairman, chief executive officer (CEO), and co-founder of Apple Inc.; CEO and majority shareholder of Pixar; a member of The Walt Disney Company\'s board of directors following its acquisition of Pixar; and the founder, chairman, and CEO of NeXT.', $profile->bio, "Profile bio is wrong");
$data = [
'User' => [
'username' => 'Someone Else',
'company_id' => 1
]
];
$profile->loadRelations($data);
$this->assertEquals('Someone Else', $profile->user->username, "User name should be 'Someone Else'");
$this->assertTrue($profile->user->isNewRecord, "User should be a new record");
$profile->save();
$this->assertTrue($profile->save(), 'Profile could not be saved');
$this->assertEquals('Someone Else', $profile->user->username, "User name should be 'Someone Else'");
}
public function testSaveNestedModels() public function testSaveNestedModels()
{ {
$project = new Project(); $project = new Project();

24
tests/models/UserProfile.php

@ -2,8 +2,11 @@
namespace tests\models; namespace tests\models;
use lhs\Yii2SaveRelationsBehavior\SaveRelationsBehavior;
class UserProfile extends \yii\db\ActiveRecord class UserProfile extends \yii\db\ActiveRecord
{ {
/** /**
* @inheritdoc * @inheritdoc
*/ */
@ -15,6 +18,19 @@ class UserProfile extends \yii\db\ActiveRecord
/** /**
* @inheritdoc * @inheritdoc
*/ */
public function behaviors()
{
return [
'saveRelations' => [
'class' => SaveRelationsBehavior::className(),
'relations' => ['user'],
],
];
}
/**
* @inheritdoc
*/
public function rules() public function rules()
{ {
return [ return [
@ -25,4 +41,12 @@ class UserProfile extends \yii\db\ActiveRecord
]; ];
} }
/**
* @return ActiveQuery
*/
public function getUser()
{
return $this->hasOne(User::className(), ['id' => 'user_id']);
}
} }

Loading…
Cancel
Save