From 94a441c974d38bd9d7cbfde344aff0a5b747d3de Mon Sep 17 00:00:00 2001 From: Alban Jubert Date: Sat, 28 Oct 2017 14:56:48 +0200 Subject: [PATCH] Implementation of enhancement #15: Allow saving extra columns to junction table --- src/SaveRelationsBehavior.php | 69 +++++++++++++++++++++++++++++-------- tests/SaveRelationsBehaviorTest.php | 38 ++++++++++++++++++-- tests/models/Project.php | 22 +++++++++++- tests/models/Tag.php | 44 +++++++++++++++++++++++ 4 files changed, 156 insertions(+), 17 deletions(-) create mode 100644 tests/models/Tag.php diff --git a/src/SaveRelationsBehavior.php b/src/SaveRelationsBehavior.php index d04cf1f..4879ed4 100644 --- a/src/SaveRelationsBehavior.php +++ b/src/SaveRelationsBehavior.php @@ -29,13 +29,14 @@ class SaveRelationsBehavior extends Behavior private $_transaction; private $_relationsScenario = []; + private $_relationsExtraColumns = []; //private $_relationsCascadeDelete = []; //TODO public function init() { parent::init(); - $allowedProperties = ['scenario']; + $allowedProperties = ['scenario', 'extraColumns']; foreach ($this->relations as $key => $value) { if (is_int($key)) { $this->_relations[] = $value; @@ -345,8 +346,6 @@ class SaveRelationsBehavior extends Behavior $model = $this->owner; $this->_relationsSaveStarted = true; try { - - foreach ($this->_relations as $relationName) { if (array_key_exists($relationName, $this->_oldRelationValue)) { // Relation was not set, do nothing... Yii::trace("Linking {$relationName} relation", __METHOD__); @@ -354,7 +353,6 @@ class SaveRelationsBehavior extends Behavior if ($relation->multiple === true) { // Has many relation // Process new relations $existingRecords = []; - /** @var BaseActiveRecord $relationModel */ foreach ($model->{$relationName} as $i => $relationModel) { if ($relationModel->isNewRecord) { @@ -367,7 +365,8 @@ class SaveRelationsBehavior extends Behavior throw new DbException("Related record {$pettyRelationName} could not be saved."); } } - $model->link($relationName, $relationModel); + $junctionTableColumns = $this->_getJunctionTableColumns($relationName, $relationModel); + $model->link($relationName, $relationModel, $junctionTableColumns); } else { $existingRecords[] = $relationModel; } @@ -381,21 +380,31 @@ class SaveRelationsBehavior extends Behavior } } } + $junctionTablePropertiesUsed = array_key_exists($relationName, $this->_relationsExtraColumns); // Process existing added and deleted relations - list($addedPks, $deletedPks) = $this->_computePkDiff($this->_oldRelationValue[$relationName], $existingRecords); + list($addedPks, $deletedPks) = $this->_computePkDiff( + $this->_oldRelationValue[$relationName], + $existingRecords, + $junctionTablePropertiesUsed + ); // Deleted relations $initialModels = ArrayHelper::index($this->_oldRelationValue[$relationName], function (BaseActiveRecord $model) { return implode("-", $model->getPrimaryKey(true)); }); + $initialRelations = $model->{$relationName}; foreach ($deletedPks as $key) { $model->unlink($relationName, $initialModels[$key], true); } // Added relations - $actualModels = ArrayHelper::index($model->{$relationName}, function (BaseActiveRecord $model) { - return implode("-", $model->getPrimaryKey(true)); - }); + $actualModels = ArrayHelper::index( + $junctionTablePropertiesUsed ? $initialRelations : $model->{$relationName}, + function (BaseActiveRecord $model) { + return implode("-", $model->getPrimaryKey(true)); + } + ); foreach ($addedPks as $key) { - $model->link($relationName, $actualModels[$key]); + $junctionTableColumns = $this->_getJunctionTableColumns($relationName, $actualModels[$key]); + $model->link($relationName, $actualModels[$key], $junctionTableColumns); } } else { // Has one relation if ($this->_oldRelationValue[$relationName] !== $model->{$relationName}) { @@ -449,12 +458,39 @@ 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 + */ + 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) + private function _computePkDiff($initialRelations, $updatedRelations, $forceSave = false) { // Compute differences between initial relations and the current ones $oldPks = ArrayHelper::getColumn($initialRelations, function (BaseActiveRecord $model) { @@ -463,9 +499,14 @@ class SaveRelationsBehavior extends Behavior $newPks = ArrayHelper::getColumn($updatedRelations, function (BaseActiveRecord $model) { return implode("-", $model->getPrimaryKey(true)); }); - $identicalPks = array_intersect($oldPks, $newPks); - $addedPks = array_values(array_diff($newPks, $identicalPks)); - $deletedPks = array_values(array_diff($oldPks, $identicalPks)); + 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]; } diff --git a/tests/SaveRelationsBehaviorTest.php b/tests/SaveRelationsBehaviorTest.php index ee86de2..339e54b 100644 --- a/tests/SaveRelationsBehaviorTest.php +++ b/tests/SaveRelationsBehaviorTest.php @@ -2,7 +2,6 @@ namespace tests; - use lhs\Yii2SaveRelationsBehavior\SaveRelationsBehavior; use RuntimeException; use tests\models\Company; @@ -11,6 +10,7 @@ use tests\models\DummyModelParent; use tests\models\Link; use tests\models\Project; use tests\models\ProjectNoTransactions; +use tests\models\Tag; use tests\models\User; use Yii; use yii\base\Model; @@ -35,6 +35,8 @@ class SaveRelationsBehaviorTest extends \PHPUnit_Framework_TestCase $db->createCommand()->dropTable('company')->execute(); $db->createCommand()->dropTable('link_type')->execute(); $db->createCommand()->dropTable('link')->execute(); + $db->createCommand()->dropTable('project_tags')->execute(); + $db->createCommand()->dropTable('tags')->execute(); $db->createCommand()->dropTable('project_link')->execute(); $db->createCommand()->dropTable('dummy')->execute(); parent::tearDown(); @@ -79,6 +81,17 @@ class SaveRelationsBehaviorTest extends \PHPUnit_Framework_TestCase 'PRIMARY KEY(language, name)' ])->execute(); + $db->createCommand()->createTable('tags', [ + 'id' => $migration->primaryKey(), + 'name' => $migration->string()->notNull()->unique() + ])->execute(); + + $db->createCommand()->createTable('project_tags', [ + 'project_id' => $migration->integer()->notNull(), + 'tag_id' => $migration->integer()->notNull(), + 'order' => $migration->integer()->notNull() + ])->execute(); + $db->createCommand()->createTable('link_type', [ 'id' => $migration->primaryKey(), 'name' => $migration->string()->notNull()->unique() @@ -381,6 +394,27 @@ class SaveRelationsBehaviorTest extends \PHPUnit_Framework_TestCase ); } + public function testSaveNewManyRelationJunctionTableColumnsShouldSucceed() + { + $project = Project::findOne(1); + $firstTag = new Tag(); + $firstTag->name = 'Tag One'; + $firstTag->setOrder(1); + $secondTag = new Tag(); + $secondTag->name = 'Tag Two'; + $secondTag->setOrder(3); + $project->tags = [ + $firstTag, + $secondTag + ]; + $this->assertTrue($project->save(), 'Project could not be saved'); + $this->assertCount(2, $project->tags, 'Project should have 2 tags after assignment'); + $firstTagJunctionTableColumns = (new \yii\db\Query())->from('project_tags')->where(['tag_id' => $firstTag->id])->one(); + $secondTagJunctionTableColumns = (new \yii\db\Query())->from('project_tags')->where(['tag_id' => $secondTag->id])->one(); + $this->assertEquals($firstTag->getOrder(), $firstTagJunctionTableColumns['order']); + $this->assertEquals($secondTag->getOrder(), $secondTagJunctionTableColumns['order']); + } + public function testSaveMixedRelationsShouldSucceed() { $project = new Project(); @@ -582,7 +616,7 @@ class SaveRelationsBehaviorTest extends \PHPUnit_Framework_TestCase $project->loadRelations($data); $this->assertFalse($project->save(), 'Project could be saved'); $data = [ - 'Link' => [ + 'Link' => [ [ 'language' => 'en', 'name' => 'yii', diff --git a/tests/models/Project.php b/tests/models/Project.php index 10287cd..c91a772 100644 --- a/tests/models/Project.php +++ b/tests/models/Project.php @@ -22,7 +22,19 @@ class Project extends \yii\db\ActiveRecord return [ 'saveRelations' => [ 'class' => SaveRelationsBehavior::className(), - 'relations' => ['company', 'users', 'links' => ['scenario' => Link::SCENARIO_FIRST]] + 'relations' => [ + 'company', + 'users', + 'links' => ['scenario' => Link::SCENARIO_FIRST], + 'tags' => [ + 'extraColumns' => function ($model) { + /** @var $model Tag */ + return [ + 'order' => $model->order + ]; + } + ] + ], ], ]; } @@ -90,4 +102,12 @@ class Project extends \yii\db\ActiveRecord return $this->hasMany(Link::className(), ['language' => 'language', 'name' => 'name'])->via('projectLinks'); } + /** + * @return ActiveQuery + */ + public function getTags() + { + return $this->hasMany(Tag::className(), ['id' => 'tag_id'])->viaTable('project_tags', ['project_id' => 'id']); + } + } \ No newline at end of file diff --git a/tests/models/Tag.php b/tests/models/Tag.php new file mode 100644 index 0000000..7a36a80 --- /dev/null +++ b/tests/models/Tag.php @@ -0,0 +1,44 @@ +order = $order; + } + + /** + * @return int + */ + public function getOrder() + { + return $this->order; + } + + /** + * @inheritdoc + */ + public function rules() + { + return [ + [['name'], 'required'], + [['name'], 'string'] + ]; + } +} \ No newline at end of file