The ESaveRelatedBehavior enables ActiveRecord models to save HAS_MANY relational active records and MANY_MANY relations along with the main model.
It provides two new methods:
saveWithRelated()
Saves the model and all specified related models
returns false if any model has not been saved
saveRelated()
Saves the specified related models only and
returns false if any model has not been saved
Features:
- handles many_many and has_many relations
- fills has_many relations with validated objects
- allows selection of scenario for has_many relations
- processes everything within a transaction
- relations can be set with data arrays or object arrays
- only specified relations are saved
- massive assignment works, since data is set on relation directly
- adds 2 methods to activeRecord, no use of beforeSave/afterSave,
therefore the behavior can be added to all activeRecord classes,
it will only do its work when the new methods are called - uses standard SQL
- NO HANDLING OF COMPOSITE KEYS yet
Requirements ¶
Yii Framework 1.1 or above PHP version 5.3 or above
Usage ¶
Extract the release file under 'protected/components'
Add the following code to your models:
public function behaviors(){
return array('ESaveRelatedBehavior' => array(
'class' => 'application.components.ESaveRelatedBehavior')
);
}
MANY_MANY relations ¶
When a MANY_MANY relation is defined in the relations() function,
instances of the foreign model can be related while saving the model.
Examples:
post model defines many_many relation with categories:
'categories'=>array(
self::MANY_MANY, 'category', 'post_category(post_id, category_id)'
)
To add new rows to the m:n table (post_category) do:
$post = new post();
$post->categories = category::model()->findAll();
$post->saveWithRelated('categories');
This saves the new post and updates the m:n table with every category.
The typical usage to assign data send from a checkBoxList would be:
$post->categories = array(1, 2, 3);
$post->saveWithRelated('categories');
So you can either pass an array of objects or an array of the primary keys of the foreign table.
You can also pass a single object or a single primary key:
$post->categories = category::model()->findByPk(1);
$post->categories = 1;
$post->saveWithRelated('categories');
If you are saving several relations you would do:
$post->saveWithRelated( array('relation1', 'relation2') );
If saving was not successful, then $post->categories will return the data that was set.
If saving was successful, then the relation will return the related models.
When updating a model then all entries in the m:n table for this model are first deleted.
(Howto prevent deletion see section "advanced usage")
HAS_MANY relations ¶
When a HAS_MANY relation is defined in the relations() function, instances of the foreign model can be added while saving the model.
Examples:
author model defines has_many relation with posts:
'posts'=>array(
self::HAS_MANY, 'post', 'author_post(author_id, post_id)'
)
To save a new author along with 2 posts you can now do:
$author = new author();
$author->posts = array(new post(), new post());
$author->saveWithRelated('posts');
This saves the new author together with two (empty) posts.
The typical usage though would be to assign data send from a tabular form. Each array entry represents the data for one post:
$author->posts = array(
array(
'title' => 'My first post',
'content' => 'This is my first post.'
),
array(
'title' => 'My second post',
'content' => 'This is my second post.'
)
);
$author->saveWithRelated('posts');
So similar to many_many relations you can either pass an array of objects or an array of data that represents the related objects.
You can also pass a single object:
$author->posts = new post();
$author->saveWithRelated('posts');
If you are saving several relations you would do:
$author->saveWithRelated( array('relation1', 'relation2') );
If saving was not successful, then $author->posts will return
an array of validated post models which can be used to display
the tabular input form along with validation messages.
If saving was successful, then the relation will return the related models.
When updating a model then all records related to this model are first deleted.
(You can prevent deletion: see section "advanced usage")
You can also specify the scenario to be used for the insertion of the related models:
$model->saveWithRelated( array('relationName1' => array('scenario' => 'special')));
You can specify the scenario to be used for the insertion of the last related model:
$model->saveWithRelated( array('relationName1' => array('lastScenario' => 'special')));
This can be useful if you have to validate aggregated values.
Say you have a field A in the model and field B in the related models.
Now you would like to check if the sum of values in field B equals the value in field A.
You can do this with an SQL statement in a validator which is active for the last scenario.
Advanced usage ¶
To save the related data only do:
$model->saveRelated( array('relationName1','relationName2') );
If you do not want to delete existing related records before insertion do
$model->saveWithRelated( array('relationName1' => array('append' => true)));
You will have to make sure that the new records are not dublicates of existing records, the behavior does not handle that.
Update ¶
v1.6
Fixed bug that occurred when saving MANY_MANY relations when parent object had errors
v1.5
Added parameter 'lastScenario'
v1.4
Small code change to make it compatible to PHP versions < 5.3.
This has not been checked on all versions though, so I left requirements at PHP >= 5.3
v1.3
Corrected error: When using saveWithRelated for new models they can contain validation errors and hence will not be saved, so their id is not known. Therefore no models can be related yet. MANY_MANY-relations will now not be saved in this case. For HAS_MANY relations the models will only be validated (no saving attempt,because the foreign key would be missing).
v1.2
Corrected error: when checking for a model class for a mn-table
the autoloader caused an exception when the class file did not exist
v1.1
- added support for scenario selection when adding has_many relations
- changed the way options are specified when saving relations
(now an array is used so that 'append' and 'scenario' can both be specified
v1.0
- first version
:)
Interesting. Very similar to giix's saveWithRelated, but without the need for the generator.
feature request
I like your extension and I use it.
I have modified it a little to also save data in additional columns that might exist on many to many table.
So lets say you have an extra column 'order', then you would pass the following array:
$post->categories = array(array(1,'order'=>3), array(2,'order'=>2), array(3,'order'=>1));
If you could add this in your extension would be great.
@mihaic seem to be very useful feature
@sluderitz mihaic 's suggestion is great ,this feature should be added ! very often we have additional column in bridge table(many_many 's brigeTable);
and mihaic could you show your modified code snippet , i need such modifying too ^-^
@mihaic feature request
Validation would be my main concern here. To implement this in a clean way, you should probably also use a model for the many2many table. Then the data could be assigend using the has_many feature of my extention. I will think about it and post a solution or maybe extend the extention :-)
New version: v1.3
I thought everything was fine but using my own extension I still ran into errors when adding new models together with related models. This is now fixed. Check out the latest version and the update note.
Added Delete Self record and MANY_MANY Relation record
Basically copy - paste with little modifications
protected function deleteR($relations) { $result = true; $t = false; // only start transaction if none is running already if (!Yii::app()->db->currentTransaction) { $t = Yii::app()->db->beginTransaction(); } // loop through all relations that should be saved foreach ((array)$relations as $key => $relationName) { // get relation information $relation = $this->owner->getActiveRelation($relationName); // get the data that was set for this relation, if no data was set, $data will contain the current related records $data = $this->owner->$relationName; // make sure data is an array $data = is_object($data) ? array($data) : (array)$data; $commandBuilder = $this->owner->getCommandBuilder(); // Handle many_many relations, this check has to be done first, since CManyManyRelation extends CHasManyRelation if ($relation instanceof CManyManyRelation) { // extract infos about mn linking table if (preg_match('/^\s*\{{0,2}\s*(.+?)\s*\}{0,2}\s*\(\s*(.+)\s*,\s*(.+)\s*\)\s*$/s',$relation->foreignKey, $matches)) { $info = array( 'mnTable' => $matches[1], 'mnFk1' => $matches[2], 'mnFk2' => $matches[3] ); } else throw new CException("Unable to get table and foreign key information from MANY_MANY relation definition (".$relation->foreignKey.")"); foreach($data as $id) { // get id if object was given if (is_object($id)) $id = $id->primaryKey; $criteria = new CDbCriteria(); $criteria->compare($info['mnFk1'], $this->owner->primaryKey); $criteria->compare($info['mnFk2'], $id); $affected = $commandBuilder->createDeleteCommand($info['mnTable'],$criteria)->execute(); if ($affected <= 0) $result = false; } // saving was successful, clear the relation, so accessing it will return the related records if ($result) unset($this->owner->$relationName); } } // commit on success if transaction was started in this behavior if ($t && $result) $t->commit(); // rollback on errors if transaction was started in this behavior if ($t && !$result) $t->rollback(); return $result; }
Doesn't work in php 5.3 <
php 5.3 < doesn't allow to use a string variable as a class
a unexpected T_PAAMAYIM_NEKUDOTAYIM error will be throw
there is an easy fix for this:
change line 258:
$class::model()->deleteAllByAttributes(array( // delete current related models $relation->foreignKey => $this->owner->primaryKey ));
TO THIS:
$relatedmodel = call_user_func(array($class, 'model')); $relatedmodel->deleteAllByAttributes(array( // delete current related models $relation->foreignKey => $this->owner->primaryKey ));
hope it helps someone
@MichaelH does not work in PHP 5.3<
Do you mean that it does not work in PHP versions below 5.3?
I changed the code a little bit and uploaded a new version, please check it out. I did it in a slightly different way (less code).
Thanx for pointing this out.
@sluderitz
yes php versions below 5.3 will give an arror when using a double double column (::) after a variable name
$class = new $relation->className; seems to be working on php 5.2 (havn't tested any other version)
have you done any work on handeling composite keys?
i am looking for thisfunctionality an will start adding it to your extension if no work is done yet.
grtz Michael
Composite keys support
I am not working on composite keys support at the moment.
So far I never needed it, I have cases where it could be used, but I prefer to introduce an extra primary key (usualy an id column) and set a unique constraint on the other multi-column PK. This is then so much easier to handle.
Nice extension...a few requests
you can set an associative array for a has_many, but not many_many. why not? this would be easier when dealing with json input
adding hasone functionality would be good too. hasone is essentially hasmany, but you would set a single model/object/associative array instead of an array of them. should also deal with null hasones
line 220, you get ALL the foreign models! what if there are 100,000? you could just let the database use its foreign key checks to ensure this.
doesn't rollback transaction if there is a database error?
thanks!
el_chief
procedural style does not fit the overall yii code style
I prefer https://github.com/yiiext/with-related-behavior
It has a generic object mapping for related entities and a common validation routine.
@el chief, requests
Thanx for feedback
For many_many relations this extension only manages the data in the connecting mn-table. It does not handle creation of the related objects (if it did, then passing associative arrays could be useful). Personally I only ever pass an array of primary keys (like array(1,6,9) - usually coming from a checkboxlist) to this kind of relation.
You can set a single object for has_many relations.
Currently there is no way to check for having passed exactly one object.
You can also set the relation to array() to delete all related objects.
There are some reasons why I get all foreign models:
The extension does not throw an error, when an invalid object-id is passed, the id is just ignored. If you do insert a wrong id in a DBMS like Postgres, a foreign key error will be thrown and the transaction automatically rolls back,
which was not the kind of behaviour I wanted.
The extension is mainly build for handling user input, i.e. the user clicking a few checkboxes to select related objects. When handling many records, this could be a performance issue, the the extension would have to be changed. Instead of getting all related objects, a SQL statement could be generated to get all valid ids. I may actually do this, its just more lines of code. Or as you suggested, the DB FK checks could be used.
Rollback for transaction errors is performed on line 290 (v1.5)
@jackfiallos (Added Delete Self record...)
Thanx for the feedback.
I have not included delete methods, because many2many-relations and hasMany-related objects can be delete by passing array() to the relation.
For deleting the object itself and the related objects I say:
When using a relational database, then you can setup the foreign keys in a way that related objects are automatically deleted as well.
Bug when saving a new record
If you use the 'saveWithRelated' method, and the model you're saving has errors, the behaviour is going to crash in the dbcriteria construction. To avoid this, you've encapsulate the foreach just after the second if clause:
... if ($saveModel && !$this->owner->save()) { // save owner model if saveWithRelated was called $result = false; } else { foreach ((array)$relations as $key => $relationName) { ...
Some corrections
Hi! Extension looks quite good, I've used something similar before, but by myself.
There are two things I modified:
1.As it was mentioned below: you get ALL the foreign models! what if there are 100,000?
I added param ensure. If you don't want to check existance of related models by loading them all, just use this:
$model->saveWithRelated( array('relationName1' => array('ensure' => false)));
2.When you use append param you will have to make sure that the new records are not dublicates of existing records, the behavior does not handle that.
I've modified code, so now rows are not dublicated in nmTable.
Modified code (to be inserted into ESaveRelatedBehavior.php starting from line 209):
if ($relation instanceof CManyManyRelation && !$this->owner->isNewRecord) { if (preg_match( // extract infos about mn linking table '/^\s*\{{0,2}\s*(.+?)\s*\}{0,2}\s*\(\s*(.+)\s*,\s*(.+)\s*\)\s*$/s', $relation->foreignKey, $matches )) { $info = array( 'mnTable' => $matches[1], 'mnFk1' => $matches[2], 'mnFk2' => $matches[3] ); } else { throw new CException("Unable to get table and foreign key information from MANY_MANY relation definition (".$relation->foreignKey.")"); } //ADDED: config param 'ensure': check for existance of related models or not. Default: true $ensure = isset($config['ensure']) ? $config['ensure']: true; $possibleModels = array(); if($ensure) { $model = new $relation->className; $possibleModels = $model->findAll(new CDbCriteria(array( // find all models, that can be related (used to make sure only existing records are linked) 'index' => $model->getMetaData()->tableSchema->primaryKey ))); } //END ADDED if (!@$config['append']) { $criteria = new CDbCriteria; $criteria->compare($info['mnFk1'], $this->owner->primaryKey); $commandBuilder->createDeleteCommand($info['mnTable'], $criteria)->execute(); // delete current links to related model } else { //ADDED: select keys for existing related records $criteria = new CDbCriteria; $criteria->compare($info['mnFk1'], $this->owner->primaryKey); $criteria->select = $info['mnFk2']; $nmRows = $commandBuilder->createFindCommand($info['mnTable'], $criteria)->queryColumn(); //END ADDED } $hasMnTableClass = @class_exists($info['mnTable']); foreach($data as $id) { if (is_object($id)) { // get id if object was given $id = $id->primaryKey; } //ADDED: if requred - check that related model exists if($ensure && !array_key_exists($id, $possibleModels)) continue; //if record in nm already exists --> do not insert it twice if(isset($nmRows) && in_array($id, $nmRows)) { unset($possibleModels[$id]); continue; } //END ADDED if ($hasMnTableClass) { // use class for inserting records into mn linking table if it exists $obj = new $info['mnTable']; $obj->attributes = array( $info['mnFk1'] => $this->owner->primaryKey, $info['mnFk2'] => $id ); if (!$obj->save()) { $result = false; } } else { // otherwise make and execute insert command $commandBuilder->createInsertCommand( $info['mnTable'], array( $info['mnFk1'] => $this->owner->primaryKey, $info['mnFk2'] => $id ) )->execute(); } unset($possibleModels[$id]); // this makes sure that id will not be inserted twice if submitted data attempts to do so. } if ($result) { unset($this->owner->$relationName); // saving was successful, clear the relation, so accessing it will return the related records } } elseif ($relation instanceof CHasManyRelation) { // Handle has_many relations ... }
It would be nice if author will add these features to extension!
cheers
Great!
Fab extension. Well done, very handy!
Not Run
CAN NOT go smoothly IN Yii VERSION 1.1.10, PLEASE upgrading EXTENSION
Answers to comments
@sucotronic (Bug when saving a new record)
This has been fixed last year with version v1.3 (see update comments)
Please check if you are using the latest version
@melengo (Not Run)
I am using the extention with Yii 1.1.10 and did not have problems so far.
Could you explain the errors that you get?
Not Compatible with Table Prefix
If the linking table is a table name with prefix, say {{some_table}}, your regular expression with only retrieve the "some_table" without "{{}}" and result error in getting table name. I suggest to change the regex to this:
'/^\s({{0,2}\s.+?\s}{0,2})\s(\s(.+)\s,\s(.+)\s)\s*$/s'
This will include the "{{}}".
Safe attributes.
There's a problem with line 276.
$obj->attributes = is_object($value) ? $value->attributes : $value;
This means that only safe attributes will be saved, which is probably not desired behaviour.
I may have other attributes to save.
Scenario
When copying the models, it may be a good idea to copy 'scenario' property, because there may be some work scenario specific done in beforeSave().
Answer to comments
@undsof: For this extension it actually was desired behavior to only save safe attributes for the related models. This data usually comes from a web form. But you are right, there could be situations in which you would like to set extra attributes on the array that do not come from user input. If I'll ever need that functionality I will include it in the extension. For the moment I will keep it simple.
Error when saving
Hi, I'm having error saving. My table are as below:-
My Article model has the following relation:-
'tags' => array(self::MANY_MANY, 'Tag', 'article_tag(tag_id, article_id)'),
However, when I save, I get the following error.
Table "tags" does not have a column named "article_tag(tag_id, article_id)".
Is there something I'm missing here.
bug
Anonymous Joe, i have the same bug.
I removed the bug so
ESaveRelatedBehavior.php line 262
} elseif ($relation instanceof CHasManyRelation && $this->owner->primaryKey) {
http://badphpcoder.blogspot.ru/2013/05/yii-savewithrelated-bug.html
but I do not know how much this decision right
Anser to comments
@Anonymous Joe
Thank you for the bug report, today I got the same problem.
@Pasta
Thank you for your suggestion to fix the bug.
The code change does remove the bug but will also remove a feature:
On HAS_MANY relations the saved related objects should still be
validated even when the parent object had errors on saving.
I fixed the bug and uploaded a new version
Detailed tutorial for saving multiple related models using ESaveRelatedBehavior component
http://scriptbaker.com/how-to-save-multiple-related-models-in-yii-complete-solution/
Bug
when the model has another connection to the database
protected function saveR($relations, $saveModel) { $connection = $this->owner->dbConnection; $result = true; $t = false; if (!$connection->currentTransaction) { // only start transaction if none is running already $t = $connection->beginTransaction(); }
patch sugested
I have a MANY_MANY relation, and when I call saveRelated(), on an object, sudden crash appear.
I start digging into ESaveRelatedBehavior code.
I have discovered that for a MANY_MANY relation, the following code is called:
$possibleModels = $model->findAll(new CDbCriteria(array( // find all models, that can be related (used to make sure only existing records are linked) 'index' => $model->getMetaData()->tableSchema->primaryKey )));
As the comment said, this is used for make sure only existing records are linked.
That is correct, but with huge impact on the performance, because all the activeRecords form the related database would be instantiated.
I think it is sufficient to extract only the activeRecords that are candidate for update, to check if they exists:
svn diff ESaveRelatedBehavior.php Index: ESaveRelatedBehavior.php =================================================================== --- ESaveRelatedBehavior.php (revision 1283) +++ ESaveRelatedBehavior.php (working copy) @@ -220,9 +220,21 @@ throw new CException("Unable to get table and foreign key information from MANY_MANY relation definition (".$relation->foreignKey.")"); } $model = new $relation->className; - $possibleModels = $model->findAll(new CDbCriteria(array( // find all models, that can be related (used to make sure only existing records are linked) + + $searchIds = array(); + + foreach($data as $id) { + if (is_object($id)) { + $searchIds[] = $id->primaryKey; + } else { + $searchIds[] = $id; + } + } + + $possibleModels = $model->findAllByPk($searchIds, new CDbCriteria(array( // find all models, that can be related (used to make sure only existing records are linked) 'index' => $model->getMetaData()->tableSchema->primaryKey ))); + if (!@$config['append']) { $criteria = new CDbCriteria; $criteria->compare($info['mnFk1'], $this->owner->primaryKey); @@ -291,4 +303,4 @@ } return $result; } -} \ No newline at end of file +}
Hello,
I have in db 1 father and 1 children assigned to father. When i try to update father and delete one and only children (just like in: http://scriptbaker.com/how-to-save-multiple-related-models-in-yii-complete-solution/) father is saved but children is not deleted from db.
Can you help me ?
Thanks :)
If you have any questions, please ask in the forum instead.
Signup or Login in order to comment.