Browse Source

Merge branch 'delete-related-records-feature'

tags/1.5.0 1.5.0
Alban Jubert 6 years ago
parent
commit
b2dab0d252
  1. 46
      README.md
  2. 467
      src/SaveRelationsBehavior.php
  3. 68
      tests/SaveRelationsBehaviorTest.php
  4. 20
      tests/bootstrap.php
  5. 5
      tests/models/Project.php
  6. 11
      tests/models/ProjectLink.php
  7. 2
      tests/models/User.php
  8. 24
      tests/models/UserProfile.php

46
README.md

@ -16,6 +16,8 @@ Features
- Works with existing as well as new related models - Works with existing as well as new related models
- Composite primary keys are supported - Composite primary keys are supported
- Only pure Active Record API is used so it should work with any DB driver - Only pure Active Record API is used so it should work with any DB driver
- As of 1.5.0 release, related records can now be deleted along with the main model
Installation Installation
------------ ------------
@ -41,6 +43,7 @@ Configuring
----------- -----------
Configure model as follows Configure model as follows
```php ```php
use lhs\Yii2SaveRelationsBehavior\SaveRelationsBehavior; use lhs\Yii2SaveRelationsBehavior\SaveRelationsBehavior;
@ -59,6 +62,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 +109,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
@ -115,6 +127,7 @@ class Project extends \yii\db\ActiveRecord
} }
} }
``` ```
> Though not mandatory, it is highly recommended to activate the transactions for the owner model. > Though not mandatory, it is highly recommended to activate the transactions for the owner model.
@ -122,6 +135,7 @@ Usage
----- -----
Every declared relations in the `relations` behavior parameter can now be set and saved as follow: Every declared relations in the `relations` behavior parameter can now be set and saved as follow:
```php ```php
$project = new Project(); $project = new Project();
$project->name = "New project"; $project->name = "New project";
@ -129,7 +143,9 @@ $project->company = Company::findOne(2);
$project->users = User::findAll([1,3]); $project->users = User::findAll([1,3]);
$project->save(); $project->save();
``` ```
You can set related model by only specifying its primary key: You can set related model by only specifying its primary key:
```php ```php
$project = new Project(); $project = new Project();
$project->name = "Another project"; $project->name = "Another project";
@ -137,6 +153,7 @@ $project->company = 2;
$project->users = [1,3]; $project->users = [1,3];
$project->save(); $project->save();
``` ```
You can even set related models as associative arrays like this: You can even set related models as associative arrays like this:
```php ```php
@ -145,6 +162,7 @@ $project->company = ['name' => 'GiHub', 'description' => 'Awesome']; // Will cre
// $project->company = ['id' => 3, 'name' => 'GiHub', 'description' => 'Awesome']; // Will update an existing company record // $project->company = ['id' => 3, 'name' => 'GiHub', 'description' => 'Awesome']; // Will update an existing company record
$project->save(); $project->save();
``` ```
Attributes of the related model will be massively assigned using the `load() method. So remember to declare the according attributes as safe in the rules of the related model. Attributes of the related model will be massively assigned using the `load() method. So remember to declare the according attributes as safe in the rules of the related model.
> **Notes:** > **Notes:**
@ -153,6 +171,7 @@ Attributes of the related model will be massively assigned using the `load() met
> See the PHPUnit tests for more examples. > See the PHPUnit tests for more examples.
Populate additional junction table columns in a many-to-many relation Populate additional junction table columns in a many-to-many relation
--------------------------------------------------------------------- ---------------------------------------------------------------------
In a many-to-many relation involving a junction table additional column values can be saved to the junction table for each model. In a many-to-many relation involving a junction table additional column values can be saved to the junction table for each model.
@ -162,6 +181,7 @@ See the configuration section for examples.
> If junction table properties are configured for a relation the rows associated with the related models in the junction table will be deleted and inserted again on each saving > If junction table properties are configured for a relation the rows associated with the related models in the junction table will be deleted and inserted again on each saving
> to ensure that changes to the junction table properties are saved too. > to ensure that changes to the junction table properties are saved too.
Validation Validation
---------- ----------
Every declared related models will be validated prior to be saved. If any validation fails, for each related model attribute in error, an error associated with the named relation will be added to the owner model. Every declared related models will be validated prior to be saved. If any validation fails, for each related model attribute in error, an error associated with the named relation will be added to the owner model.
@ -170,6 +190,7 @@ For `hasMany()` relations, the index of the related model will be used to identi
It is possible to specify the validation scenario for each relation by declaring an associative array in which the `scenario` key must contain the needed scenario value. It is possible to specify the validation scenario for each relation by declaring an associative array in which the `scenario` key must contain the needed scenario value.
For instance, in the following configuration, the `links ` related records will be validated using the `Link::SOME_SCENARIO` scenario: For instance, in the following configuration, the `links ` related records will be validated using the `Link::SOME_SCENARIO` scenario:
```php ```php
... ...
public function behaviors() public function behaviors()
@ -191,12 +212,37 @@ For instance, in the following configuration, the `links ` related records will
> An error message will be attached to the relation attribute of the owner model. > 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. > In order to be able to handle these cases in a user-friendly way, one will have to catch `yii\db\Exception` exceptions.
Delete related records when the main model is deleted
-----------------------------------------------------
For DBMs with no built in relational constraints, as of 1.5.0 release, one can now specify a relation to be deleted along with the main model.
To do so, the relation should be declared with a property `cascadeDelete` set to true.
For example, related `projectLinks` records will automaticaly be deleted when the main model will be deleted:
```php
...
'saveRelations' => [
'class' => SaveRelationsBehavior::className(),
'relations' => [
'projectLinks' => ['cascadeDelete' => true]
],
],
...
```
> **Note:**
> Every records related to the main model as they are defined in their `ActiveQuery` statement will be deleted.
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.
Simply call the `loadRelations()` with the according input data. Simply call the `loadRelations()` with the according input data.
For instance: For instance:
```php ```php
$project = Project::findOne(1); $project = Project::findOne(1);
/** /**

467
src/SaveRelationsBehavior.php

@ -15,6 +15,7 @@ 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.
@ -28,24 +29,14 @@ class SaveRelationsBehavior extends Behavior
private $_relations = []; private $_relations = [];
private $_oldRelationValue = []; // Store initial relations value private $_oldRelationValue = []; // Store initial relations value
private $_newRelationValue = []; // Store update relations value private $_newRelationValue = []; // Store update relations value
private $_relationsToDelete = [];
private $_relationsSaveStarted = false; private $_relationsSaveStarted = false;
private $_transaction; private $_transaction;
private $_relationsScenario = []; private $_relationsScenario = [];
private $_relationsExtraColumns = []; private $_relationsExtraColumns = [];
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
@ -53,7 +44,7 @@ class SaveRelationsBehavior extends Behavior
public function init() public function init()
{ {
parent::init(); parent::init();
$allowedProperties = ['scenario', 'extraColumns']; $allowedProperties = ['scenario', 'extraColumns', 'cascadeDelete'];
foreach ($this->relations as $key => $value) { foreach ($this->relations as $key => $value) {
if (is_int($key)) { if (is_int($key)) {
$this->_relations[] = $value; $this->_relations[] = $value;
@ -72,6 +63,8 @@ class SaveRelationsBehavior extends Behavior
} }
} }
//private $_relationsCascadeDelete = []; //TODO
/** /**
* @inheritdoc * @inheritdoc
*/ */
@ -81,6 +74,8 @@ class SaveRelationsBehavior extends Behavior
BaseActiveRecord::EVENT_BEFORE_VALIDATE => 'beforeValidate', BaseActiveRecord::EVENT_BEFORE_VALIDATE => 'beforeValidate',
BaseActiveRecord::EVENT_AFTER_INSERT => 'afterSave', BaseActiveRecord::EVENT_AFTER_INSERT => 'afterSave',
BaseActiveRecord::EVENT_AFTER_UPDATE => 'afterSave', BaseActiveRecord::EVENT_AFTER_UPDATE => 'afterSave',
BaseActiveRecord::EVENT_BEFORE_DELETE => 'beforeDelete',
BaseActiveRecord::EVENT_AFTER_DELETE => 'afterDelete'
]; ];
} }
@ -150,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
@ -216,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
@ -289,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
@ -327,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
*/ */
@ -386,81 +513,6 @@ 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];
}
/**
* 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
*/ */
@ -530,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
*/ */
@ -553,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) public function beforeDelete()
{ {
/** @var BaseActiveRecord $relationModel */ $model = $this->owner;
foreach ($model->{$relationName} as $i => $relationModel) { foreach ($this->_relationsCascadeDelete as $relationName => $params) {
$this->validateRelationModel(self::prettyRelationName($relationName, $i), $relationName, $relationModel); 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});
* @param BaseActiveRecord $model } else {
* @param ModelEvent $event $this->_relationsToDelete[] = $model->{$relationName};
* @param $relationName }
*/ }
private function _prepareHasOneRelation(BaseActiveRecord $model, $relationName, ModelEvent $event)
{
/** @var ActiveQuery $relation */
$relation = $model->getRelation($relationName);
$relationModel = $model->{$relationName};
$p1 = $model->isPrimaryKey(array_keys($relation->link));
$p2 = $relationModel::isPrimaryKey(array_values($relation->link));
if ($relationModel->getIsNewRecord() && $p1 && !$p2) {
// Save Has one relation new record
$this->validateRelationModel(self::prettyRelationName($relationName), $relationName, $model->{$relationName});
if ($event->isValid && (count($model->dirtyAttributes) || $model->{$relationName}->isNewRecord)) {
Yii::debug('Saving ' . self::prettyRelationName($relationName) . ' relation model', __METHOD__);
$model->{$relationName}->save(false);
} }
} else {
$this->validateRelationModel(self::prettyRelationName($relationName), $relationName, $relationModel);
}
}
/**
* @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;
} }
/** /**
* Load existing model or create one if no key was provided and data is not empty * Delete related models marked as to be deleted
* @param $data * @throws Exception
* @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;
} }
} }

68
tests/SaveRelationsBehaviorTest.php

@ -8,6 +8,7 @@ use tests\models\DummyModel;
use tests\models\DummyModelParent; use tests\models\DummyModelParent;
use tests\models\Link; use tests\models\Link;
use tests\models\Project; use tests\models\Project;
use tests\models\ProjectLink;
use tests\models\ProjectNoTransactions; use tests\models\ProjectNoTransactions;
use tests\models\Tag; use tests\models\Tag;
use tests\models\User; use tests\models\User;
@ -142,6 +143,13 @@ class SaveRelationsBehaviorTest extends \PHPUnit_Framework_TestCase
[4, 'Jonathan Ive', 1] [4, 'Jonathan Ive', 1]
])->execute(); ])->execute();
$db->createCommand()->batchInsert('user_profile', ['user_id', 'bio'], [
[1, '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.'],
[2, 'William Henry Gates III (born October 28, 1955) is an American business magnate, investor, author, philanthropist, and co-founder of the Microsoft Corporation along with Paul Allen.'],
[3, 'Timothy Donald Cook (born November 1, 1960) is an American business executive, industrial engineer, and developer. Cook is the Chief Executive Officer of Apple Inc., previously serving as the company\'s Chief Operating Officer, under its founder Steve Jobs.'],
[4, 'Sir Jonathan Paul "Jony" Ive, KBE (born 27 February 1967), is an English industrial designer who is currently the chief design officer (CDO) of Apple and chancellor of the Royal College of Art in London.']
])->execute();
$db->createCommand()->batchInsert('project', ['id', 'name', 'company_id'], [ $db->createCommand()->batchInsert('project', ['id', 'name', 'company_id'], [
[1, 'Mac OS X', 1], [1, 'Mac OS X', 1],
[2, 'Windows 10', 2] [2, 'Windows 10', 2]
@ -657,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();
@ -725,4 +751,46 @@ class SaveRelationsBehaviorTest extends \PHPUnit_Framework_TestCase
$user->userProfile->bio = 'Lawrence Edward Page (born March 26, 1973) is an American computer scientist and Internet entrepreneur who co-founded Google with Sergey Brin.'; $user->userProfile->bio = 'Lawrence Edward Page (born March 26, 1973) is an American computer scientist and Internet entrepreneur who co-founded Google with Sergey Brin.';
$this->assertTrue($user->save(), 'User could not be saved'); $this->assertTrue($user->save(), 'User could not be saved');
} }
public function testDeleteRelatedHasOneShouldSucceed()
{
User::findOne(1)->delete();
$this->assertNull(UserProfile::findOne(1), 'Related user profile was not deleted');
$this->assertNotNull(UserProfile::findOne(2), 'Unrelated user profile was deleted');
}
public function testDeleteRelatedHasManyShouldSucceed()
{
Project::findOne(1)->delete();
$this->assertCount(0, ProjectLink::find()->where(['project_id' => 1])->all(), 'Related project links were not deleted');
}
public function testDeleteRelatedWithErrorShouldThrowAnException()
{
$this->setExpectedException('\yii\db\Exception');
$project = Project::findOne(1);
foreach ($project->projectLinks as $projectLink) {
$projectLink->blockDelete = true;
}
$this->assertFalse($project->delete(), 'Project could be deleted');
}
public function testSaveProjectWithCompanyWithUserShouldSucceed()
{
// Test for cascading save relations
$project = new Project();
$project->name = "Cartoon";
$company = new Company();
$company->name = 'ACME';
$user = new User();
$user->username = "Bugs Bunny";
$company->users = $user;
$project->company = $company;
$this->assertTrue($project->save(), 'Project could not be saved');
$this->assertEquals('ACME', $project->company->name, 'Project company\'s name is wrong');
$this->assertCount(1, $project->company->users, 'Count of related users is wrong');
$this->assertEquals('Bugs Bunny', $project->company->users[0]->username, 'Company user\'s name is wrong');
$this->assertFalse($project->company->isNewRecord, 'Company record should be saved');
$this->assertFalse($project->company->users[0]->isNewRecord, 'Company Users records should be saved');
}
} }

20
tests/bootstrap.php

@ -15,19 +15,19 @@ new \yii\console\Application([
'id' => 'unit', 'id' => 'unit',
'basePath' => __DIR__, 'basePath' => __DIR__,
'vendorPath' => dirname(__DIR__) . '/vendor', 'vendorPath' => dirname(__DIR__) . '/vendor',
// 'bootstrap' => ['log'], 'bootstrap' => ['log'],
'components' => [ 'components' => [
'db' => [ 'db' => [
'class' => 'yii\db\Connection', 'class' => 'yii\db\Connection',
'dsn' => 'sqlite::memory:', 'dsn' => 'sqlite::memory:',
], ],
// 'log' => [ 'log' => [
// 'targets' => [ 'targets' => [
// [ [
// 'class' => 'yii\log\FileTarget', 'class' => 'yii\log\FileTarget',
// 'categories' => ['yii\db\*', 'lhs\Yii2SaveRelationsBehavior\*'] 'categories' => ['yii\db\*', 'lhs\Yii2SaveRelationsBehavior\*']
// ], ],
// ] ]
// ], ],
] ]
]); ]);

5
tests/models/Project.php

@ -25,8 +25,9 @@ class Project extends \yii\db\ActiveRecord
'relations' => [ 'relations' => [
'company', 'company',
'users', 'users',
'links' => ['scenario' => Link::SCENARIO_FIRST], 'links' => ['scenario' => Link::SCENARIO_FIRST],
'tags' => [ 'projectLinks' => ['cascadeDelete' => true],
'tags' => [
'extraColumns' => function ($model) { 'extraColumns' => function ($model) {
/** @var $model Tag */ /** @var $model Tag */
return [ return [

11
tests/models/ProjectLink.php

@ -5,6 +5,8 @@ namespace tests\models;
class ProjectLink extends \yii\db\ActiveRecord class ProjectLink extends \yii\db\ActiveRecord
{ {
public $blockDelete = false;
/** /**
* @inheritdoc * @inheritdoc
*/ */
@ -24,4 +26,13 @@ class ProjectLink extends \yii\db\ActiveRecord
]; ];
} }
public function beforeDelete()
{
if ($this->blockDelete === true) {
return false;
} else {
return parent::beforeDelete();
}
}
} }

2
tests/models/User.php

@ -22,7 +22,7 @@ class User extends \yii\db\ActiveRecord
return [ return [
'saveRelations' => [ 'saveRelations' => [
'class' => SaveRelationsBehavior::className(), 'class' => SaveRelationsBehavior::className(),
'relations' => ['userProfile', 'company'] 'relations' => ['userProfile' => ['cascadeDelete' => true], 'company']
], ],
]; ];
} }

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