From dcc11984cd9ff8f6e6dd05cf5ffb0de598f1e402 Mon Sep 17 00:00:00 2001 From: Alban Jubert Date: Sat, 26 Mar 2016 17:02:41 +0100 Subject: [PATCH] Initial commit --- .gitignore | 4 + README.md | 107 ++++++++++++ composer.json | 35 ++++ phpunit.xml | 18 ++ src/SaveRelationsBehavior.php | 340 ++++++++++++++++++++++++++++++++++++ tests/SaveRelationsBehaviorTest.php | 326 ++++++++++++++++++++++++++++++++++ tests/bootstrap.php | 24 +++ tests/models/Company.php | 27 +++ tests/models/Link.php | 26 +++ tests/models/Project.php | 92 ++++++++++ tests/models/ProjectLink.php | 25 +++ tests/models/ProjectUser.php | 26 +++ tests/models/User.php | 26 +++ 13 files changed, 1076 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 composer.json create mode 100644 phpunit.xml create mode 100644 src/SaveRelationsBehavior.php create mode 100644 tests/SaveRelationsBehaviorTest.php create mode 100644 tests/bootstrap.php create mode 100644 tests/models/Company.php create mode 100644 tests/models/Link.php create mode 100644 tests/models/Project.php create mode 100644 tests/models/ProjectLink.php create mode 100644 tests/models/ProjectUser.php create mode 100644 tests/models/User.php diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..62b5eef --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ + +*.lock +/vendor/ +/tests/report/ \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..18e5024 --- /dev/null +++ b/README.md @@ -0,0 +1,107 @@ +Yii2 Active Record Save Relations Behavior +========================================== +Automatically validate and save Active Record related models. +Both Has Many and Has One relations are supported. + +Installation +------------ + +The preferred way to install this extension is through [composer](http://getcomposer.org/download/). + +Either run + +``` +php composer.phar require --prefer-dist lhs/yii2-save-relations-behavior "*" +``` + +or add + +``` +"lhs/yii2-save-relations-behavior": "*" +``` + +to the require section of your `composer.json` file. + + +Configuring +----------- + +Configure model as follows +```php +use lhs\Yii2SaveRelationsBehavior\SaveRelationsBehavior; + +class Project extends \yii\db\ActiveRecord +{ + public function behaviors() + { + return [ + 'timestamp' => TimestampBehavior::className(), + 'blameable' => BlameableBehavior::className(), + ... + 'saveRelations' => [ + 'class' => SaveRelationsBehavior::className(), + 'relations' => ['users', 'company'] + ], + ]; + } + + public function transactions() + { + return [ + self::SCENARIO_DEFAULT => self::OP_ALL, + ]; + } + + ... + + + /** + * @return ActiveQuery + */ + public function getCompany() + { + return $this->hasOne(Company::className(), ['id' => 'company_id']); + } + + /** + * @return ActiveQuery + */ + public function getMyModelUsers() + { + return $this->hasMany(ProjectUser::className(), ['project_id' => 'id']); + } + + /** + * @return ActiveQuery + */ + public function getUsers() + { + return $this->hasMany(User::className(), ['id' => 'user_id'])->via('ProjectUsers'); + } + +} +``` +Though not mandatory, it is highly recommended to activate the transactions + + +Usage +----- + +Every declared relations in the `relations` behavior parameter can now be set as follow: +```php +// Has one relation using a model +$model = MyModel::findOne(321); +$company = Company::findOne(123); +$model->company = $company; +$model->save(); +``` + +or + +```php +// Has one relation using a foreign key +$model = MyModel::findOne(321); +$model->company = 123; // or $model->company = ['id' => 123]; +$model->save(); +``` + diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..6f89c94 --- /dev/null +++ b/composer.json @@ -0,0 +1,35 @@ +{ + "name": "la-haute-societe/yii2-save-relations-behavior", + "description": "Automatically validate and save related Active Record models.", + "type": "yii2-extension", + "keywords": [ + "yii2", + "extension", + "active-record", + "active-query", + "database", + "behavior", + "relations", + "has_many", + "has_one" + ], + "license": "MIT", + "authors": [ + { + "name": "Alban Jubert", + "email": "alban@lahautesociete.com" + } + ], + "require": { + "yiisoft/yii2": ">=2.0.7" + }, + "autoload": { + "psr-4": { + "lhs\\Yii2SaveRelationsBehavior\\": "src/" + } + }, + "config": { + "process-timeout": 1800, + "preferred-install": "dist" + } +} diff --git a/phpunit.xml b/phpunit.xml new file mode 100644 index 0000000..98c1816 --- /dev/null +++ b/phpunit.xml @@ -0,0 +1,18 @@ + + + + + ./tests + + + + + ./src/SaveRelationsBehavior.php + + + diff --git a/src/SaveRelationsBehavior.php b/src/SaveRelationsBehavior.php new file mode 100644 index 0000000..461f89e --- /dev/null +++ b/src/SaveRelationsBehavior.php @@ -0,0 +1,340 @@ + 'beforeValidate', + ActiveRecord::EVENT_AFTER_INSERT => 'afterSave', + ActiveRecord::EVENT_AFTER_UPDATE => 'afterSave', + ]; + } + + /** + * Check if the behavior is attached to an Active Record + * @param ActiveRecord $owner + * @throws RuntimeException + */ + public function attach($owner) + { + if (!($owner instanceof ActiveRecord)) { + throw new RuntimeException('Owner must be instance of yii\db\ActiveRecord'); + } + parent::attach($owner); + } + + /** + * Override canSetProperty method to be able to detect if a relation setter is allowed + * @param string $name + * @param boolean $checkVars + * @return boolean + */ + public function canSetProperty($name, $checkVars = true) + { + $getter = 'get' . $name; + if (in_array($name, $this->relations) && method_exists($this->owner, + $getter) && $this->owner->$getter() instanceof ActiveQuery + ) { + return true; + } + return parent::canSetProperty($name, $checkVars); + } + + /** + * Override __set method to be able to set relations values either by providing related primary keys + * or instance of related model + * @param string $name + * @param mixed $value + */ + public function __set($name, $value) + { + if (in_array($name, $this->relations)) { + //echo "Setting " . $name . "\n"; + /** @var ActiveRecord $model */ + $model = $this->owner; + Yii::trace("Setting {$name} relation value", __METHOD__); + /** @var \yii\db\ActiveQuery $relation */ + $relation = $model->getRelation($name); + if (!isset($this->_oldRelationValue[$name])) { + //Yii::trace("Initializing old {$name} relation value", __METHOD__); + $this->_oldRelationValue[$name] = $this->owner->{$name}; + } + if ($relation->multiple === true) { + $newRelations = []; + foreach ($value as $entry) { + if ($entry instanceof $relation->modelClass) { + $newRelations[] = $entry; + } else { + // TODO handle this with one DB request to retrieve all models + $newRelations[] = $this->_processModelAsArray($entry, $relation); + } + } + $model->populateRelation($name, $newRelations); + } else { + if (!($value instanceof $relation->modelClass)) { + $value = $this->_processModelAsArray($value, $relation); + } + $model->populateRelation($name, $value); + } + } + } + + /** + * Get an ActiveRecord model using the given $data parameter. + * $data could either be a model ID or a model associative array representing its attributes => values + * @param mixed $data + * @param \yii\db\ActiveQuery $relation + * @return ActiveRecord + */ + public function _processModelAsArray($data, $relation) + { + /** @var ActiveRecord $modelClass */ + $modelClass = $relation->modelClass; + // get the related model foreign keys + if (is_array($data)) { + $fks = []; + foreach ($relation->link as $relatedAttribute => $modelAttribute) { + if (array_key_exists($relatedAttribute, $data) && !empty($data[$relatedAttribute])) { + $fks[$relatedAttribute] = $data[$relatedAttribute]; + } + } + } else { + $fks = $data; + } + // Load existing model or create one if no key was provided + /** @var ActiveRecord $relationModel */ + $relationModel = null; + if (!empty($fks)) { + $relationModel = $modelClass::findOne($fks); + } + if (!$relationModel) { + $relationModel = new $modelClass; + } + if (is_array($data)) { + $relationModel->setAttributes($data); + } + return $relationModel; + } + + /** + * Before the owner model validation, for has one relations, set the according foreign keys of the owner model + * to be able to validate it + * @param ModelEvent $event + */ + public function beforeValidate(ModelEvent $event) + { + if ($this->_relationsSaveStarted == false && !empty($this->_oldRelationValue)) { + /* @var $model ActiveRecord */ + $model = $this->owner; + if ($this->_saveRelatedRecords($model, $event)) { + // If relation is has_one, try to set related model attributes + foreach ($this->relations as $relationName) { + if (array_key_exists($relationName, + $this->_oldRelationValue)) { // Relation was not set, do nothing... + $relation = $model->getRelation($relationName); + if ($relation->multiple === false && !empty($model->{$relationName})) { + Yii::trace("Setting foreign keys for {$relationName}", __METHOD__); + foreach ($relation->link as $relatedAttribute => $modelAttribute) { + $model->{$modelAttribute} = $model->{$relationName}->{$relatedAttribute}; + } + } + } + } + } + } + } + + /** + * For each related model, try to save it first. + * If set in the owner model, operation is done in a transactional way so if one of the models should not validate + * or be saved, a rollback will occur. + * This is done during the before validation process to be able to set the related foreign keys. + * @param ActiveRecord $model + * @param ModelEvent $event + * @return bool + */ + public function _saveRelatedRecords(ActiveRecord $model, ModelEvent $event) + { + if (($model->isNewRecord && $model->isTransactional($model::OP_INSERT)) + || (!$model->isNewRecord && $model->isTransactional($model::OP_UPDATE)) + || $model->isTransactional($model::OP_ALL) + ) { + $this->_transaction = $model->getDb()->beginTransaction(); + } + try { + foreach ($this->relations as $relationName) { + + if (array_key_exists($relationName, + $this->_oldRelationValue)) { // Relation was not set, do nothing... + $relation = $model->getRelation($relationName); + if (!empty($model->{$relationName})) { + if ($relation->multiple === false) { + // Save Has one relation new record + $pettyRelationName = Inflector::camel2words($relationName, true); + $this->_saveModelRecord($model->{$relationName}, $event, $pettyRelationName, + $relationName); + } else { + // Save Has many relations new records + /** @var ActiveRecord $relationModel */ + foreach ($model->{$relationName} as $i => $relationModel) { + $pettyRelationName = Inflector::camel2words($relationName, true) . " #{$i}"; + $this->_saveModelRecord($relationModel, $event, $pettyRelationName, $relationName); + } + } + } + } + } + if (!$event->isValid) { + throw new Exception("One of the related model could not be validated"); + } + } catch (Exception $e) { + $this->_transaction->rollBack(); // If anything goes wrong, transaction will be rolled back + return false; + } + return true; + } + + + /** + * + * @param ActiveRecord $model + * @param ModelEvent $event + * @param $pettyRelationName + * @param $relationName + */ + public function _saveModelRecord(ActiveRecord $model, ModelEvent $event, $pettyRelationName, $relationName) + { + $this->_validateRelationModel($pettyRelationName, $relationName, $model, + $event); + if ($event->isValid && count($model->dirtyAttributes)) { + Yii::trace("Saving {$pettyRelationName} relation model", __METHOD__); + $model->save(false); + } + } + + /** + * Validate a relation model and add an error message to owner attribute if needed + * @param string $pettyRelationName + * @param string $relationName + * @param ActiveRecord $relationModel + * @param ModelEvent $event + */ + private function _validateRelationModel( + $pettyRelationName, + $relationName, + ActiveRecord $relationModel, + ModelEvent $event + ) { + /** @var ActiveRecord $model */ + $model = $this->owner; + if (!is_null($relationModel) && ($relationModel->isNewRecord || count($relationModel->getDirtyAttributes()))) { + Yii::trace("Validating {$pettyRelationName} relation model", __METHOD__); + if (!$relationModel->validate()) { + foreach ($relationModel->errors as $attributeErrors) { + foreach ($attributeErrors as $error) { + $model->addError($relationName, "{$pettyRelationName}: {$error}"); + } + $event->isValid = false; + } + } + } + } + + /** + * Save the related models. + * If the models have not been changed, nothing will be done. + * Otherwise, new related records will be created, changed related records will be update and linked to the owner + * using the ActiveRecord link() method. + * Unchanged records will stay untouched. + * @param ModelEvent $event + */ + public function afterSave() + { + if ($this->_relationsSaveStarted == false) { + /** @var ActiveRecord $model */ + $model = $this->owner; + $this->_relationsSaveStarted = true; + foreach ($this->relations as $relationName) { + if (array_key_exists($relationName, $this->_oldRelationValue)) { // Relation was not set, do nothing... + Yii::trace("Linking {$relationName} relation", __METHOD__); + $relation = $model->getRelation($relationName); + if ($relation->multiple === true) { // Has many relation + list($addedPks, $deletedPks) = $this->_computePkDiff($this->_oldRelationValue[$relationName], + $model->{$relationName}); + // Deleted relations + $initialModels = ArrayHelper::index($this->_oldRelationValue[$relationName], + function (ActiveRecord $model) { + return implode("-", $model->getPrimaryKey(true)); + }); + foreach ($deletedPks as $key) { + $model->unlink($relationName, $initialModels[$key], true); + } + // Added relations + $actualModels = ArrayHelper::index($model->{$relationName}, function (ActiveRecord $model) { + return implode("-", $model->getPrimaryKey(true)); + }); + foreach ($addedPks as $key) { + $model->link($relationName, $actualModels[$key]); + } + } else { // Has one relation + if ($this->_oldRelationValue[$relationName] != $model->{$relationName}) { + $model->link($relationName, $model->{$relationName}); + } + } + unset($this->_oldRelationValue[$relationName]); + } + } + $model->refresh(); + $this->_relationsSaveStarted = false; + if ($this->_transaction->isActive) { + $this->_transaction->commit(); + } + } + } + + /** + * Compute the difference between two set of records using primary keys "tokens" + * @param ActiveRecord[] $initialRelations + * @param ActiveRecord[] $updatedRelations + * @return array + */ + private function _computePkDiff($initialRelations, $updatedRelations) + { + // Compute differences between initial relations and the current ones + $oldPks = ArrayHelper::getColumn($initialRelations, function (ActiveRecord $model) { + return implode("-", $model->getPrimaryKey(true)); + }); + $newPks = ArrayHelper::getColumn($updatedRelations, function (ActiveRecord $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)); + return [$addedPks, $deletedPks]; + } + +} diff --git a/tests/SaveRelationsBehaviorTest.php b/tests/SaveRelationsBehaviorTest.php new file mode 100644 index 0000000..f39fa6b --- /dev/null +++ b/tests/SaveRelationsBehaviorTest.php @@ -0,0 +1,326 @@ +setupDbData(); + } + + protected function tearDown() + { + $db = Yii::$app->getDb(); + $db->createCommand()->dropTable('project_user')->execute(); + $db->createCommand()->dropTable('project')->execute(); + $db->createCommand()->dropTable('user')->execute(); + $db->createCommand()->dropTable('company')->execute(); + $db->createCommand()->dropTable('link')->execute(); + $db->createCommand()->dropTable('project_link')->execute(); + parent::tearDown(); + } + + protected function setupDbData() + { + /** @var \yii\db\Connection $db */ + $db = Yii::$app->getDb(); + $migration = new Migration(); + + /** + * Create tables + **/ + + // Company + $db->createCommand()->createTable('company', [ + 'id' => $migration->primaryKey(), + 'name' => $migration->string()->notNull()->unique() + ])->execute(); + + // User + $db->createCommand()->createTable('user', [ + 'id' => $migration->primaryKey(), + 'username' => $migration->string()->notNull()->unique() + ])->execute(); + + // Project + $db->createCommand()->createTable('project', [ + 'id' => $migration->primaryKey(), + 'name' => $migration->string()->notNull(), + 'company_id' => $migration->integer()->notNull(), + ])->execute(); + + $db->createCommand()->createIndex('company_id-name', 'project', 'company_id,name', true)->execute(); + + $db->createCommand()->createTable('link', [ + 'language' => $migration->string(5)->notNull(), + 'name' => $migration->string()->notNull(), + 'link' => $migration->string()->notNull(), + 'PRIMARY KEY(language, name)' + ])->execute(); + + $db->createCommand()->createTable('project_link', [ + 'language' => $migration->string(5)->notNull(), + 'name' => $migration->string()->notNull(), + 'project_id' => $migration->integer()->notNull(), + 'PRIMARY KEY(language, name, project_id)' + ])->execute(); + + // Project User + $db->createCommand()->createTable('project_user', [ + 'project_id' => $migration->integer()->notNull(), + 'user_id' => $migration->integer()->notNull(), + 'PRIMARY KEY(project_id, user_id)' + ])->execute(); + + /** + * Insert some data + */ + + $db->createCommand()->batchInsert('company', ['id', 'name'], [ + [1, 'Apple'], + [2, 'Microsoft'], + [3, 'Google'], + ])->execute(); + + $db->createCommand()->batchInsert('user', ['id', 'username'], [ + [1, 'Steve Jobs'], + [2, 'Bill Gates'], + [3, 'Tim Cook'], + [4, 'Jonathan Ive'] + ])->execute(); + + $db->createCommand()->batchInsert('project', ['id', 'name', 'company_id'], [ + [1, 'Mac OS X', 1], + [2, 'Windows 10', 2] + ])->execute(); + + $db->createCommand()->batchInsert('link', ['language', 'name', 'link'], [ + ['fr', 'mac_os_x', 'http://www.apple.com/fr/osx/'], + ['en', 'mac_os_x', 'http://www.apple.com/osx/'] + ])->execute(); + + $db->createCommand()->batchInsert('project_link', ['language', 'name', 'project_id'], [ + ['fr', 'mac_os_x', 1], + ['en', 'mac_os_x', 1] + ])->execute(); + + $db->createCommand()->batchInsert('project_user', ['project_id', 'user_id'], [ + [1, 1], + [1, 4], + [2, 2] + ])->execute(); + + } + + /** + * @expectedException RuntimeException + */ + public function testCannotAttachBehaviorToAnythingButActiveRecord() + { + $model = new Model(); + $model->attachBehavior('saveRelated', SaveRelationsBehavior::className()); + } + + /** + * @expectedException \yii\base\InvalidCallException + */ + public function testTryToSetUndeclaredRelationShouldFail() + { + $project = new Project(); + $project->projectUsers = []; + } + + public function testSaveExistingHasOneRelationAsModelShouldSucceed() + { + $project = new Project(); + $project->name = "iOS 9"; + $project->company = Company::findOne(1); + $this->assertTrue($project->validate(), 'Project should be valid'); + $this->assertTrue($project->save(), 'Project could not be saved'); + $this->assertEquals(1, $project->company_id, 'Company ID is not the one expected'); + } + + public function testSaveExistingHasOneRelationAsIdShouldSucceed() + { + $project = new Project(); + $project->name = "GMail"; + $project->company = 3; + $this->assertTrue($project->save(), 'Project could not be saved'); + $this->assertEquals(3, $project->company_id, 'Company ID is not the one expected'); + } + + public function testSaveNewHasOneRelationShouldSucceed() + { + $project = new Project(); + $project->name = "Java"; + $company = new Company(); + $company->name = "Oracle"; + $project->company = $company; + $this->assertTrue($company->isNewRecord, 'Company should be a new record'); + $this->assertTrue($project->save(), 'Project could not be saved'); + $this->assertNotNull($project->company_id, 'Company ID should be set'); + $this->assertEquals($project->company_id, $company->id, 'Company ID is not the one expected'); + } + + public function testChangingHasOneRelationShouldSucceed() + { + $project = Project::findOne(1); + $project->company = Company::findOne(2); // Change project company from Apple to Microsoft + $this->assertTrue($project->save(), 'Project could be saved'); + $this->assertEquals(2, $project->company_id); + $this->assertEquals('Microsoft', $project->company->name); + } + + public function testSaveInvalidNewHasOneRelationShouldFail() + { + $project = new Project(); + $project->name = "Java"; + $company = new Company(); + $project->company = $company; + $this->assertTrue($company->isNewRecord, 'Company should be a new record'); + $this->assertFalse($project->save(), 'Project could be saved'); + $this->assertArrayHasKey('company', $project->getErrors(), + 'Validation errors do not contain a message for company'); + $this->assertEquals('Company: Name cannot be blank.', $project->getFirstError('company')); + } + + public function testSaveAddedExistingHasManyRelationShouldSucceed() + { + $project = Project::findOne(1); + $user = User::findOne(3); + $this->assertEquals(2, count($project->users), 'Project should have 2 users before save'); + $project->users = array_merge($project->users, [$user]); // Add new user to the existing list + $this->assertEquals(3, count($project->users), 'Project should have 3 users after assignment'); + $this->assertTrue($project->save(), 'Project could not be saved'); + $this->assertEquals(3, count($project->users), 'Project should have 3 users after save'); + } + + public function testSaveAddedExistingHasManyRelationAsArrayShouldSucceed() + { + $project = Project::findOne(1); + $user = ['id' => 3]; + $this->assertEquals(2, count($project->users), 'Project should have 2 users before save'); + $project->users = array_merge($project->users, [$user]); // Add new user to the existing list + $this->assertEquals(3, count($project->users), 'Project should have 3 users after assignment'); + $this->assertTrue($project->save(), 'Project could not be saved'); + $this->assertEquals(3, count($project->users), 'Project should have 3 users after save'); + } + + public function testSaveAddedExistingHasManyRelationAsIDShouldSucceed() + { + $project = Project::findOne(1); + $user = 3; + $this->assertEquals(2, count($project->users), 'Project should have 2 users before save'); + $project->users = array_merge($project->users, [$user]); // Add new user to the existing list + $this->assertEquals(3, count($project->users), 'Project should have 3 users after assignment'); + $this->assertTrue($project->save(), 'Project could not be saved'); + $this->assertEquals(3, count($project->users), 'Project should have 3 users after save'); + } + + public function testSaveDeletedExistingHasManyRelationShouldSucceed() + { + $project = Project::findOne(1); + $this->assertEquals(2, count($project->users), 'Project should have 2 users before save'); + $project->users = User::findAll([1]); // Change users by removing one + $this->assertEquals(1, count($project->users), 'Project should have 1 user after assignment'); + $this->assertTrue($project->save(), 'Project could not be saved'); + $this->assertEquals(1, count($project->users), 'Project should have 1 user after save'); + } + + public function testSaveNewHasManyRelationAsModelShouldSucceed() + { + $project = Project::findOne(2); + $this->assertEquals(1, count($project->users), 'Project should have 1 user before save'); + $user = new User(); + $user->username = "Steve Balmer"; + $project->users = array_merge($project->users, [$user]); // Add a fresh new user + $this->assertEquals(2, count($project->users), 'Project should have 2 users after assignment'); + $this->assertTrue($project->save(), 'Project could not be saved'); + $this->assertEquals(2, count($project->users), 'Project should have 2 users after save'); + } + + public function testSaveNewHasManyRelationAsArrayShouldSucceed() + { + $project = Project::findOne(2); + $this->assertEquals(1, count($project->users), 'Project should have 1 user before save'); + $user = ['username' => "Steve Balmer"]; + $project->users = array_merge($project->users, [$user]); // Add a fresh new user + $this->assertEquals(2, count($project->users), 'Project should have 2 users after assignment'); + $this->assertTrue($project->save(), 'Project could not be saved'); + $this->assertEquals(2, count($project->users), 'Project should have 2 users after save'); + $this->assertEquals("Steve Balmer", $project->users[1]->username, 'Second user should be Steve Balmer'); + $this->assertNotEmpty($project->users[1]->id, 'Second user should have an ID'); + } + + public function testSaveNewHasManyRelationWithCompositeFksShouldSucceed() + { + $project = Project::findOne(1); + $this->assertEquals(2, count($project->links), 'Project should have 2 links before save'); + $link = new Link(); + $link->language = 'fr'; + $link->name = 'windows10'; + $link->link = 'https://www.microsoft.com/fr-fr/windows/features'; + $project->links = array_merge($project->links, [$link]); + $this->assertEquals(3, count($project->links), 'Project should have 3 links after assignment'); + $this->assertTrue($project->save(), 'Project could not be saved'); + $this->assertEquals(3, count($project->links), 'Project should have 3 links after save'); + $this->assertEquals("https://www.microsoft.com/fr-fr/windows/features", $project->links[2]->link, 'Second link should be https://www.microsoft.com/fr-fr/windows/features'); + } + + public function testSaveNewHasManyRelationWithCompositeFksAsArrayShouldSucceed() + { + $project = Project::findOne(1); + $this->assertEquals(2, count($project->links), 'Project should have 2 links before save'); + $links = [ + ['language' => 'fr', 'name' => 'windows10', 'link' => 'https://www.microsoft.com/fr-fr/windows/features'], + ['language' => 'en', 'name' => 'windows10', 'link' => 'https://www.microsoft.com/en-us/windows/features'] + ]; + $project->links = array_merge($project->links, $links); + $this->assertEquals(4, count($project->links), 'Project should have 4 links after assignment'); + $this->assertTrue($project->save(), 'Project could not be saved'); + $this->assertEquals(4, count($project->links), 'Project should have 4 links after save'); + $this->assertEquals("https://www.microsoft.com/fr-fr/windows/features", $project->links[2]->link, 'Second link should be https://www.microsoft.com/fr-fr/windows/features'); + $this->assertEquals("https://www.microsoft.com/en-us/windows/features", $project->links[3]->link, 'Third link should be https://www.microsoft.com/en-us/windows/features'); + } + + public function testSaveUpdatedHasManyRelationWithCompositeFksAsArrayShouldSucceed() + { + $project = Project::findOne(1); + $this->assertEquals(2, count($project->links), 'Project should have 2 links before save'); + $links = $project->links; + $links[1]->link = "http://www.otherlink.com/"; + $project->links = $links; + $this->assertTrue($project->save(), 'Project could not be saved'); + $this->assertEquals("http://www.otherlink.com/", $project->links[1]->link, 'Second link "Link" attribute should be "http://www.otherlink.com/"'); + } + + public function testSaveMixedRelationsShouldSucceed() + { + $project = new Project(); + $project->name = "New project"; + $project->company = Company::findOne(2); + $users = User::findAll([1,3]); + $this->assertEquals(0, count($project->users), 'Project should have 0 users before save'); + $project->users = $users; // Add users + $this->assertEquals(2, count($project->users), 'Project should have 2 users after assignment'); + $this->assertTrue($project->save(), 'Project could not be saved'); + $this->assertEquals(2, count($project->users), 'Project should have 2 users after save'); + $this->assertEquals(2, $project->company_id, 'Company ID is not the one expected'); + } + + +} diff --git a/tests/bootstrap.php b/tests/bootstrap.php new file mode 100644 index 0000000..417eab4 --- /dev/null +++ b/tests/bootstrap.php @@ -0,0 +1,24 @@ + 'unit', + 'basePath' => __DIR__, + 'vendorPath' => dirname(__DIR__) . '/vendor', + 'components' => [ + 'db' => [ + 'class' => 'yii\db\Connection', + 'dsn' => 'sqlite::memory:', + ], + ], +]); diff --git a/tests/models/Company.php b/tests/models/Company.php new file mode 100644 index 0000000..4aece91 --- /dev/null +++ b/tests/models/Company.php @@ -0,0 +1,27 @@ + '\tests\models\Company'], + ]; + } + +} diff --git a/tests/models/Link.php b/tests/models/Link.php new file mode 100644 index 0000000..a8bf7c8 --- /dev/null +++ b/tests/models/Link.php @@ -0,0 +1,26 @@ + ['language', 'name']], + ]; + } + +} diff --git a/tests/models/Project.php b/tests/models/Project.php new file mode 100644 index 0000000..20ea457 --- /dev/null +++ b/tests/models/Project.php @@ -0,0 +1,92 @@ + [ + 'class' => SaveRelationsBehavior::className(), + 'relations' => ['company', 'users', 'links'] + ], + ]; + } + + /** + * @inheritdoc + */ + public function rules() + { + return [ + [['name', 'company_id'], 'required'], + [['name'], 'unique', 'targetAttribute' => ['company_id', 'name']], + ]; + } + + /** + * @inheritdoc + */ + public function transactions() + { + return [ + self::SCENARIO_DEFAULT => self::OP_ALL, + ]; + } + + /** + * @return ActiveQuery + */ + public function getCompany() + { + return $this->hasOne(Company::className(), ['id' => 'company_id']); + } + + /** + * @return ActiveQuery + */ + public function getProjectUsers() + { + return $this->hasMany(ProjectUser::className(), ['project_id' => 'id']); + } + + /** + * @return ActiveQuery + */ + public function getUsers() + { + 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 + */ + public function getLinks() + { + return $this->hasMany(Link::className(), ['language' => 'language', 'name' => 'name'])->via('projectLinks'); + } + +} \ No newline at end of file diff --git a/tests/models/ProjectLink.php b/tests/models/ProjectLink.php new file mode 100644 index 0000000..3ca876e --- /dev/null +++ b/tests/models/ProjectLink.php @@ -0,0 +1,25 @@ + '\tests\models\User'], + ]; + } + +}